From 1b6a78de18d4ef5f95c3c1f9f4bec71b96660c9e Mon Sep 17 00:00:00 2001 From: Eric Allam Date: Sat, 8 Aug 2026 07:19:38 +0100 Subject: [PATCH] feat(sdk,slack): webhook sources, agent channels, and human-in-the-loop --- .changeset/hosted-webhook-ingress.md | 14 + docs/ai-chat/backend.mdx | 31 + docs/ai-chat/reference.mdx | 40 + docs/docs.json | 13 + docs/webhooks/channels.mdx | 133 ++++ docs/webhooks/connect.mdx | 35 + docs/webhooks/deliveries.mdx | 48 ++ docs/webhooks/filters.mdx | 99 +++ docs/webhooks/human-in-the-loop.mdx | 143 ++++ docs/webhooks/overview.mdx | 96 +++ docs/webhooks/session-routing.mdx | 96 +++ docs/webhooks/sources.mdx | 115 +++ packages/cli-v3/src/dev/devSupervisor.ts | 13 +- .../src/entryPoints/dev-index-worker.ts | 2 + .../src/entryPoints/managed-index-worker.ts | 2 + packages/slack/package.json | 76 ++ packages/slack/src/index.test.ts | 307 +++++++ packages/slack/src/index.ts | 409 ++++++++++ packages/slack/tsconfig.json | 8 + packages/slack/tsconfig.src.json | 12 + packages/slack/vitest.config.ts | 8 + packages/trigger-sdk/src/v3/ai.ts | 747 +++++++++++++++++- .../src/v3/channelReactions.test.ts | 39 + packages/trigger-sdk/src/v3/chat.ts | 18 + packages/trigger-sdk/src/v3/webhooks.ts | 325 +++++++- pnpm-lock.yaml | 123 ++- 26 files changed, 2940 insertions(+), 12 deletions(-) create mode 100644 .changeset/hosted-webhook-ingress.md create mode 100644 docs/webhooks/channels.mdx create mode 100644 docs/webhooks/connect.mdx create mode 100644 docs/webhooks/deliveries.mdx create mode 100644 docs/webhooks/filters.mdx create mode 100644 docs/webhooks/human-in-the-loop.mdx create mode 100644 docs/webhooks/overview.mdx create mode 100644 docs/webhooks/session-routing.mdx create mode 100644 docs/webhooks/sources.mdx create mode 100644 packages/slack/package.json create mode 100644 packages/slack/src/index.test.ts create mode 100644 packages/slack/src/index.ts create mode 100644 packages/slack/tsconfig.json create mode 100644 packages/slack/tsconfig.src.json create mode 100644 packages/slack/vitest.config.ts create mode 100644 packages/trigger-sdk/src/v3/channelReactions.test.ts diff --git a/.changeset/hosted-webhook-ingress.md b/.changeset/hosted-webhook-ingress.md new file mode 100644 index 00000000000..82e5f06fd5b --- /dev/null +++ b/.changeset/hosted-webhook-ingress.md @@ -0,0 +1,14 @@ +--- +"@trigger.dev/core": minor +"@trigger.dev/sdk": minor +"@trigger.dev/slack": minor +"trigger.dev": minor +--- + +Add hosted webhooks: receive and verify provider webhooks as a task, with no ingress or verification code of your own. + +- `webhook()` declares an endpoint that routes a verified, typed event to an `onEvent` handler. Choose a source with a preset (`webhooks.stripe()`, `webhooks.github()`, and others) or `webhooks.custom(config)`. Declared webhooks are discovered like tasks and synced to a hosted URL on deploy. +- `filter` gates which deliveries run, using a type-safe expression checked against the event at author time (`event.`/`header.`/`webhook.` paths, `&&`/`||`, comparison and `in`/`contains` operators, field-to-field comparison, and array quantifiers). A non-matching delivery is still recorded, not routed. +- `chat.event({ source, key, type })` routes deliveries that share a `key` to one durable session (per customer, installation, or issue) and delivers them to an agent's `onAction` as a typed envelope. +- Channels turn a chat surface into an agent frontend: `chat.channels.custom({ source, key, inbound, send })`, or the new `@trigger.dev/slack` package's `slack()` (Slack Events API verification, per-thread sessions, `chat.postMessage`/`chat.update` egress, `mentions()`, `startOn`, lifecycle reactions). Inbound messages run as turns and the reply posts back. Human-in-the-loop is built in: a tool with no `execute` pauses the turn, the connector posts controls (Slack ships Approve / Deny buttons), and a verified click resolves the tool and resumes the run. +- HTTP API for listing webhook endpoints and deliveries, plus rotate-secret, enable/disable, and replay. diff --git a/docs/ai-chat/backend.mdx b/docs/ai-chat/backend.mdx index c055570ef16..4aa8fdb14d4 100644 --- a/docs/ai-chat/backend.mdx +++ b/docs/ai-chat/backend.mdx @@ -470,6 +470,37 @@ Custom actions let the frontend send structured commands (undo, rollback, edit, See [Actions](/ai-chat/actions). +### Webhook events and channels + +Two `chat.agent()` options wire an agent to verified inbound webhooks. `events` claims [`chat.event(...)`](/webhooks/session-routing) descriptors: each verified delivery is routed to this agent's session and arrives at `onAction` as an action (not a turn), so [session routing](/webhooks/session-routing) decides which conversation it lands on. `channels` claims channel connectors that turn an external chat surface into a frontend for the agent: an inbound message runs as a turn through `run()` and the reply is posted back. `slack()` ships in `@trigger.dev/slack`, and `chat.channels.custom(...)` builds a connector for any source without a preset. + +```ts +import { webhooks } from "@trigger.dev/sdk"; +import { chat } from "@trigger.dev/sdk/ai"; +import { slack } from "@trigger.dev/slack"; + +export const orderEvents = chat.event({ + id: "order-events", + source: webhooks.stripe(), + key: "{body.data.object.customer}", + type: "order.event", +}); + +export const myChat = chat.agent({ + id: "my-chat", + events: [orderEvents], + channels: [slack({ id: "support-slack", token: process.env.SLACK_BOT_TOKEN! })], + onAction: async ({ action }) => { + // A verified order-events delivery arrives here as an action. + }, + run: async (payload) => { + // Inbound Slack messages run here as normal turns. + }, +}); +``` + +See [session routing](/webhooks/session-routing) and [channels](/webhooks/channels). For the interactive approvals layer, where a turn pauses on a human decision (buttons in the thread) and resumes on the click, see [human-in-the-loop](/webhooks/human-in-the-loop). + ### Chat history Imperative API for reading and modifying the accumulated message history. Works from any hook (`onAction`, `onTurnStart`, `onBeforeTurnComplete`, `onTurnComplete`, `hydrateMessages`) or from `run()` and AI SDK tools. diff --git a/docs/ai-chat/reference.mdx b/docs/ai-chat/reference.mdx index da08f9a0473..fa5f6201691 100644 --- a/docs/ai-chat/reference.mdx +++ b/docs/ai-chat/reference.mdx @@ -47,6 +47,8 @@ Options for `chat.agent()`. | `hydrateMessages` | `(event: HydrateMessagesEvent) => UIMessage[] \| Promise` | — | Load message history from backend, replacing the linear accumulator. See [hydrateMessages](/ai-chat/lifecycle-hooks#hydratemessages) | | `actionSchema` | `TaskSchema` | — | Schema for validating custom actions sent via `transport.sendAction()`. See [Actions](/ai-chat/actions) | | `onAction` | `(event: ActionEvent) => Promise \| unknown` | — | Handle custom actions. Actions are not turns — only `hydrateMessages` + `onAction` fire. Return a `StreamTextResult` (or `string` / `UIMessage`) for a model response; return `void` for side-effect-only. See [Actions](/ai-chat/actions) | +| `events` | `ChatEvent[]` | — | Webhook event descriptors (from `chat.event()`) whose verified deliveries are routed to this agent as actions and handled in `onAction`. See [session routing](/webhooks/session-routing). | +| `channels` | `ChannelConnector[]` | — | Channel connectors (for example `slack()`) that turn an external chat surface into a frontend for the agent: inbound messages run as turns and the reply posts back. See [channels](/webhooks/channels). | | `onTurnStart` | `(event: TurnStartEvent) => Promise \| void` | — | Fires every turn before `run()` | | `onBeforeTurnComplete` | `(event: BeforeTurnCompleteEvent) => Promise \| void` | — | Fires after response but before stream closes. Includes `writer`. | | `onTurnComplete` | `(event: TurnCompleteEvent) => Promise \| void` | — | Fires after each turn completes (stream closed) | @@ -501,6 +503,8 @@ All methods available on the `chat` object from `@trigger.dev/sdk/ai`. | Method | Description | | ------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------- | | `chat.agent(options)` | Create a chat agent | +| `chat.event(options)` | Declare an inbound webhook event descriptor an agent claims via `chat.agent({ events })`. See [session routing](/webhooks/session-routing). | +| `chat.channels.custom(options)` | Create a generic chat-frontend channel over any verified webhook source (you supply the egress). The `slack()` preset ships in `@trigger.dev/slack`. See [channels](/webhooks/channels). | | `chat.createSession(payload, options)` | Create an async iterator for chat turns | | `chat.pipe(source, options?)` | Pipe a stream to the frontend (from anywhere inside a task) | | `chat.pipeAndCapture(source, options?)` | Pipe and capture the response; returns `{ message, status, error }` | @@ -530,6 +534,42 @@ All methods available on the `chat` object from `@trigger.dev/sdk/ai`. | `chat.withUIMessage(config?)` | Returns a [ChatBuilder](/ai-chat/types#chatbuilder) with a fixed `UIMessage` subtype. See [Types](/ai-chat/types) | | `chat.withClientData({ schema })` | Returns a [ChatBuilder](/ai-chat/types#chatbuilder) with a fixed client data schema. See [Types](/ai-chat/types#typed-client-data-with-chatwithclientdata) | +## `chat.event` + +Declare an inbound webhook event that an agent claims via [`events`](#chatagentoptions) on `chat.agent()`. It is a descriptor only, with no handler: it names a [source](/webhooks/sources) to verify, a `key` template that resolves each delivery to a durable [session](/ai-chat/sessions), and an optional `type` label (defaults to the descriptor `id`). Verified deliveries are routed to that session and arrive at `onAction` as a `{ type, event, source, headers, deliveryId }` envelope, not as a chat turn. See [session routing](/webhooks/session-routing). + +```ts +import { webhooks } from "@trigger.dev/sdk"; +import { chat } from "@trigger.dev/sdk/ai"; + +export const orderEvents = chat.event({ + id: "order-events", + source: webhooks.stripe(), + key: "{body.data.object.customer}", + type: "order.event", +}); +``` + +## `chat.channels.custom` + +Create a generic chat-frontend channel over any verified [source](/webhooks/sources), claimed via [`channels`](#chatagentoptions) on `chat.agent()`. You supply the session `key`, the `inbound` map from event to turn message, and your own `send` egress that posts the reply back, so the whole round-trip is under your control. Inbound messages run as normal turns and the reply is posted back. The `slack()` preset ships in `@trigger.dev/slack` and wires the egress for you. See [channels](/webhooks/channels), and the interactive approvals layer at [human-in-the-loop](/webhooks/human-in-the-loop). + +```ts +import { webhooks } from "@trigger.dev/sdk"; +import { chat } from "@trigger.dev/sdk/ai"; + +export const mySurface = chat.channels.custom({ + id: "my-surface", + source: webhooks.custom({ /* verifier config */ }), + key: "{body.conversationId}", + inbound: (event) => event.text, + send: async (message, ctx) => { + const ref = await postToMySurface(ctx.event, message.text, ctx.previousRef); + return { ref }; + }, +}); +``` + ## `chat.withUIMessage` Returns a [`ChatBuilder`](/ai-chat/types#chatbuilder) with a fixed `UIMessage` subtype. Chain `.withClientData()`, hook methods, and `.agent()`. diff --git a/docs/docs.json b/docs/docs.json index 609ff7b3e16..076a2989367 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -147,6 +147,19 @@ } ] }, + { + "group": "Webhooks", + "pages": [ + "webhooks/overview", + "webhooks/sources", + "webhooks/connect", + "webhooks/deliveries", + "webhooks/filters", + "webhooks/session-routing", + "webhooks/channels", + "webhooks/human-in-the-loop" + ] + }, { "group": "Configuration", "pages": [ diff --git a/docs/webhooks/channels.mdx b/docs/webhooks/channels.mdx new file mode 100644 index 00000000000..eb1d36ff499 --- /dev/null +++ b/docs/webhooks/channels.mdx @@ -0,0 +1,133 @@ +--- +title: "Channels (chat frontends)" +description: "Point Slack (or any chat surface) at an agent: messages become turns and replies post back." +sidebarTitle: "Channels" +--- + +A [session route](/webhooks/session-routing) delivers a verified event to an agent as an [action](/ai-chat/actions): the agent reacts, and the response is a side effect. A **channel** is the other half: the webhook IS the chat surface. Inbound messages become **turns** (the normal `run()` loop), and the agent's reply is posted **back** to the surface. A Slack thread becomes a real conversation with the agent, exactly like the browser chat, just a different frontend. + +List channels on a [`chat.agent`](/ai-chat/overview) alongside (or instead of) `events`: + +```ts +import { chat } from "@trigger.dev/sdk/ai"; +import { slack } from "@trigger.dev/slack"; + +export const supportAgent = chat.agent({ + id: "support-agent", + channels: [slack({ id: "support-slack", token: process.env.SLACK_BOT_TOKEN! })], + run: async ({ messages }) => streamText({ model: anthropic("claude-sonnet-4-5"), messages }), +}); +``` + +The `run()` loop is unchanged: the agent does not know or care that it is talking to Slack. One verified Slack message in a thread is routed to a durable [session](/ai-chat/sessions) keyed to that thread, run as a turn, and the reply is posted into the thread. + +## Slack + +`slack()` (from `@trigger.dev/slack`) is a channel connector: it verifies inbound Slack events, maps a message to the turn, and posts the reply back with `chat.postMessage` / `chat.update`. + + + + Create an app at [api.slack.com/apps](https://api.slack.com/apps). Add the `chat:write` bot scope and install it to your workspace to get a bot token (`xoxb-...`). + + + Deploying registers a hosted [endpoint](/webhooks/connect) for the channel. Set its signing secret to your Slack app's **Signing Secret**, and pass the bot token as `token`. + + + In the app's **Event Subscriptions**, set the request URL to the endpoint's webhook URL. Slack sends a one-time `url_verification` handshake, which the endpoint answers automatically. Subscribe the bot to `message.channels`, then invite the bot to the channel (`/invite @yourapp`). + + + +By default `slack()` keys one session per thread, strips the leading bot mention from the message, posts an "on it..." placeholder while the agent works, and edits it to the answer. Override any of that: + +```ts +slack({ + id: "support-slack", + token: process.env.SLACK_BOT_TOKEN!, + // ignore anything but questions (composed with the built-in self-message guard) + filter: "event.event.text contains '?'", + inbound: (e) => e.event?.text ?? "", + outbound: (reply) => ({ text: reply.text }), + ack: (e) => ({ text: "thinking..." }), // pass `null` to post only the final answer +}); +``` + + + `slack()` always drops the bot's own messages (and their edits) before they reach the agent, so the + agent never replies to itself. A multi-workspace app can pass a `token` resolver keyed on the event's + team instead of a single string. + + +### Summoning with a mention + +By default `slack()` starts (or resumes) a session for every non-bot message in a subscribed channel. To make the agent respond only when it is @mentioned, pass `startOn` with the `mentions` helper. The first mention in a thread starts the session, and the agent then follows the rest of the thread without needing to be mentioned again. + +```ts +import { slack, mentions } from "@trigger.dev/slack"; + +slack({ + id: "support-slack", + token: process.env.SLACK_BOT_TOKEN!, + startOn: mentions("U012BOT"), // your bot's user id (pass several for multiple bots) +}); +``` + +### Reacting to messages + +`slack()` can add an emoji reaction to the triggering message to signal progress. Set `reactions` with any of `working`, `done`, and `error`: the connector adds `working` when the turn starts, swaps it to `done` when the turn finishes, and reacts with `error` if it fails. This needs the `reactions:write` scope. + +```ts +slack({ + id: "support-slack", + token: process.env.SLACK_BOT_TOKEN!, + reactions: { working: "eyes", done: "white_check_mark", error: "warning" }, +}); +``` + +### Options + +| Option | Type | Description | +| --- | --- | --- | +| `id` | `string` | Connector id, unique per agent. | +| `token` | `string` or resolver | Bot token (`xoxb-...`), or a function of the event's team for multi-workspace apps. | +| `key` | `string` | Session [key](/webhooks/session-routing) template. Defaults to one session per thread. | +| `filter` | `string` | Extra [filter](/webhooks/filters), composed with the built-in self-message guard. | +| `startOn` | `string` | Only start a session when the event matches (see `mentions`). Existing sessions always resume. | +| `ack` | message, `null`, or function | Placeholder posted while the agent works. Pass `null` to post only the final answer. | +| `reactions` | `{ working?, done?, error? }` | Lifecycle emoji reactions on the triggering message. | +| `inbound` / `outbound` | functions | Map the Slack event to the turn, and the reply to a Slack message. | +| `delivery` | `"final"` or `"stream"` | `"final"` (default) posts a placeholder and edits it to the answer. `"stream"` edits live as the reply streams. | +| `apiBaseUrl` | `string` | Override the Slack Web API base, for testing against a mock. | + +## Approvals and interactive controls + +An agent on a channel can pause a turn to get a human decision, approving a refund or confirming a deletion, and resume once someone clicks a button in the thread. `slack()` renders Approve / Deny buttons for you and collapses them to the decision once clicked. See [human-in-the-loop](/webhooks/human-in-the-loop). + +## Any surface: `chat.channels.custom` + +For a surface without a preset, `chat.channels.custom` is the generic connector. You supply the [source](/webhooks/sources) to verify, the session `key`, the `inbound` map, and the egress `send`: + +```ts +import { chat } from "@trigger.dev/sdk/ai"; +import { webhooks } from "@trigger.dev/sdk"; + +const mySurface = chat.channels.custom({ + id: "my-surface", + source: webhooks.custom({ /* verifier config */ }), + key: "{body.conversationId}", + inbound: (e) => e.text, + outbound: (reply) => (reply.text ? { text: reply.text } : null), // null posts nothing + send: async (message, ctx) => { + const ref = await postToMySurface(ctx.event, message.text, ctx.previousRef); + return { ref }; // an existing ref means edit-in-place on the next turn + }, +}); +``` + +`send` is called to post the reply. `ctx.previousRef` is the ref you returned last time, so streaming or a follow-up edits the same message instead of posting a new one. Return `null` from `outbound` to stay silent (a tool-only turn, say). + +## Channels vs events + +Both are inbound surfaces on a `chat.agent`, and an agent can list both: + +- [`events`](/webhooks/session-routing) (`chat.event`): the webhook is a signal. Delivered to `onAction`; the agent acts, no reply is sent back. +- `channels` (`slack`, `chat.channels.custom`): the webhook is a chat frontend. Delivered as a turn to `run()`; the reply is posted back. diff --git a/docs/webhooks/connect.mdx b/docs/webhooks/connect.mdx new file mode 100644 index 00000000000..0f6430e117a --- /dev/null +++ b/docs/webhooks/connect.mdx @@ -0,0 +1,35 @@ +--- +title: "Connecting a provider" +description: "Point a provider at the webhook URL and set the signing secret." +sidebarTitle: "Connecting a provider" +--- + +When you deploy (or run `dev`), each webhook task gets an **endpoint** with a unique, unguessable webhook URL. Open the webhook in the dashboard, go to **Endpoints**, and open the endpoint to find its **Connect** panel. + + + + Copy it from the endpoint's Connect panel. On Trigger.dev Cloud it looks like + `https://webhooks.trigger.dev/webhooks/v1/ingest/`. A self-hosted instance serves it from that + instance's own base URL. This is what you give the provider as its webhook destination. + + + A webhook can't accept deliveries until its signing secret is set. Until then every request is + rejected. There are two flows, and the Connect panel shows the right one for the provider: + + - **The provider generates the secret** (Stripe, Svix): copy it from the provider and paste it + into **Set secret**. + - **You choose the secret** (GitHub, or a service you control): click **Generate secret** and + Trigger.dev mints a strong secret and shows it once. Paste that into the provider's webhook config. + + + Add the webhook URL as the destination in your provider's dashboard. The Connect panel + shows the exact signature scheme (header, algorithm, signing string) the provider should use. + + + + + The signing secret is stored encrypted and is never shown again after it's set. To rotate it, + use **Rotate secret** (or **Regenerate**) and update the provider with the new value. + + +Once a provider is sending events, watch them arrive on the [Deliveries](/webhooks/deliveries) page, which also explains what an [endpoint](/webhooks/deliveries#endpoints) is. diff --git a/docs/webhooks/deliveries.mdx b/docs/webhooks/deliveries.mdx new file mode 100644 index 00000000000..95fcbed6519 --- /dev/null +++ b/docs/webhooks/deliveries.mdx @@ -0,0 +1,48 @@ +--- +title: "Deliveries and endpoints" +description: "Observe inbound webhook requests, the runs they trigger, and their payloads in the dashboard." +sidebarTitle: "Deliveries & endpoints" +--- + +The dashboard surfaces two concepts under the **Webhooks** section. + +## Deliveries + +A delivery is a single inbound request that passed verification. The **Deliveries** page lists every +delivery across all your webhooks (much like the Runs page), and you can filter by webhook, status, +delivery id, or run id. + +Open a delivery to see: + +- Its **status** and the **run** it triggered (linked). +- The verified **event payload** and the inbound **request headers**, on separate tabs. +- The external delivery id, idempotency key, and timestamps. + + + Duplicate deliveries are deduplicated automatically. The idempotency key is the provider's event id + (e.g. the Stripe event id, or GitHub's `X-GitHub-Delivery`), so a provider retry of the same event + resolves to the original delivery and won't trigger a second run. + + +## Endpoints + +An endpoint is the connection instance for a webhook: its webhook URL, signing-secret state, +verification scheme, and delivery history. Each webhook's **Endpoints** tab lists its endpoints (a +declared webhook has one), and opening an endpoint shows its [Connect panel](/webhooks/connect) and +its scoped deliveries. + +## What happens to a request + + + + The signature, timestamp, and idempotency key are checked. A failure returns `400` and records + nothing. + + + A verified request becomes a delivery, with its parsed event and headers stored. + + + The delivery is routed to your webhook task, which runs and calls `onEvent`. The delivery's status + reflects that run's outcome. + + diff --git a/docs/webhooks/filters.mdx b/docs/webhooks/filters.mdx new file mode 100644 index 00000000000..4ab7f7f8483 --- /dev/null +++ b/docs/webhooks/filters.mdx @@ -0,0 +1,99 @@ +--- +title: "Filtering deliveries" +description: "Gate which verified webhook deliveries run, with a type-safe filter checked against the event." +sidebarTitle: "Filters" +--- + +By default every verified delivery runs your `onEvent` (or routes to a [session](/webhooks/session-routing)). A **filter** is a server-side predicate that decides whether a delivery is routed at all. A delivery that does not match is still received and recorded, it just does not run anything. + +Filtering happens at the endpoint, before any run is triggered, so a filtered-out event costs you nothing. + +## Adding a filter + +Pass a `filter` string to `webhook()`. It is a small expression checked, at build time, against the event shape from your [source](/webhooks/sources#typing-the-event): + +```ts +import { webhook, webhooks } from "@trigger.dev/sdk"; + +export const onOrder = webhook({ + id: "orders", + source: webhooks.stripe(), + // only route succeeded payment intents over $100 + filter: "event.type == 'payment_intent.succeeded' && event.data.object.amount >= 10000", + onEvent: async ({ event }) => { + // only runs for deliveries that matched + }, +}); +``` + +The filter is type-safe: referencing a field that does not exist, or comparing it to the wrong kind of literal, is a compile error, not a runtime surprise. + +## What a non-match does + +A delivery that does not match is **not dropped**. It still returns `200` to the provider and is recorded as a [delivery](/webhooks/deliveries) with the status `FILTERED` and a reason naming the clause that failed (and the value it saw). It just never triggers a run. This keeps a filtered delivery auditable: you can see in the dashboard that it arrived and why it was not routed. + + + If a filter throws while evaluating (for example, a malformed event), the delivery is routed rather + than dropped. Filters fail open so a filter bug never silently swallows real events. + + +## The expression language + +A filter is one or more `path operator value` clauses combined with `&&` and `||` (use parentheses to group). + +### Paths + +A path reads from one of three namespaces: + +- `event.*`: the verified, parsed request body, for example `event.data.object.amount`. +- `header.*`: an inbound request header, matched case-insensitively, for example `header.x-github-event`. +- `webhook.*`: endpoint metadata (`webhook.source`, `webhook.id`, `webhook.deliveryId`, and for per-tenant endpoints `webhook.externalRef` / `webhook.tenantId`). + +The [session routing](/webhooks/session-routing) key template addresses this same parsed body, but spells it `{body.*}` rather than `event.*`. + +### Operators + +| Operator | Meaning | +| --- | --- | +| `==` `!=` | equality | +| `>` `<` `>=` `<=` | numeric comparison | +| `in` `not in` | membership in a list, for example `event.type in ['a','b']` | +| `startsWith` `endsWith` `contains` | string matching | + +Values are strings in single quotes (`'created'`), numbers (`10000`), booleans (`true`), or a list for `in` / `not in`. + +### Comparing two fields + +The right-hand side can be another path instead of a literal, so you can compare two fields of the same event: + +```ts +filter: "event.billing.country == event.shipping.country"; +``` + +### Matching inside a list + +`any` and `all` quantify over an array, testing a sub-path on each element: + +```ts +// route only if at least one line item has a positive quantity +filter: "event.items any ( quantity > 0 )"; +``` + +### Spacing + +The type checker reads the filter as a token stream, so a couple of spots are strict about spacing: keep `in` / `not in` lists unspaced (`['a','b']`, not `[ 'a', 'b' ]`) and put spaces around the quantifier parentheses (`any ( ... )`). + +To match only certain event types, write a clause against the field that carries the type: `event.type` for Stripe / Svix / Square / Discord, or the `x-github-event` header for GitHub (the filter DSL can read a `header.` namespace too): + +```ts +export const onGithub = webhook({ + id: "github", + source: webhooks.github(), + filter: "header.x-github-event in ['issues','pull_request']", + onEvent: async ({ event }) => {}, +}); +``` + +## Filtering a session route + +A `filter` works the same way on [`chat.event`](/webhooks/session-routing): a non-matching delivery is recorded `FILTERED` and never reaches the session. diff --git a/docs/webhooks/human-in-the-loop.mdx b/docs/webhooks/human-in-the-loop.mdx new file mode 100644 index 00000000000..1fc52cbce61 --- /dev/null +++ b/docs/webhooks/human-in-the-loop.mdx @@ -0,0 +1,143 @@ +--- +title: "Human-in-the-loop" +sidebarTitle: "Human-in-the-loop" +description: "Pause an agent mid-turn to get a human decision from the channel (Slack approve/deny), then resume with the answer." +--- + +**An agent on a [channel](/webhooks/channels) can pause a turn to get a human decision, then resume once someone answers in the thread.** It posts controls into the conversation and picks up where it left off once someone clicks, with the decision merged into the turn. This is the channel counterpart of browser [human-in-the-loop](/ai-chat/patterns/human-in-the-loop): the same no-`execute` tool, but the controls live in Slack (or your own surface) instead of a React component. The [`slack()`](/webhooks/channels) connector ships Approve / Deny buttons out of the box. + +## How it works + +The building block is a tool with no `execute` function. When the model calls it, the turn completes with the tool call still pending instead of resolving it. Over a channel the framework then takes over the round-trip: + +```mermaid +sequenceDiagram + participant U as User + participant S as Slack + participant A as Agent run + U->>S: Message in a thread + S->>A: Verified delivery starts a turn + A->>A: Model calls requestApproval (no execute) + A->>S: renderInteraction posts Approve / Deny + Note over A: Turn completes, run suspends (no compute while waiting) + U->>S: Clicks a button + S->>A: Signed block_actions callback to the same webhook URL + A->>A: onInteraction resolves the pending tool + A->>S: finalizeInteraction collapses the buttons + A->>A: Run resumes, model continues + A->>S: Reply posts back to the thread +``` + +Because it is a no-`execute` pause, the run suspends while it waits rather than holding compute. A human can take minutes or days to decide without burning compute or hitting [`maxDuration`](/runs/max-duration). The mechanics are the same as the browser case, covered in [human-in-the-loop](/ai-chat/patterns/human-in-the-loop#duration-and-cost-while-paused). + +## Slack approvals + +Out of the box, `slack()` posts Approve / Deny buttons for any pending no-`execute` tool. A click resolves to `{ approved: boolean }` and the buttons collapse to the decision. You define the tool and list the channel on the agent: + +```ts trigger/support-agent.ts +import { chat } from "@trigger.dev/sdk/ai"; +import { slack } from "@trigger.dev/slack"; +import { streamText, tool } from "ai"; +import { anthropic } from "@ai-sdk/anthropic"; +import { z } from "zod"; + +// No execute: calling this pauses the turn for a human decision. +const requestApproval = tool({ + description: + "Request human approval before a sensitive or irreversible action. " + + "Call this and stop; you will receive { approved: boolean }, then proceed or decline.", + inputSchema: z.object({ + action: z.string().describe("A short description of the action needing approval"), + }), +}); + +export const supportAgent = chat.agent({ + id: "support-agent", + channels: [slack({ id: "support-slack", token: process.env.SLACK_BOT_TOKEN! })], + tools: { requestApproval }, + run: async ({ messages, tools }) => + streamText({ + model: anthropic("claude-sonnet-4-5"), + messages, + tools, + system: + "Before any sensitive or irreversible action (refunds, cancellations, deletions), " + + "call requestApproval and wait. If approved, confirm it is done; if denied, decline.", + }), +}); +``` + +Button clicks arrive on a different Slack API than messages, so there is one extra setup step beyond [connecting the channel](/webhooks/channels): + + + + In the Slack app's **Interactivity & Shortcuts**, enable interactivity and set the Request URL to the **same** webhook URL you used for events. Slack posts button clicks there as a signed `block_actions` callback, which the endpoint verifies and routes to the paused run. + + + Posting and editing the controls uses the `chat:write` scope you already added for replies. No extra scope is needed to collapse the buttons after a decision. + + + +When the model calls `requestApproval`, the bot posts "Approval needed" with the action and Approve / Deny buttons. Clicking **Approve** resolves the tool to `{ approved: true }`, the buttons collapse to "Approved by @you", and the agent continues and posts the outcome. **Deny** resolves `{ approved: false }`. + +## Customizing the controls + +For [`chat.channels.custom`](/webhooks/channels), or to override the Slack defaults, three hooks own the interaction round-trip: + + + Map the pending tool call(s) to the controls you post. `pending` is a list of `{ toolCallId, toolName, input }`. Return `null` to skip posting controls. + + + + Map a verified callback event to a resolution. The `output` is stitched onto the pending tool call matched by `toolCallId` and the run resumes. Return `null` to treat the event as a normal message instead (a new turn). + + + + Collapse the controls after a decision so they cannot be clicked again. Best effort: a failure here is logged and the run still resumes. + + +Slack encodes the decision in each button's `value` as `${toolCallId}::approve|deny` so `onInteraction` can resolve the exact call, and `finalizeInteraction` posts to the interaction's `response_url` with `replace_original` to swap the buttons for the outcome. On a custom surface you choose the encoding and how you edit the controls away. + +```ts +chat.channels.custom({ + id: "my-surface", + source: webhooks.custom({ /* verifier config */ }), + key: "{body.conversationId}", + inbound: (e) => e.text, + send: async (message, ctx) => ({ ref: await postToMySurface(ctx.event, message) }), + renderInteraction: (pending) => ({ + text: `Approve: ${pending[0].input.action}?`, + buttons: pending.map((c) => [`${c.toolCallId}:yes`, `${c.toolCallId}:no`]), + }), + onInteraction: (e) => { + const [toolCallId, choice] = e.buttonValue.split(":"); + return { toolCallId, output: { approved: choice === "yes" } }; + }, + finalizeInteraction: async (e) => { + await editMySurface(e.messageRef, "Decision recorded"); + }, +}); +``` + +The resolved `output` is whatever your tool expects to receive. The Slack default resolves `{ approved: boolean }`; if your tool needs a richer answer, supply your own `onInteraction` that returns the shape your tool reads. + +## The reply after a decision + +When the turn resumes, the channel reply shows the agent's answer, not the text it produced before pausing. The framework posts the assistant text generated after the tool call, so a preamble like "I'll need approval first" is not concatenated onto the final "done" message. The approval prompt itself already lives in the collapsed controls. + +## Next steps + + + + Point Slack or any surface at an agent so messages become turns. + + + The same no-execute pause, rendered in a React frontend. + + + The durable per-conversation state a channel turn runs on. + + + Gate which verified deliveries reach the agent. + + diff --git a/docs/webhooks/overview.mdx b/docs/webhooks/overview.mdx new file mode 100644 index 00000000000..73dc3b35447 --- /dev/null +++ b/docs/webhooks/overview.mdx @@ -0,0 +1,96 @@ +--- +title: "Webhooks overview" +description: "Receive and verify webhooks from external providers as a task, with a hosted webhook URL." +sidebarTitle: "Overview" +--- + +A webhook is a task that runs when an external provider (Stripe, GitHub, Svix, your own service, …) sends an HTTP request. Trigger.dev gives each webhook a hosted webhook URL, verifies the incoming request's signature, and routes the verified event to your task's `onEvent` handler. + +You don't host an endpoint yourself, and you don't write verification code: you declare which provider the webhook is from, point the provider at the webhook URL, and set the signing secret. + +## Defining a webhook task + +A webhook is created with `webhook()`. It takes an `id`, a `source` (which provider, and how to verify it), and an `onEvent` handler: + +```ts +import { webhook, webhooks } from "@trigger.dev/sdk"; + +export const onStripeEvent = webhook({ + id: "stripe-events", + source: webhooks.stripe(), + onEvent: async ({ event, headers, ctx }) => { + // `event` is the verified, parsed body + console.log("Received", event.type, event.id); + + // `headers` is a standard Web Headers object + console.log(headers.get("stripe-signature")); + + // `ctx` is the usual run context + console.log(ctx.run.id); + }, +}); +``` + +`onEvent` receives: + +- **`event`**: the verified request body, parsed from JSON and typed by the source (see [Typing the event](/webhooks/sources#typing-the-event)). +- **`headers`**: the inbound request headers as a Web [`Headers`](https://developer.mozilla.org/en-US/docs/Web/API/Headers) object (case-insensitive `.get()` / `.has()`). +- **`ctx`**: the run context, the same one regular tasks receive. + + + A webhook is a first-class task kind. It runs on a real run (with retries, logs, and everything else + tasks get), and shows up in the dashboard alongside your other tasks. + + +## How it works + + + + Define a `webhook()` with a `source`. The source is a provider preset (like `webhooks.stripe()`) + or a `webhooks.custom()` config. See [Sources and verification](/webhooks/sources). + + + Deploying the webhook creates an endpoint with a hosted webhook URL. Set its signing secret and + point your provider at the URL. See [Connecting a provider](/webhooks/connect). + + + Each inbound request is verified, recorded as a delivery, and routed to a run that calls your + `onEvent`. See [Deliveries and endpoints](/webhooks/deliveries). + + + +## Beyond fan-out + +A few things build on the basic model: + +- **[Filters](/webhooks/filters)** gate which deliveries run. A non-matching delivery is recorded but never triggers a run. +- **[Session routing](/webhooks/session-routing)** sends deliveries that share a key to one durable session (per customer, installation, or issue) instead of a fresh run each time. +- **[Channels](/webhooks/channels)** turn a webhook into a chat frontend: inbound messages become agent turns, and the agent's reply posts back to the surface. +- **[Human-in-the-loop](/webhooks/human-in-the-loop)** adds approvals and interactive controls over a channel, like Slack approve and deny buttons. + + + + Provider presets, custom verification, and typing the event. + + + The webhook URL and signing secret. + + + Observe inbound requests in the dashboard. + + + Route only the deliveries you care about. + + + Route deliveries to a durable per-key session. + + + Turn a webhook into a chat frontend for an agent. + + + Approvals and interactive controls over a channel. + + + The other declarative task trigger. + + diff --git a/docs/webhooks/session-routing.mdx b/docs/webhooks/session-routing.mdx new file mode 100644 index 00000000000..575d8914c03 --- /dev/null +++ b/docs/webhooks/session-routing.mdx @@ -0,0 +1,96 @@ +--- +title: "Routing to a session" +description: "Route verified webhook deliveries to a durable per-key session instead of a fresh run." +sidebarTitle: "Session routing" +--- + +A plain [`webhook()`](/webhooks/overview) runs a fresh, stateless run for every delivery. Sometimes you want the opposite: deliveries that share a key (a customer, an installation, an issue) should land on **one durable [session](/ai-chat/sessions)** and be handled in order, with state carried across them. That is what session routing does. + +Instead of a handler, you declare a `chat.event` and list it on an agent. The verified delivery is routed to a find-or-created session and arrives as an [action](/ai-chat/actions). + +## Declaring a chat event + +`chat.event` describes the routing only: the [source](/webhooks/sources) to verify, a `key` that identifies the session, and a `type` label for the delivered action. It has no handler. + +```ts +import { webhooks } from "@trigger.dev/sdk"; +import { chat } from "@trigger.dev/sdk/ai"; + +export const orderEvents = chat.event({ + id: "order-events", + source: webhooks.stripe(), + // one session per customer + key: "{body.data.object.customer}", + type: "order.event", +}); +``` + +### The key + +The `key` is what makes routing durable: two deliveries that resolve to the same key reach the same session; a different key gets its own. It is a template string, evaluated server-side at ingest, whose `{...}` placeholders read three namespaces: + +- `{body.*}`: the parsed body (a bare `{customer}` defaults to the body namespace). +- `{webhook.*}`: endpoint metadata (`externalRef`, `tenantId`, `id`, `source`, `deliveryId`). +- `{header.name}`: an inbound header. + +[Filter](/webhooks/filters) expressions read this same parsed body, but spell it `event.*` rather than `{body.*}`. + +Compose several placeholders into one key: `"{webhook.externalRef}-{body.issue.id}"`. Each placeholder is checked against the event type at build time, so a bad field is a red squiggle on the `key` line, not a runtime surprise. + +### The `type` label + +`type` is a name you choose for the delivered action. It flows straight through to `action.type` on the envelope, so your handler can tell webhook actions apart from each other and from browser actions. It is optional and defaults to the descriptor `id`. + +## Handling deliveries on an agent + +List the descriptor on a [`chat.agent`](/ai-chat/overview) (or a session agent). The delivery arrives at `onAction`, not as a chat message: + +```ts +import { chat } from "@trigger.dev/sdk/ai"; +import { orderEvents } from "./order-events"; + +export const orderAgent = chat.agent({ + id: "order-agent", + events: [orderEvents], + onAction: async ({ action }) => { + switch (action.type) { + case "order.event": + // action.event is the verified Stripe event, fully typed + console.log("order for", action.event.data.object.customer); + break; + } + }, + run: async ({ messages, signal }) => { + /* ... */ + }, +}); +``` + +`action.type` is a closed union of the `type` labels of the events you listed, and each arm narrows `action.event` to that event's payload type. The full envelope is `{ type, event, source, headers, deliveryId }`. + +Because it is an [action](/ai-chat/actions), the handler is not a turn: it can mutate session state and, if you return a `streamText` result, produce a model response. Webhook actions bypass your `actionSchema` (their shape is fixed and already typed). + + + The descriptor never names the agent, and the agent references the descriptor, so there is no + circular dependency. Whichever agent lists it becomes the routing target. + + +## One endpoint per agent + +Listing a descriptor on an agent creates that agent's own endpoint, with its own [webhook URL and signing secret](/webhooks/connect). If two agents list the same descriptor, you get two endpoints, each routing to its own agent, and you point the provider at both URLs. + +An exported `chat.event` that no agent lists routes nothing. `trigger dev` warns about it (it is not an error, since declaring the descriptor before wiring the agent is a normal step), so wire it onto an agent once you are ready. + +## Filtering + +A [`filter`](/webhooks/filters) works here exactly as on a fan-out webhook: a non-matching delivery is recorded `FILTERED` and never reaches the session. + +```ts +export const orderEvents = chat.event({ + id: "order-events", + source: webhooks.stripe(), + key: "{body.data.object.customer}", + type: "order.event", + filter: "event.type == 'payment_intent.succeeded'", +}); +``` diff --git a/docs/webhooks/sources.mdx b/docs/webhooks/sources.mdx new file mode 100644 index 00000000000..7fa37067de1 --- /dev/null +++ b/docs/webhooks/sources.mdx @@ -0,0 +1,115 @@ +--- +title: "Sources and verification" +description: "Provider presets, custom verification config, and typing the webhook event." +sidebarTitle: "Sources & verification" +--- + +A webhook's `source` tells Trigger.dev which provider the request is from and how to verify it. Use a built-in preset, or `webhooks.custom()` for a provider without one. + +## Presets + +Built-in presets know the provider's signature scheme, so you don't configure anything: + + + +```ts Stripe +import { webhook, webhooks } from "@trigger.dev/sdk"; + +export const stripeWebhook = webhook({ + id: "stripe", + source: webhooks.stripe(), + onEvent: async ({ event }) => { + if (event.type === "payment_intent.succeeded") { + // ... + } + }, +}); +``` + +```ts GitHub +export const githubWebhook = webhook({ + id: "github", + source: webhooks.github(), + onEvent: async ({ event, headers }) => { + // GitHub puts the event type in a header + console.log(headers.get("x-github-event")); + }, +}); +``` + +```ts Svix +// Also covers Clerk, Resend, and other Svix-powered providers +export const svixWebhook = webhook({ + id: "svix", + source: webhooks.svix(), + onEvent: async ({ event }) => { + // ... + }, +}); +``` + + + +The available presets are `stripe()`, `github()`, `svix()`, `square()`, and `discord()`. + +## Custom providers + +For a provider without a preset, `webhooks.custom()` describes the scheme as data. For example, an HMAC-SHA256 signature over the raw body, in a custom header: + +```ts +export const customWebhook = webhook({ + id: "custom", + source: webhooks.custom<{ id: string; message: string }>({ + scheme: "hmac", + algorithm: "sha256", + encoding: "hex", + signatureHeader: "x-webhook-signature", + signingString: "raw", + idempotencyField: { from: "body", name: "id" }, + }), + onEvent: async ({ event }) => { + console.log(event.message); + }, +}); +``` + + + Reach for a preset first; drop to `custom()` for the long tail. A custom config can almost always + express a provider's scheme without any code. + + +## Typing the event + +Presets ship a sensible default event type, and they're generic, so you can plug in the provider's official type for full type-safety and autocomplete: + +```ts +import type Stripe from "stripe"; +import { webhooks } from "@trigger.dev/sdk"; + +// `event` is now the full, discriminated Stripe.Event union +webhooks.stripe(); +``` + +For `webhooks.custom()`, pass your own event type as `T`. + + + The event is typed but not re-validated against that type at runtime: once the signature is + verified, the body is trusted (the same model the official provider SDKs use). If you want runtime + validation, validate `event` inside `onEvent`. + + +## How verification works + +Every inbound request is verified before your task runs. Trigger.dev checks the signature, the +timestamp (for replay protection, where the provider supplies one), and derives the idempotency key +from the provider's event id. A request that fails verification gets a `400` and never creates a run. + +Presets handle this for you. Under the hood, every scheme is one of: + +- **`hmac`**: HMAC over the raw body or a templated signing string, signature in a header. The header can be a raw value, a prefixed one (like GitHub's `sha256=…`), or a structured one (like Stripe's `t=…,v1=…`). +- **`shared-secret`**: a static token compared in a header, bearer, basic auth, or the body. +- **`url-secret`**: a secret in the URL path or query string. +- **`asymmetric`**: public-key signatures (Ed25519, ECDSA, RSA). You store the provider's public key instead of a shared secret. + +Once a request is verified, see [Connecting a provider](/webhooks/connect) for how to point the +provider at the webhook URL and set the secret. diff --git a/packages/cli-v3/src/dev/devSupervisor.ts b/packages/cli-v3/src/dev/devSupervisor.ts index 6a0d1888afd..e8ba2d4afaf 100644 --- a/packages/cli-v3/src/dev/devSupervisor.ts +++ b/packages/cli-v3/src/dev/devSupervisor.ts @@ -32,7 +32,7 @@ import type { CliApiClient } from "../apiClient.js"; import { copySkillFolders } from "../build/bundleSkills.js"; import type { DevCommandOptions } from "../commands/dev.js"; import { DevRunController } from "../entryPoints/dev-run-controller.js"; -import { cliLink, prettyError } from "../utilities/cliOutput.js"; +import { cliLink, prettyError, prettyWarning } from "../utilities/cliOutput.js"; import { devBranchPathSegment } from "../utilities/devBranch.js"; import { eventBus } from "../utilities/eventBus.js"; import { resolveLocalEnvVars } from "../utilities/localEnvVars.js"; @@ -382,6 +382,16 @@ class DevSupervisor implements WorkerRuntime { return; } + // Non-blocking nudge: a chat.event that no agent lists routes nothing. Common (and fine) + // mid-development while you wire up the chat.agent, so warn rather than fail. + const unclaimedSessionWebhooks = backgroundWorker.manifest.unclaimedSessionWebhooks ?? []; + if (unclaimedSessionWebhooks.length > 0) { + prettyWarning( + `Unclaimed chat.event: ${unclaimedSessionWebhooks.join(", ")}`, + "Not listed on any agent's `events: [...]`, so deliveries won't be routed anywhere. Add each to a chat.agent once you wire it up." + ); + } + const sourceFiles = resolveSourceFiles(manifest.sources, backgroundWorker.manifest.tasks); const backgroundWorkerBody: CreateBackgroundWorkerRequestBody = { @@ -391,6 +401,7 @@ class DevSupervisor implements WorkerRuntime { cliPackageVersion: manifest.cliPackageVersion, tasks: backgroundWorker.manifest.tasks, prompts: backgroundWorker.manifest.prompts, + webhooks: backgroundWorker.manifest.webhooks, queues: backgroundWorker.manifest.queues, contentHash: manifest.contentHash, sourceFiles, diff --git a/packages/cli-v3/src/entryPoints/dev-index-worker.ts b/packages/cli-v3/src/entryPoints/dev-index-worker.ts index 59228f0971d..bf83ff8d720 100644 --- a/packages/cli-v3/src/entryPoints/dev-index-worker.ts +++ b/packages/cli-v3/src/entryPoints/dev-index-worker.ts @@ -195,6 +195,8 @@ await sendMessageInCatalog( tasks, prompts: convertPromptSchemasToJsonSchemas(resourceCatalog.listPromptManifests()), skills: resourceCatalog.listSkillManifests(), + webhooks: resourceCatalog.listWebhookManifests(), + unclaimedSessionWebhooks: resourceCatalog.listUnclaimedSessionWebhooks(), queues: resourceCatalog.listQueueManifests(), configPath: buildManifest.configPath, runtime: buildManifest.runtime, diff --git a/packages/cli-v3/src/entryPoints/managed-index-worker.ts b/packages/cli-v3/src/entryPoints/managed-index-worker.ts index f463c4156e5..8b3f2665518 100644 --- a/packages/cli-v3/src/entryPoints/managed-index-worker.ts +++ b/packages/cli-v3/src/entryPoints/managed-index-worker.ts @@ -191,6 +191,8 @@ await sendMessageInCatalog( tasks, prompts: convertPromptSchemasToJsonSchemas(resourceCatalog.listPromptManifests()), skills: resourceCatalog.listSkillManifests(), + webhooks: resourceCatalog.listWebhookManifests(), + unclaimedSessionWebhooks: resourceCatalog.listUnclaimedSessionWebhooks(), queues: resourceCatalog.listQueueManifests(), configPath: buildManifest.configPath, runtime: buildManifest.runtime, diff --git a/packages/slack/package.json b/packages/slack/package.json new file mode 100644 index 00000000000..fa8b483eaa5 --- /dev/null +++ b/packages/slack/package.json @@ -0,0 +1,76 @@ +{ + "name": "@trigger.dev/slack", + "version": "4.5.0-rc.7", + "description": "Slack chat frontend (channel) for trigger.dev agents", + "license": "MIT", + "publishConfig": { + "access": "public" + }, + "repository": { + "type": "git", + "url": "https://github.com/triggerdotdev/trigger.dev", + "directory": "packages/slack" + }, + "type": "module", + "files": [ + "dist" + ], + "tshy": { + "selfLink": false, + "main": true, + "module": true, + "project": "./tsconfig.src.json", + "exclude": [ + "./src/**/*.test.ts" + ], + "exports": { + "./package.json": "./package.json", + ".": "./src/index.ts" + }, + "sourceDialects": [ + "@triggerdotdev/source" + ] + }, + "scripts": { + "clean": "rimraf dist .tshy .tshy-build .turbo", + "build": "tshy && pnpm run update-version", + "dev": "tshy --watch", + "typecheck": "tsc --noEmit -p tsconfig.src.json", + "test": "vitest", + "update-version": "tsx ../../scripts/updateVersion.ts", + "check-exports": "attw --pack ." + }, + "dependencies": { + "@trigger.dev/core": "workspace:4.5.0-rc.7" + }, + "peerDependencies": { + "@trigger.dev/sdk": "workspace:^4.5.0-rc.7" + }, + "devDependencies": { + "@arethetypeswrong/cli": "^0.18.5", + "@trigger.dev/sdk": "workspace:4.5.0-rc.7", + "rimraf": "6.0.1", + "tshy": "^3.0.2", + "tsx": "4.17.0" + }, + "engines": { + "node": ">=18.20.0" + }, + "exports": { + "./package.json": "./package.json", + ".": { + "import": { + "@triggerdotdev/source": "./src/index.ts", + "types": "./dist/esm/index.d.ts", + "default": "./dist/esm/index.js" + }, + "require": { + "types": "./dist/commonjs/index.d.ts", + "default": "./dist/commonjs/index.js" + } + } + }, + "main": "./dist/commonjs/index.js", + "types": "./dist/commonjs/index.d.ts", + "module": "./dist/esm/index.js" +} diff --git a/packages/slack/src/index.test.ts b/packages/slack/src/index.test.ts new file mode 100644 index 00000000000..2777eac61c9 --- /dev/null +++ b/packages/slack/src/index.test.ts @@ -0,0 +1,307 @@ +import { describe, expect, it, vi } from "vitest"; +import { mentions, slack, toSlackMrkdwn, type SlackMessageEvent } from "./index.js"; + +const messageEvent = ( + over: Partial> = {} +): SlackMessageEvent => ({ + type: "event_callback", + event: { type: "message", channel: "C9", ts: "1699999999.0001", text: "hi", ...over }, +}); + +describe("slack channel", () => { + it("default inbound strips a leading bot mention", () => { + const c = slack({ id: "s1", token: "xoxb-t" }); + expect(c.inbound(messageEvent({ text: "<@U123> hello there" }))).toBe("hello there"); + expect(c.inbound(messageEvent({ text: "plain" }))).toBe("plain"); + }); + + it("composes the self-message guard with a user filter, and always admits interactivity", () => { + const guardOnly = slack({ id: "s2", token: "t" }); + expect(guardOnly.filter).toContain("event.event.type == 'message'"); + expect(guardOnly.filter).toContain("event.event.bot_id == null"); + expect(guardOnly.filter).toContain( + "event.event.subtype in [null, 'file_share', 'thread_broadcast']" + ); + expect(guardOnly.filter).toContain("event.type == 'block_actions'"); + + const withUser = slack({ id: "s3", token: "t", filter: "event.event.channel == 'C1'" }); + expect(withUser.filter).toContain("&& (event.event.channel == 'C1')"); + expect(withUser.filter).toContain("event.type == 'block_actions'"); + }); + + it("keys one session per thread, converging message events and interactivity", () => { + const c = slack({ id: "s4", token: "t" }); + expect(c.key).toBe( + "{body.team_id || body.team.id}:{body.event.channel || body.container.channel_id}:{body.event.thread_ts || body.event.ts || body.container.thread_ts || body.container.message_ts}" + ); + }); + + it("renderInteraction produces Block Kit approve/deny buttons carrying the toolCallId", () => { + const c = slack({ id: "s-hitl", token: "t" }); + const msg = c.renderInteraction?.( + [{ toolCallId: "call-1", toolName: "requestApproval", input: { amount: 50 } }], + { + event: messageEvent(), + deliveryId: "d1", + } + ); + const values = (msg?.blocks as any[]).flatMap((b) => b.elements ?? []).map((e: any) => e.value); + expect(values).toContain("call-1::approve"); + expect(values).toContain("call-1::deny"); + }); + + it("onInteraction resolves a block_actions click to a tool output; ignores messages", () => { + const c = slack({ id: "s-hitl2", token: "t" }); + const approve = c.onInteraction?.({ + type: "block_actions", + actions: [{ value: "call-9::approve" }], + } as never); + expect(approve).toEqual({ toolCallId: "call-9", output: { approved: true } }); + + const deny = c.onInteraction?.({ + type: "block_actions", + actions: [{ value: "call-9::deny" }], + } as never); + expect(deny).toEqual({ toolCallId: "call-9", output: { approved: false } }); + + expect(c.onInteraction?.(messageEvent())).toBeNull(); + }); + + it("finalizeInteraction replaces the controls via response_url, dropping the buttons", async () => { + const calls: Array<{ url: string; body: Record }> = []; + vi.stubGlobal( + "fetch", + vi.fn(async (url: string, init: { body: string }) => { + calls.push({ url, body: JSON.parse(init.body) }); + return { json: async () => ({ ok: true }) }; + }) + ); + const c = slack({ id: "s-fin", token: "t" }); + await c.finalizeInteraction?.( + { + type: "block_actions", + user: { id: "U42" }, + response_url: "https://hooks.slack.test/r/1", + actions: [{ value: "call-1::approve" }], + message: { + blocks: [ + { type: "section", text: { type: "mrkdwn", text: "Approval needed" } }, + { type: "actions", elements: [{ type: "button", value: "call-1::approve" }] }, + ], + }, + } as never, + { toolCallId: "call-1", output: { approved: true } } + ); + expect(calls[0]?.url).toBe("https://hooks.slack.test/r/1"); + expect(calls[0]?.body.replace_original).toBe(true); + const types = (calls[0]?.body.blocks as Array<{ type: string }>).map((b) => b.type); + expect(types).not.toContain("actions"); + expect(JSON.stringify(calls[0]?.body.blocks)).toContain("Approved"); + vi.unstubAllGlobals(); + }); + + it("finalizeInteraction is a no-op without a response_url", async () => { + const fetchMock = vi.fn(); + vi.stubGlobal("fetch", fetchMock); + const c = slack({ id: "s-fin2", token: "t" }); + await c.finalizeInteraction?.( + { type: "block_actions", actions: [{ value: "x::deny" }] } as never, + { toolCallId: "x", output: { approved: false } } + ); + expect(fetchMock).not.toHaveBeenCalled(); + vi.unstubAllGlobals(); + }); + + it("passes startOn through verbatim (not composed with the guard)", () => { + const none = slack({ id: "s5", token: "t" }); + expect(none.startOn).toBeUndefined(); + + const summon = slack({ id: "s6", token: "t", startOn: mentions("U012BOT") }); + expect(summon.startOn).toBe( + "(event.event.text contains '<@U012BOT>' || event.event.text contains '<@U012BOT|')" + ); + }); + + it("default ack varies text on crash recovery", () => { + const c = slack({ id: "s7", token: "t" }); + expect(c.ack?.(messageEvent(), { recovered: false })).toEqual({ text: "on it..." }); + expect(c.ack?.(messageEvent(), { recovered: true })).toEqual({ + text: "picking this back up...", + }); + }); + + it("ack: null disables the placeholder", () => { + const c = slack({ id: "s8", token: "t", ack: null }); + expect(c.ack).toBeUndefined(); + }); + + it("a custom ack receives the recovery ctx", () => { + const c = slack({ + id: "s9", + token: "t", + ack: (_e, ctx) => ({ text: ctx.recovered ? "resuming" : "starting" }), + }); + expect(c.ack?.(messageEvent(), { recovered: false })).toEqual({ text: "starting" }); + expect(c.ack?.(messageEvent(), { recovered: true })).toEqual({ text: "resuming" }); + }); + + it("toSlackMrkdwn converts common markdown to Slack mrkdwn", () => { + expect(toSlackMrkdwn("**bold**")).toBe("*bold*"); + expect(toSlackMrkdwn("__bold__")).toBe("*bold*"); + expect(toSlackMrkdwn("## Heading")).toBe("*Heading*"); + expect(toSlackMrkdwn("- one\n- two")).toBe("• one\n• two"); + expect(toSlackMrkdwn("[docs](https://trigger.dev)")).toBe(""); + expect(toSlackMrkdwn("~~gone~~")).toBe("~gone~"); + // A real model reply: heading + bold + bullets in one string. + expect(toSlackMrkdwn("## Help\n\nI can do **stuff**:\n- a\n- b")).toBe( + "*Help*\n\nI can do *stuff*:\n• a\n• b" + ); + }); + + it("mentions() builds a mention predicate for one or many bot ids", () => { + expect(mentions("U1")).toBe( + "(event.event.text contains '<@U1>' || event.event.text contains '<@U1|')" + ); + expect(mentions("U1", "U2")).toContain("<@U1>"); + expect(mentions("U1", "U2")).toContain("<@U2|"); + expect(() => mentions()).toThrow(/at least one/); + }); + + it("send posts then edits, threading the ref and using the bot token", async () => { + const calls: Array<{ url: string; body: Record; auth: unknown }> = []; + vi.stubGlobal( + "fetch", + vi.fn(async (url: string, init: { body: string; headers: Record }) => { + calls.push({ url, body: JSON.parse(init.body), auth: init.headers.authorization }); + return { json: async () => ({ ok: true, ts: "1700000000.0001" }) }; + }) + ); + + const c = slack({ id: "s5", token: "xoxb-secret", apiBaseUrl: "https://mock.slack" }); + const event = messageEvent(); + + const ackRes = await c.send!( + { text: "on it..." }, + { + event, + deliveryId: "d1", + mode: "final", + final: false, + } + ); + expect(ackRes.ref).toBe("1700000000.0001"); + expect(calls[0]?.url).toBe("https://mock.slack/chat.postMessage"); + expect(calls[0]?.auth).toBe("Bearer xoxb-secret"); + expect(calls[0]?.body.channel).toBe("C9"); + expect(calls[0]?.body.thread_ts).toBe("1699999999.0001"); + + await c.send!( + { text: "done" }, + { + event, + deliveryId: "d1", + previousRef: ackRes.ref, + mode: "final", + final: true, + } + ); + expect(calls[1]?.url).toBe("https://mock.slack/chat.update"); + expect(calls[1]?.body.ts).toBe("1700000000.0001"); + expect(calls[1]?.body.text).toBe("done"); + + vi.unstubAllGlobals(); + }); + + it("send targets the thread from a block_actions payload (HITL resume egress)", async () => { + const calls: Array<{ body: Record }> = []; + vi.stubGlobal( + "fetch", + vi.fn(async (_url: string, init: { body: string }) => { + calls.push({ body: JSON.parse(init.body) }); + return { json: async () => ({ ok: true, ts: "1700000000.9" }) }; + }) + ); + const c = slack({ id: "s-resume", token: "t", apiBaseUrl: "https://mock.slack" }); + const interaction = { + type: "block_actions", + team: { id: "T1" }, + container: { + type: "message", + channel_id: "C42", + thread_ts: "1699999999.0001", + message_ts: "1700000000.5", + }, + actions: [{ value: "call-1::approve" }], + }; + await c.send!( + { text: "refund approved and processed" }, + { + event: interaction as never, + deliveryId: "d-resume", + mode: "final", + final: true, + } + ); + expect(calls[0]?.body.channel).toBe("C42"); + expect(calls[0]?.body.thread_ts).toBe("1699999999.0001"); + vi.unstubAllGlobals(); + }); + + it("send throws when the bot is not in the channel", async () => { + vi.stubGlobal( + "fetch", + vi.fn(async () => ({ json: async () => ({ ok: false, error: "not_in_channel" }) })) + ); + const c = slack({ id: "s6", token: "t", apiBaseUrl: "https://mock.slack" }); + await expect( + c.send!({ text: "x" }, { event: messageEvent(), deliveryId: "d", mode: "final", final: true }) + ).rejects.toThrow(/not_in_channel/); + vi.unstubAllGlobals(); + }); + + it("react adds/removes an emoji on the triggering message (colons stripped)", async () => { + const calls: Array<{ url: string; body: Record }> = []; + vi.stubGlobal( + "fetch", + vi.fn(async (url: string, init: { body: string }) => { + calls.push({ url, body: JSON.parse(init.body) }); + return { json: async () => ({ ok: true }) }; + }) + ); + const c = slack({ + id: "s7", + token: "xoxb-secret", + apiBaseUrl: "https://mock.slack", + reactions: { working: "eyes", done: "white_check_mark" }, + }); + expect(c.reactions).toEqual({ working: "eyes", done: "white_check_mark" }); + + await c.react!({ name: "eyes" }, { event: messageEvent(), deliveryId: "d1" }); + expect(calls[0]?.url).toBe("https://mock.slack/reactions.add"); + expect(calls[0]?.body).toMatchObject({ + channel: "C9", + timestamp: "1699999999.0001", + name: "eyes", + }); + + await c.react!( + { name: ":white_check_mark:", remove: true }, + { event: messageEvent(), deliveryId: "d1" } + ); + expect(calls[1]?.url).toBe("https://mock.slack/reactions.remove"); + expect(calls[1]?.body.name).toBe("white_check_mark"); + vi.unstubAllGlobals(); + }); + + it("react swallows already_reacted (idempotent)", async () => { + vi.stubGlobal( + "fetch", + vi.fn(async () => ({ json: async () => ({ ok: false, error: "already_reacted" }) })) + ); + const c = slack({ id: "s8", token: "t", apiBaseUrl: "https://mock.slack" }); + await expect( + c.react!({ name: "eyes" }, { event: messageEvent(), deliveryId: "d1" }) + ).resolves.toBeUndefined(); + vi.unstubAllGlobals(); + }); +}); diff --git a/packages/slack/src/index.ts b/packages/slack/src/index.ts new file mode 100644 index 00000000000..a91c0de8e69 --- /dev/null +++ b/packages/slack/src/index.ts @@ -0,0 +1,409 @@ +import { chat } from "@trigger.dev/sdk/ai"; +import type { + ChannelAckCtx, + ChannelConnector, + ChannelInteractionCtx, + ChannelInteractionResolution, + ChannelMessage, + ChannelMessageInput, + ChannelPendingToolCall, + ChannelReaction, + ChannelReactCtx, + ChannelReactions, + ChannelReply, + ChannelSendCtx, +} from "@trigger.dev/sdk/ai"; +import type { + WebhookHandshakeConfig, + WebhookHmacConfig, + WebhookSource, +} from "@trigger.dev/core/v3"; + +// A minimal Slack Events API envelope; pass your own event type for fuller typing. +export type SlackMessageEvent = { + type: string; + event_id?: string; + team_id?: string; + event?: { + type: string; + subtype?: string; + text?: string; + user?: string; + channel?: string; + ts?: string; + thread_ts?: string; + bot_id?: string; + }; +}; + +// Slack signs `X-Slack-Signature: v0=` over `v0:{timestamp}:{body}`; the timestamp rides in +// `X-Slack-Request-Timestamp`. You paste the Slack signing secret as the endpoint's signing secret. +const SLACK_VERIFIER: WebhookHmacConfig = { + scheme: "hmac", + algorithm: "sha256", + encoding: "hex", + signatureHeader: "x-slack-signature", + signature: { fieldSeparator: "=", field: "v0" }, + timestamp: { + source: { from: "header", name: "x-slack-request-timestamp" }, + toleranceSeconds: 300, + }, + signingString: { template: "v0:{timestamp}:{body}" }, + idempotencyField: { from: "body", name: "event_id" }, + formPayload: { field: "payload" }, +}; + +// Slack's Event Subscriptions url_verification handshake: echo the challenge, do not record/route. +const SLACK_HANDSHAKE: WebhookHandshakeConfig = { + matchPath: "type", + matchValue: "url_verification", + respondPath: "challenge", +}; + +/** + * One session per Slack thread, for BOTH message events and block_actions interactivity (a button click + * on the in-thread ack). thread_ts is only on replies, so a thread-STARTING message falls back to ts; + * interactivity carries the same thread via `container.thread_ts` / `container.message_ts`. The `||` + * operator resolves to the first non-empty path, so both surfaces converge on one externalId. + */ +const DEFAULT_KEY = + "{body.team_id || body.team.id}:{body.event.channel || body.container.channel_id}:{body.event.thread_ts || body.event.ts || body.container.thread_ts || body.container.message_ts}"; + +/** + * Mandatory loop guard for MESSAGE events: `bot_id == null` drops the agent's own posts (no reply loop); + * the subtype allowlist keeps real user messages (absent subtype matches `null`) and drops system/edit + * events. Interactivity (block_actions) is a separate surface, admitted via INTERACTIVITY_PASS. + */ +const SELF_MESSAGE_GUARD = + "event.event.type == 'message' && event.event.bot_id == null && event.event.subtype in [null, 'file_share', 'thread_broadcast']"; + +/** Interactivity callbacks (button clicks) always pass the loop guard; onInteraction resolves them. */ +const INTERACTIVITY_PASS = "event.type == 'block_actions'"; + +const SLACK_API_BASE_URL = "https://slack.com/api"; + +export type SlackToken = string | ((event: TEvent) => string | Promise); + +export type SlackChannelOptions = { + id: string; + /** Bot token (xoxb-...). A string for one workspace, or a resolver keyed on the event's team_id. */ + token: SlackToken; + /** Session key template. Defaults to one session per thread. */ + key?: string; + /** Map a Slack event to the turn's message. Defaults to the message text with the bot mention stripped. */ + inbound?: (event: TEvent) => ChannelMessageInput; + /** Map the agent's reply to a Slack message. Defaults to the reply text (null posts nothing). */ + outbound?: (reply: ChannelReply) => ChannelMessage | null; + /** Placeholder posted while the agent works, then edited to the answer. `null` posts only the answer. */ + ack?: ((event: TEvent, ctx: ChannelAckCtx) => ChannelMessage | null) | null; + /** Extra server-side filter, composed AND with the mandatory self-message guard. */ + filter?: string; + /** + * Only start a NEW session when an event matches this filter; existing threads always resume. Use it + * to summon the bot on mention, then continue the thread silently, e.g. + * `startOn: "event.event.text contains '<@U012BOT>'"` (your bot's user id). + */ + startOn?: string; + /** "final" (default): ack then one edit. "stream": debounced live edits (fast-follow). */ + delivery?: "final" | "stream"; + /** + * Lifecycle emoji reactions on the user's message (names without colons, e.g. "eyes"): `working` is + * added while the turn runs and swapped to `done`, or `error` on failure. Needs the `reactions:write` + * scope. The agent can also react itself via `run({ channel })`. + */ + reactions?: ChannelReactions; + /** Override the Slack Web API base URL (for testing against a mock). */ + apiBaseUrl?: string; +}; + +/** + * Build a predicate that matches when the bot is @mentioned, for `startOn` (summon-on-mention) or + * `filter`. Pass your bot's user id(s) from the Slack app (they start with `U`); matches both the plain + * `<@U012BOT>` and labelled `<@U012BOT|name>` mention forms: + * `slack({ id, token, startOn: mentions("U012BOT") })`. + */ +export function mentions(...botUserIds: string[]): string { + const ids = botUserIds.filter(Boolean); + if (ids.length === 0) throw new Error("mentions() requires at least one bot user id"); + const clauses = ids.flatMap((id) => [ + `event.event.text contains '<@${id}>'`, + `event.event.text contains '<@${id}|'`, + ]); + return `(${clauses.join(" || ")})`; +} + +/** + * Slack as a chat frontend for an agent. List on `chat.agent({ channels: [slack({...})] })`: verified + * Slack messages in a thread are routed to a durable per-thread session and run as turns, and the reply + * is posted back to the thread. Set the endpoint's signing secret to your Slack signing secret; pass the + * bot token as `token`. Subscribe the app to `message.channels` (and invite the bot to the channel). + */ +export function slack( + options: SlackChannelOptions +): ChannelConnector { + const apiBaseUrl = options.apiBaseUrl ?? SLACK_API_BASE_URL; + const source: WebhookSource = { + provider: "slack", + verifier: { kind: "config", config: SLACK_VERIFIER, handshake: SLACK_HANDSHAKE }, + secretProvisioning: "integrator", + }; + const messageFilter = options.filter + ? `${SELF_MESSAGE_GUARD} && (${options.filter})` + : SELF_MESSAGE_GUARD; + const filter = `${INTERACTIVITY_PASS} || (${messageFilter})`; + const ack = + options.ack === null + ? undefined + : (options.ack ?? + ((_event: TEvent, ctx: ChannelAckCtx) => ({ + text: ctx.recovered ? "picking this back up..." : "on it...", + }))); + + return chat.channels.custom>({ + id: options.id, + source, + key: options.key ?? DEFAULT_KEY, + inbound: options.inbound ?? (defaultSlackInbound as (event: TEvent) => ChannelMessageInput), + outbound: options.outbound ?? defaultSlackOutbound, + ack, + send: makeSlackSend(options.token, apiBaseUrl), + renderInteraction: defaultSlackRenderInteraction as ( + pending: ChannelPendingToolCall[], + ctx: ChannelInteractionCtx + ) => ChannelMessage | null, + onInteraction: defaultSlackOnInteraction as ( + event: TEvent + ) => ChannelInteractionResolution | null, + finalizeInteraction: defaultSlackFinalizeInteraction as ( + event: TEvent, + resolution: ChannelInteractionResolution + ) => Promise, + // Composed at runtime (guard + optional user filter), so it bypasses the literal-only filter + // validator; the user's `filter` arg was already validated on the way in. + filter: filter as never, + startOn: options.startOn as never, + delivery: options.delivery ?? "final", + react: makeSlackReact(options.token, apiBaseUrl), + reactions: options.reactions, + }); +} + +/** + * Default HITL controls: render each pending human-decision tool as a Block Kit approve/deny pair. The + * button `value` carries `${toolCallId}::${decision}` so `onInteraction` can resolve the exact tool. + */ +function defaultSlackRenderInteraction( + pending: ChannelPendingToolCall[], + _ctx: ChannelInteractionCtx +): ChannelMessage | null { + const call = pending[0]; + if (!call) return null; + const detail = call.input !== undefined ? "\n```" + safeStringify(call.input) + "```" : ""; + return { + text: `Approval needed: ${call.toolName}`, + blocks: [ + { + type: "section", + text: { type: "mrkdwn", text: `*Approval needed* for \`${call.toolName}\`${detail}` }, + }, + { + type: "actions", + elements: [ + { + type: "button", + action_id: "trigger_hitl_approve", + style: "primary", + text: { type: "plain_text", text: "Approve" }, + value: `${call.toolCallId}::approve`, + }, + { + type: "button", + action_id: "trigger_hitl_deny", + style: "danger", + text: { type: "plain_text", text: "Deny" }, + value: `${call.toolCallId}::deny`, + }, + ], + }, + ], + }; +} + +/** + * Default interaction resolver: a `block_actions` button click resolves the tool named in its `value` + * (`${toolCallId}::approve|deny`) to `{ approved }`. Any non-interactivity event returns null (the + * normal message path handles it). + */ +function defaultSlackOnInteraction(event: unknown): ChannelInteractionResolution | null { + const payload = event as { type?: string; actions?: Array<{ value?: string }> }; + if (payload?.type !== "block_actions") return null; + const action = (payload.actions ?? []).find( + (a) => typeof a?.value === "string" && a.value.includes("::") + ); + if (!action?.value) return null; + const [toolCallId, decision] = action.value.split("::"); + if (!toolCallId || (decision !== "approve" && decision !== "deny")) return null; + return { toolCallId, output: { approved: decision === "approve" } }; +} + +/** + * After a decision, collapse the controls via the interaction's `response_url` (Slack's documented path: + * the click gets a bare 200 ack, then `response_url` accepts `replace_original` for up to 30 minutes). + * Keeps the original context blocks, drops the `actions` block, and appends the outcome, so the buttons + * can't be clicked again. No-op when the payload carries no `response_url` (e.g. a synthetic test event). + */ +async function defaultSlackFinalizeInteraction( + event: unknown, + resolution: ChannelInteractionResolution +): Promise { + const payload = event as { + response_url?: string; + user?: { id?: string }; + message?: { blocks?: unknown[] }; + }; + const responseUrl = payload?.response_url; + if (!responseUrl) return; + + const approved = (resolution.output as { approved?: boolean } | undefined)?.approved === true; + const decision = approved ? "Approved" : "Denied"; + const icon = approved ? ":white_check_mark:" : ":x:"; + const who = payload.user?.id ? ` by <@${payload.user.id}>` : ""; + + const original = Array.isArray(payload.message?.blocks) ? payload.message!.blocks : []; + const kept = original.filter((b) => (b as { type?: string })?.type !== "actions"); + const blocks = [ + ...kept, + { type: "context", elements: [{ type: "mrkdwn", text: `${icon} *${decision}*${who}` }] }, + ]; + + await fetch(responseUrl, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ replace_original: true, text: `${decision}${who}`, blocks }), + }); +} + +function safeStringify(value: unknown): string { + try { + return JSON.stringify(value, null, 2); + } catch { + return String(value); + } +} + +// Strip a leading bot mention (`<@U123> hi` -> `hi`) so the agent sees the plain text. +function defaultSlackInbound(event: SlackMessageEvent): string { + return (event.event?.text ?? "").replace(/^\s*<@[A-Z0-9]+>\s*/i, ""); +} + +/** + * Convert common GitHub-flavored markdown (what a model emits) to Slack mrkdwn: `**bold**` -> `*bold*`, + * `#` headings -> bold, `-`/`*` bullets -> `•`, `[t](url)` -> ``, `~~s~~` -> `~s~`. Applied by the + * default outbound; a custom `outbound` controls its own formatting (call this from it if you want it). + * Note: single-asterisk `*italic*` is left as-is, so it renders bold in Slack (rare in model output). + */ +export function toSlackMrkdwn(md: string): string { + return md + .replace(/\[([^\]]+)\]\((https?:\/\/[^)\s]+)\)/g, "<$2|$1>") + .replace(/^[ \t]{0,3}#{1,6}[ \t]+(.+?)[ \t]*#*[ \t]*$/gm, "*$1*") + .replace(/\*\*([^*\n]+)\*\*/g, "*$1*") + .replace(/__([^_\n]+)__/g, "*$1*") + .replace(/~~([^~\n]+)~~/g, "~$1~") + .replace(/^([ \t]*)[-*+][ \t]+/gm, "$1• "); +} + +function defaultSlackOutbound(reply: ChannelReply): ChannelMessage | null { + return reply.text ? { text: toSlackMrkdwn(reply.text) } : null; +} + +function makeSlackSend(token: SlackToken, apiBaseUrl: string) { + return async ( + message: ChannelMessage, + ctx: ChannelSendCtx + ): Promise<{ ref?: string }> => { + const event = ctx.event as SlackMessageEvent & { + container?: { channel_id?: string; thread_ts?: string; message_ts?: string }; + channel?: { id?: string }; + }; + const channel = event.event?.channel ?? event.container?.channel_id ?? event.channel?.id; + const threadTs = + event.event?.thread_ts ?? + event.event?.ts ?? + event.container?.thread_ts ?? + event.container?.message_ts; + + const resolve = () => (typeof token === "function" ? token(ctx.event) : token); + let botToken = await resolve(); + + const blocks = (message as { blocks?: unknown }).blocks; + const rich = Array.isArray(blocks) && blocks.length > 0 ? { blocks } : {}; + + const post = async () => + ctx.previousRef + ? slackApi(apiBaseUrl, "chat.update", botToken, { + channel, + ts: ctx.previousRef, + text: message.text, + ...rich, + }) + : slackApi(apiBaseUrl, "chat.postMessage", botToken, { + channel, + thread_ts: threadTs, + text: message.text, + ...rich, + }); + + let result = await post(); + // Re-resolve once on an auth error (token rotation) when a resolver was supplied. + if (!result.ok && typeof token === "function" && isAuthError(result.error)) { + botToken = await resolve(); + result = await post(); + } + if (!result.ok) { + // not_in_channel / channel_not_found is the common one: the bot isn't in the channel. Surface it. + throw new Error( + `slack ${ctx.previousRef ? "chat.update" : "chat.postMessage"} failed: ${result.error}` + ); + } + return { ref: ctx.previousRef ?? result.ts }; + }; +} + +function isAuthError(error: string | undefined): boolean { + return error === "invalid_auth" || error === "token_revoked" || error === "account_inactive"; +} + +// Add/remove an emoji reaction on the triggering Slack message (needs the reactions:write scope). +function makeSlackReact(token: SlackToken, apiBaseUrl: string) { + return async (reaction: ChannelReaction, ctx: ChannelReactCtx): Promise => { + const event = ctx.event as SlackMessageEvent; + const channel = event.event?.channel; + const timestamp = event.event?.ts; + const name = reaction.name.replace(/^:|:$/g, ""); + if (!channel || !timestamp || !name) return; + const botToken = typeof token === "function" ? await token(ctx.event) : token; + const method = reaction.remove ? "reactions.remove" : "reactions.add"; + const result = await slackApi(apiBaseUrl, method, botToken, { channel, timestamp, name }); + // already_reacted / no_reaction are benign idempotent outcomes; surface anything else. + if (!result.ok && result.error !== "already_reacted" && result.error !== "no_reaction") { + throw new Error(`slack ${method} failed: ${result.error}`); + } + }; +} + +async function slackApi( + baseUrl: string, + method: string, + token: string, + body: Record +): Promise<{ ok: boolean; ts?: string; error?: string }> { + const res = await fetch(`${baseUrl}/${method}`, { + method: "POST", + headers: { + "content-type": "application/json; charset=utf-8", + authorization: `Bearer ${token}`, + }, + body: JSON.stringify(body), + }); + return (await res.json()) as { ok: boolean; ts?: string; error?: string }; +} diff --git a/packages/slack/tsconfig.json b/packages/slack/tsconfig.json new file mode 100644 index 00000000000..16881b51b6e --- /dev/null +++ b/packages/slack/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../.configs/tsconfig.base.json", + "references": [ + { + "path": "./tsconfig.src.json" + } + ] +} diff --git a/packages/slack/tsconfig.src.json b/packages/slack/tsconfig.src.json new file mode 100644 index 00000000000..19b058321cb --- /dev/null +++ b/packages/slack/tsconfig.src.json @@ -0,0 +1,12 @@ +{ + "extends": "./tsconfig.json", + "include": ["./src/**/*.ts"], + "exclude": ["./src/**/*.test.ts"], + "compilerOptions": { + "isolatedDeclarations": false, + "composite": true, + "sourceMap": true, + "customConditions": ["@triggerdotdev/source"], + "types": ["node"] + } +} diff --git a/packages/slack/vitest.config.ts b/packages/slack/vitest.config.ts new file mode 100644 index 00000000000..4afd9264256 --- /dev/null +++ b/packages/slack/vitest.config.ts @@ -0,0 +1,8 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + include: ["**/*.test.ts"], + globals: true, + }, +}); diff --git a/packages/trigger-sdk/src/v3/ai.ts b/packages/trigger-sdk/src/v3/ai.ts index 844d506079b..5e85d38fb3b 100644 --- a/packages/trigger-sdk/src/v3/ai.ts +++ b/packages/trigger-sdk/src/v3/ai.ts @@ -36,6 +36,14 @@ import { type TaskWithSchema, TRIGGER_CONTROL_SUBTYPE, type StreamWriteResult, + type AnyChatEvent, + type ChatEventActions, + type ValidatedWebhookKey, + type ValidateWebhookFilter, + type WebhookVerifierArtifact, + type WebhookSecretProvisioning, + type AnyWebhookSource, + type InferWebhookEvent, } from "@trigger.dev/core/v3"; import type { FinishReason, @@ -50,6 +58,7 @@ import type { JSONSchema7, Schema, } from "ai"; +import { chatEvent, normalizeKeyString } from "./webhooks.js"; // Runtime VALUES go through the ESM/CJS shim so the CJS build can `require` // ESM-only `ai@7` (see ../imports/ai-runtime.ts). import { type Attributes, trace } from "@opentelemetry/api"; @@ -171,6 +180,17 @@ const chatSessionHandleKey = locals.create("chat.sessionHandle"); // metadata reads this directly rather than the Session handle id. const chatExternalIdKey = locals.create("chat.externalId"); +/** + * Set at boot when a continuation run inherited an interrupted turn (a + * partial assistant reply on `session.out`). Consumed by the first channel + * turn's ack so the re-emitted placeholder reads as a recovery resume rather + * than a silent duplicate. Boxed so the ack site can flip it to false. + * @internal + */ +const chatChannelRecoveryPendingKey = locals.create<{ value: boolean }>( + "chat.channelRecoveryPending" +); + /** * S2 seq_num of the most recent `turn-complete` control record written by * this worker. Read by `writeTurnCompleteChunk` to know what to trim back @@ -1243,6 +1263,41 @@ function createChatAccessToken( return auth.createTriggerPublicToken(taskId as string, { expirationTime: "24h" }); } +/** + * Mint a read-only, session-scoped token for WATCHING a chat session by its `externalId`. + * + * A session is addressed by `externalId`, so the same session is reachable from any surface that + * knows it: a web `useChat` client and a Slack thread converge on one session when they share an + * `externalId` (for a channel, the connector's `key` template produces it). This token carries + * `read:sessions:{externalId}` only (no write): the holder can subscribe to the session's `.out` + * stream (observe the conversation, hydrating history from the snapshot) but cannot append to `.in`. + * + * Use it for a dashboard viewer or a cross-surface watcher of a Slack-thread session. Run server-side + * (needs the secret key). To CONTINUE a session from another surface (drive it, not just watch), mint + * a read+write token instead ({@link createChatStartSessionAction}). + * + * @example + * ```ts + * // actions.ts + * "use server"; + * import { chat } from "@trigger.dev/sdk/ai"; + * + * export const watchChat = (externalId: string) => chat.createWatchToken(externalId); + * ``` + */ +function createChatWatchToken( + externalId: string, + options?: { tokenTTL?: string } +): Promise { + if (!externalId) { + throw new Error("chat.createWatchToken: externalId is required (the session addressing key)."); + } + return auth.createPublicToken({ + scopes: { read: { sessions: externalId } }, + expirationTime: options?.tokenTTL ?? "1h", + }); +} + // --------------------------------------------------------------------------- // Chat transport helpers — backend side // --------------------------------------------------------------------------- @@ -1532,6 +1587,11 @@ export type ChatTaskRunPayload< * `tools` option on `chat.agent`). Empty object when no `tools` were declared. */ tools: TTools; + /** + * Present only for a channel-delivered turn whose connector supports reactions. Lets the agent + * react to the triggering message to signal meaning (e.g. `channel.react("white_check_mark")`). + */ + channel?: ChannelRunSurface; }; // Input streams for bidirectional chat communication @@ -4642,12 +4702,434 @@ export type ChatResumeEvent = { + event: TEvent; + deliveryId: string; + previousRef?: string; + mode: "final" | "stream"; + final: boolean; +}; + +export type ChannelAckCtx = { + /** + * True when this ack is being re-posted because the run is recovering an + * interrupted turn after a crash/continuation. The prior run's message ref + * is gone, so egress re-emits into the thread as a fresh message; a + * connector can vary the placeholder text to read as a deliberate resume + * ("picking this back up...") rather than a silent duplicate. + */ + recovered: boolean; +}; + +/** + * A tool call the turn paused on, awaiting a human decision (HITL). `renderInteraction` maps these to + * the controls posted in the thread; the callback resolves one by `toolCallId`. + */ +export type ChannelPendingToolCall = { toolCallId: string; toolName: string; input?: unknown }; + +/** + * A verified interaction callback resolved to a tool output. `onInteraction` returns this to resume the + * paused run: the framework stitches `output` onto the pending tool part (by `toolCallId`) and continues. + */ +export type ChannelInteractionResolution = { toolCallId: string; output: unknown }; + +export type ChannelInteractionCtx = { event: TEvent; deliveryId: string }; + +// A reaction to add (or remove) on the triggering message. `name` is a provider emoji id (e.g. "eyes"). +export type ChannelReaction = { name: string; remove?: boolean }; +export type ChannelReactCtx = { event: TEvent; deliveryId: string }; + +// A lifecycle reaction choice: one emoji name, an array (one picked at random per turn), or a resolver +// of the event returning either (null/undefined to skip). Names are provider emoji ids without colons. +export type ChannelReactionChoice = + | string + | string[] + | (( + event: TEvent + ) => string | string[] | null | undefined | Promise); + +// Lifecycle reactions the run loop applies to the user's message around a turn (add working at start, +// swap to done at complete, error on failure). Any subset; requires the connector's `react`. +export type ChannelReactions = { + working?: ChannelReactionChoice; + done?: ChannelReactionChoice; + error?: ChannelReactionChoice; +}; + +// Handed to run() for a channel-delivered turn (when the connector supports reactions), so the agent can +// react to the triggering message to signal meaning. Best-effort: a failed reaction never throws. +export type ChannelRunSurface = { + react: (name: string) => Promise; + unreact: (name: string) => Promise; +}; + +// Reserved for 2c: resolve a provider credential keyed by the incoming event's installation (team_id), +// not the connector (one connector serves many Slack workspaces). The in-run send() calls it at post time. +export type ResolveChannelToken = (event: TEvent) => Promise; + +declare const channelEventPhantom: unique symbol; + +export interface ChannelConnector { + id: string; + source: string; + key: string; // compiled canonical key template + verifierArtifact: WebhookVerifierArtifact; + secretProvisioning?: WebhookSecretProvisioning; + filter?: string; + // Gate session creation: a resolved key with no session is only started when the event matches this + // filter (existing sessions always resume). Absent => every routed event can start one. + startOn?: string; + inbound: (event: TEvent) => ChannelMessageInput; + // Egress (optional; a channel can be inbound-only). Fires only when `send` is set. + outbound?: (reply: ChannelReply) => ChannelMessage | null; + ack?: (event: TEvent, ctx: ChannelAckCtx) => ChannelMessage | null; // placeholder posted at turn start ("final" mode) + send?: (message: ChannelMessage, ctx: ChannelSendCtx) => Promise<{ ref?: string }>; + /** HITL: controls posted when a turn pauses on a human-decision tool (a tool with no `execute`). */ + renderInteraction?: ( + pending: ChannelPendingToolCall[], + ctx: ChannelInteractionCtx + ) => ChannelMessage | null; + /** + * HITL: map a verified callback event to a tool resolution. Non-null resumes the paused run; null + * means treat the event as a normal inbound message (a new turn). + */ + onInteraction?: (event: TEvent) => ChannelInteractionResolution | null; + /** + * HITL: finalize the posted controls once a decision is made (edit them away, show the outcome), so + * the buttons can't be clicked again. Called with the same verified callback event when + * `onInteraction` resolves, before the run resumes. Best-effort: a throw is logged and the run continues. + */ + finalizeInteraction?: ( + event: TEvent, + resolution: ChannelInteractionResolution + ) => Promise | void; + delivery: "final" | "stream"; // "final" (ack + edit) is v1; "stream" (debounced edits) is a fast-follow + // Add/remove a reaction on the triggering message. Powers lifecycle reactions + run()'s channel surface. + react?: (reaction: ChannelReaction, ctx: ChannelReactCtx) => Promise; + reactions?: ChannelReactions; + readonly [channelEventPhantom]?: TEvent; +} +export type AnyChannelConnector = ChannelConnector; + +/** + * A generic chat-frontend channel over any verified source. Unlike `slack()`, you supply the egress + * `send` yourself (post/edit the reply back to your surface), so the whole round-trip is under your + * control. Use for providers without a preset, or to test the channel round-trip end to end. + */ +export function chatChannelCustom< + TSource extends AnyWebhookSource, + const TKey extends string = string, + const TFilter extends string = string, + const TStartOn extends string = string, +>(options: { + id: string; + source: TSource; + key: ValidatedWebhookKey, TKey>; + inbound: (event: InferWebhookEvent) => ChannelMessageInput; + outbound?: (reply: ChannelReply) => ChannelMessage | null; + ack?: (event: InferWebhookEvent, ctx: ChannelAckCtx) => ChannelMessage | null; + send?: ( + message: ChannelMessage, + ctx: ChannelSendCtx> + ) => Promise<{ ref?: string }>; + renderInteraction?: ( + pending: ChannelPendingToolCall[], + ctx: ChannelInteractionCtx> + ) => ChannelMessage | null; + onInteraction?: (event: InferWebhookEvent) => ChannelInteractionResolution | null; + finalizeInteraction?: ( + event: InferWebhookEvent, + resolution: ChannelInteractionResolution + ) => Promise | void; + react?: ( + reaction: ChannelReaction, + ctx: ChannelReactCtx> + ) => Promise; + reactions?: ChannelReactions>; + filter?: TFilter & ValidateWebhookFilter, TFilter>; + // Only start a new session when the event matches this filter (existing sessions always resume). + startOn?: TStartOn & ValidateWebhookFilter, TStartOn>; + delivery?: "final" | "stream"; +}): ChannelConnector> { + const { + id, + source, + key, + inbound, + outbound, + ack, + send, + renderInteraction, + onInteraction, + finalizeInteraction, + react, + reactions, + filter, + startOn, + delivery, + } = options; + resourceCatalog.registerDeclaredSessionWebhook(id); + return { + id, + source: source.provider, + key: normalizeKeyString(key as string), + verifierArtifact: source.verifier, + secretProvisioning: source.secretProvisioning, + filter, + startOn, + inbound, + outbound, + ack, + send, + renderInteraction, + onInteraction, + finalizeInteraction, + react, + reactions, + delivery: delivery ?? "final", + } as ChannelConnector>; +} + +const chatChannels = { + /** A generic chat-frontend channel over any source, with your own egress. See {@link chatChannelCustom}. */ + custom: chatChannelCustom, +}; + +// Turn a channel connector's inbound() result into a user UIMessage for the turn. +function toUserUIMessage(input: ChannelMessageInput, messageId: string): UIMessage { + if (typeof input !== "string") return input; + return { id: messageId, role: "user", parts: [{ type: "text", text: input }] } as UIMessage; +} + +/** + * The assistant's closing text for a channel reply: the text after the last tool part in the message, + * so a pre-tool preamble (e.g. the HITL "I'll need approval first" line, or a "let me look that up") + * is dropped and only the answer is posted. Falls back to all text when there's no trailing text or no + * tool parts (an ordinary turn), so a plain reply is unchanged. + */ +function channelReplyText(message: UIMessage): string { + const parts = (message.parts ?? []) as any[]; + const isTool = (p: any) => { + const t = p?.type; + return typeof t === "string" && (t.startsWith("tool-") || t === "dynamic-tool"); + }; + let lastToolIdx = -1; + parts.forEach((p, i) => { + if (isTool(p)) lastToolIdx = i; + }); + const textFrom = (from: number) => + parts + .slice(from) + .map((p) => + p && typeof p === "object" && "text" in p ? String((p as { text: unknown }).text) : "" + ) + .join(""); + const trailing = textFrom(lastToolIdx + 1); + return trailing.trim().length > 0 ? trailing : textFrom(0); +} + +/** Tool parts of a UIMessage awaiting a human answer (`input-available`), for `renderInteraction`. */ +function pendingToolCallsInMessage(message: UIMessage): ChannelPendingToolCall[] { + const out: ChannelPendingToolCall[] = []; + for (const part of (message.parts ?? []) as any[]) { + if (!part || typeof part !== "object") continue; + const type = part.type; + const isTool = + typeof type === "string" && (type.startsWith("tool-") || type === "dynamic-tool"); + if (!isTool || part.state !== "input-available") continue; + const toolName = + type === "dynamic-tool" ? String(part.toolName ?? "") : String(type).slice("tool-".length); + if (typeof part.toolCallId === "string") + out.push({ toolCallId: part.toolCallId, toolName, input: part.input }); + } + return out; +} + +/** + * Build the slim assistant message the HITL resume path expects (mirrors `slimSubmitMessageForWire`): + * the pending tool part matched by `toolCallId` across `messages`, advanced to `output-available` with + * the interaction's output. Returns undefined when no pending part matches (a stale/duplicate callback). + */ +function buildInteractionResolutionMessage( + resolution: ChannelInteractionResolution, + messages: UIMessage[] +): UIMessage | undefined { + for (let i = messages.length - 1; i >= 0; i--) { + const message = messages[i]!; + if (message.role !== "assistant") continue; + for (const part of (message.parts ?? []) as any[]) { + if (!part || typeof part !== "object" || part.toolCallId !== resolution.toolCallId) continue; + const type = part.type; + const isTool = + typeof type === "string" && (type.startsWith("tool-") || type === "dynamic-tool"); + if (!isTool) continue; + const slimPart: Record = { + type, + toolCallId: resolution.toolCallId, + state: "output-available", + output: resolution.output, + }; + if (type === "dynamic-tool" && typeof part.toolName === "string") + slimPart.toolName = part.toolName; + return { id: message.id, role: "assistant", parts: [slimPart] } as unknown as UIMessage; + } + } + return undefined; +} + +// Default outbound: post the reply text, or nothing when empty (a tool-only / empty turn). +function defaultChannelOutbound(reply: ChannelReply): ChannelMessage | null { + return reply.text ? { text: reply.text } : null; +} + +// Resolve a lifecycle reaction choice to one emoji name for this turn: call a resolver if given, then +// pick one at random from an array. Returns undefined to skip (no choice / empty). +export async function resolveReactionChoice( + choice: ChannelReactionChoice | undefined, + event: unknown +): Promise { + if (choice == null) return undefined; + let value: string | string[] | null | undefined = + typeof choice === "function" ? await choice(event) : choice; + if (Array.isArray(value)) { + if (value.length === 0) return undefined; + value = value[Math.floor(Math.random() * value.length)]; + } + return typeof value === "string" && value.length > 0 ? value : undefined; +} + +// Best-effort reaction on the triggering message; a failure never breaks the turn. +async function applyChannelReaction( + connector: AnyChannelConnector | undefined, + wireEvent: { event: unknown; deliveryId: string } | undefined, + reaction: ChannelReaction +): Promise { + if (!connector?.react || !wireEvent || !reaction.name) return; + try { + await connector.react(reaction, { event: wireEvent.event, deliveryId: wireEvent.deliveryId }); + } catch (error) { + logger.warn("chat.agent: channel reaction failed", { error, name: reaction.name }); + } +} + +// The run()-facing reaction surface for a channel turn (flavor 2). Undefined when the connector has no +// `react`, so `payload.channel` is only present when reacting is actually possible. +function buildChannelRunSurface( + connector: AnyChannelConnector | undefined, + wireEvent: { event: unknown; deliveryId: string } | undefined +): ChannelRunSurface | undefined { + if (!connector?.react || !wireEvent) return undefined; + return { + react: (name) => applyChannelReaction(connector, wireEvent, { name }), + unreact: (name) => applyChannelReaction(connector, wireEvent, { name, remove: true }), + }; +} + +// "stream" egress: as the reply streams, debounce-edit the ack message (previousRef) with the growing +// text. Trailing-edge, one edit per interval (Slack chat.update ~1/s); the turn-complete final edit is +// the authoritative last write. Best-effort: an edit failure is logged, never fatal. +const CHANNEL_STREAM_EDIT_INTERVAL_MS = 1000; +function makeChannelStreamEditor( + connector: ChannelConnector, + channelEvent: { event: unknown; deliveryId: string }, + ackRef: string +) { + const outbound = connector.outbound ?? defaultChannelOutbound; + let latest = ""; + let timer: ReturnType | undefined; + let inFlight = false; + let stopped = false; + + const edit = async () => { + if (stopped || inFlight) return; + const text = latest; + const message = outbound({ + text, + message: { id: ackRef, role: "assistant", parts: [{ type: "text", text }] } as UIMessage, + final: false, + stopped: false, + }); + if (!message) return; + inFlight = true; + try { + await connector.send!(message, { + event: channelEvent.event as TEvent, + deliveryId: channelEvent.deliveryId, + previousRef: ackRef, + mode: "stream", + final: false, + }); + } catch (error) { + logger.warn("chat.agent: channel stream edit failed", { error }); + } finally { + inFlight = false; + } + }; + + return { + observe(chunk: unknown) { + if (stopped) return; + const c = chunk as { type?: string; delta?: unknown }; + if (c?.type === "text-delta" && typeof c.delta === "string") { + latest += c.delta; + if (!timer) + timer = setTimeout(() => { + timer = undefined; + void edit(); + }, CHANNEL_STREAM_EDIT_INTERVAL_MS); + } + }, + stop() { + stopped = true; + if (timer) { + clearTimeout(timer); + timer = undefined; + } + }, + }; +} + +// The `action` type onAction receives: the actionSchema output (when set) unioned with the action +// envelopes of any listed chat.event descriptors. ChatEventActions<[]> is `never`, so it collapses +// cleanly when no events are listed; `unknown` is kept only when neither source is present. +type ChatActionType< + TActionSchema extends TaskSchema | undefined, + TW extends readonly AnyChatEvent[], +> = [TActionSchema] extends [TaskSchema] + ? inferSchemaOut | ChatEventActions + : [TW] extends [readonly []] + ? unknown + : ChatEventActions; + export type ChatAgentOptions< TIdentifier extends string, TClientDataSchema extends TaskSchema | undefined = undefined, TUIMessage extends UIMessage = UIMessage, TActionSchema extends TaskSchema | undefined = undefined, TTools extends ToolSet = ToolSet, + TW extends readonly AnyChatEvent[] = [], + TChannels extends readonly AnyChannelConnector[] = [], > = Omit< TaskOptions< TIdentifier, @@ -4750,9 +5232,25 @@ export type ChatAgentOptions< * `StreamTextResult` (auto-piped), `string`, or `UIMessage`. Returning * `void` or nothing is the side-effect-only default. */ + /** + * Inbound `chat.event(...)` descriptors this agent handles. Listing one registers an agent-scoped + * webhook endpoint that routes verified deliveries to this agent's session; the delivery arrives at + * `onAction` as a `{ type, event, source, headers, deliveryId }` envelope whose `type` is the + * descriptor's `type`. The same descriptor listed on another agent gets its own endpoint. + */ + events?: TW; + + /** + * Inbound chat frontends (Slack, etc.) via `chat.channels.*`. Listing one registers an agent-scoped + * webhook endpoint that routes verified events to a durable per-key session and delivers them as + * turns: the connector's `inbound()` maps the raw event to the turn's message, `run()` fires as + * normal, and the reply streams back to the channel. Use the connector's `filter` to ignore events. + */ + channels?: TChannels; + onAction?: ( event: ActionEvent< - [TActionSchema] extends [TaskSchema] ? inferSchemaOut : unknown, + ChatActionType, inferSchemaOut, TUIMessage > @@ -5395,8 +5893,18 @@ function chatAgent< TUIMessage extends UIMessage = UIMessage, TActionSchema extends TaskSchema | undefined = undefined, TTools extends ToolSet = ToolSet, + const TW extends readonly AnyChatEvent[] = [], + const TChannels extends readonly AnyChannelConnector[] = [], >( - options: ChatAgentOptions + options: ChatAgentOptions< + TIdentifier, + TClientDataSchema, + TUIMessage, + TActionSchema, + TTools, + TW, + TChannels + > ): Task>, unknown> { const { run: userRun, @@ -5408,6 +5916,8 @@ function chatAgent< onValidateMessages, hydrateMessages, actionSchema, + events, + channels, onAction, onTurnStart, onBeforeTurnComplete, @@ -5781,6 +6291,10 @@ function chatAgent< // those route through the normal continuation-wait path. const hasRecoveredState = partialAssistant !== undefined; + if (couldHavePriorState && hasRecoveredState) { + locals.set(chatChannelRecoveryPendingKey, { value: true }); + } + let hookChain: TUIMessage[] | undefined; let hookRecoveredTurns: TUIMessage[] | undefined; let hookBeforeBoot: (() => Promise) | undefined; @@ -6408,6 +6922,9 @@ function chatAgent< let capturedPartialResponse: TUIMessage | undefined; let responseCommitted = false; const turnBufferedChunks: UIMessageChunk[] = []; + let channelConn: AnyChannelConnector | undefined; + let channelWorkingReaction: string | undefined; + let channelWireEvent: { event: unknown; deliveryId: string } | undefined; try { // Extract turn-level context before entering the span. Slim // wire: at most one delta message per record. `headStartMessages` @@ -6417,11 +6934,74 @@ function chatAgent< metadata: wireMetadata, message: incomingMessage, headStartMessages: _hsm, + channelEvent: wireChannelEvent, ...restWire } = currentWirePayload; void _hsm; - const incomingMessages: TUIMessage[] = incomingMessage - ? [incomingMessage as TUIMessage] + channelWireEvent = wireChannelEvent; + let effectiveIncomingMessage = incomingMessage; + let channelAckRef: string | undefined; + if (wireChannelEvent) { + channelConn = channels?.find((c) => c.id === wireChannelEvent.connectorId); + if (channelConn) { + const interaction = channelConn.onInteraction?.(wireChannelEvent.event) ?? null; + const resolutionMessage = interaction + ? buildInteractionResolutionMessage( + interaction, + accumulatedUIMessages as UIMessage[] + ) + : undefined; + if (resolutionMessage) { + effectiveIncomingMessage = resolutionMessage as typeof incomingMessage; + if (interaction && channelConn.finalizeInteraction) { + try { + await channelConn.finalizeInteraction(wireChannelEvent.event, interaction); + } catch (finalizeError) { + logger.warn("chat.agent: channel finalizeInteraction failed; continuing", { + error: finalizeError, + }); + } + } + } else { + effectiveIncomingMessage = toUserUIMessage( + channelConn.inbound(wireChannelEvent.event), + currentWirePayload.messageId ?? wireChannelEvent.deliveryId + ) as typeof incomingMessage; + if (channelConn.send && channelConn.ack) { + const recoveryPending = locals.get(chatChannelRecoveryPendingKey); + const recovered = recoveryPending?.value === true; + if (recovered) recoveryPending!.value = false; + const ackMessage = channelConn.ack(wireChannelEvent.event, { recovered }); + if (ackMessage) { + try { + const ackResult = await channelConn.send(ackMessage, { + event: wireChannelEvent.event, + deliveryId: wireChannelEvent.deliveryId, + mode: channelConn.delivery, + final: false, + }); + channelAckRef = ackResult?.ref; + } catch (ackError) { + logger.warn("chat.agent: channel ack post failed; continuing", { + error: ackError, + }); + } + } + } + channelWorkingReaction = await resolveReactionChoice( + channelConn.reactions?.working, + wireChannelEvent.event + ); + if (channelWorkingReaction) { + await applyChannelReaction(channelConn, wireChannelEvent, { + name: channelWorkingReaction, + }); + } + } + } + } + const incomingMessages: TUIMessage[] = effectiveIncomingMessage + ? [effectiveIncomingMessage as TUIMessage] : []; // Cleaning happens once here so `extractLastUserMessageText` and // every downstream consumer see the same message shape — and @@ -6580,9 +7160,11 @@ function chatAgent< let actionStreamResult: unknown = undefined; if (isAction) { // Parse and validate the action payload - const parsedAction = parseAction - ? await parseAction(currentWirePayload.action) - : currentWirePayload.action; + const isWebhookAction = currentWirePayload.actionSource === "webhook"; + const parsedAction = + parseAction && !isWebhookAction + ? await parseAction(currentWirePayload.action) + : currentWirePayload.action; // Hydrate messages from backend if configured if (hydrateMessages) { @@ -7157,6 +7739,7 @@ function chatAgent< signal: combinedSignal, cancelSignal, stopSignal, + channel: buildChannelRunSurface(channelConn, wireChannelEvent), } as any); } @@ -7199,7 +7782,32 @@ function chatAgent< resolveOnFinish!(); }, }); - await pipeChat(tapUIMessageChunks(uiStream, turnBufferedChunks), { + let streamForPipe: typeof uiStream = uiStream; + if ( + wireChannelEvent && + channelConn?.send && + channelConn.delivery === "stream" && + channelAckRef && + uiStream instanceof ReadableStream + ) { + const editor = makeChannelStreamEditor( + channelConn, + wireChannelEvent, + channelAckRef + ); + streamForPipe = uiStream.pipeThrough( + new TransformStream({ + transform(chunk, controller) { + editor.observe(chunk); + controller.enqueue(chunk); + }, + flush() { + editor.stop(); + }, + }) + ); + } + await pipeChat(tapUIMessageChunks(streamForPipe, turnBufferedChunks), { signal: combinedSignal, spanName: "stream response", }); @@ -7650,6 +8258,61 @@ function chatAgent< turnAccessToken ); + // Channel egress ("final"): map the turn's reply via outbound() and send it, editing + // the ack placeholder posted at turn start (previousRef). Best-effort: an egress failure + // is logged, not fatal. A manual-pipe turn has no responseMessage, so it opts out. + if (wireChannelEvent && channelConn?.send && turnCompleteEvent.responseMessage) { + const pendingToolCalls = pendingToolCallsInMessage( + turnCompleteEvent.responseMessage + ); + const channelMessage = + pendingToolCalls.length > 0 && channelConn.renderInteraction + ? channelConn.renderInteraction(pendingToolCalls, { + event: wireChannelEvent.event, + deliveryId: wireChannelEvent.deliveryId, + }) + : (channelConn.outbound ?? defaultChannelOutbound)({ + text: channelReplyText(turnCompleteEvent.responseMessage), + message: turnCompleteEvent.responseMessage, + final: true, + stopped: turnCompleteEvent.stopped, + }); + if (channelMessage) { + try { + await channelConn.send(channelMessage, { + event: wireChannelEvent.event, + deliveryId: wireChannelEvent.deliveryId, + previousRef: channelAckRef, + mode: channelConn.delivery, + final: true, + }); + } catch (egressError) { + logger.warn("chat.agent: channel egress send failed", { + error: egressError, + }); + } + } + } + + // Lifecycle reaction: turn done. Remove "working", mark "done". + if (wireChannelEvent && channelConn?.react) { + if (channelWorkingReaction) { + await applyChannelReaction(channelConn, wireChannelEvent, { + name: channelWorkingReaction, + remove: true, + }); + } + const doneReaction = await resolveReactionChoice( + channelConn.reactions?.done, + wireChannelEvent.event + ); + if (doneReaction) { + await applyChannelReaction(channelConn, wireChannelEvent, { + name: doneReaction, + }); + } + } + // Fire onTurnComplete — stream is closed, use for persistence. if (onTurnComplete) { await tracer.startActiveSpan( @@ -7905,6 +8568,22 @@ function chatAgent< throw turnError; } + if (channelWireEvent && channelConn?.react) { + if (channelWorkingReaction) { + await applyChannelReaction(channelConn, channelWireEvent, { + name: channelWorkingReaction, + remove: true, + }); + } + const errorReaction = await resolveReactionChoice( + channelConn.reactions?.error, + channelWireEvent.event + ); + if (errorReaction) { + await applyChannelReaction(channelConn, channelWireEvent, { name: errorReaction }); + } + } + let errorTurnCompleteResult: | Awaited> | undefined; @@ -8136,6 +8815,52 @@ function chatAgent< }); } + // Claim each listed chat.event descriptor: register an agent-scoped webhook endpoint that routes + // verified deliveries to THIS agent's session. The id is scoped by agent so the same descriptor on + // two agents yields two endpoints (no collision), each routing to its own agent. + if (events) { + for (const wh of events) { + resourceCatalog.markSessionWebhookClaimed(wh.id); + resourceCatalog.registerWebhookMetadata({ + id: `${options.id}:${wh.id}`, + source: wh.source, + verifierArtifact: wh.verifierArtifact, + secretProvisioning: wh.secretProvisioning, + filter: wh.filter, + routingTarget: { + type: "session", + taskIdentifier: options.id, + keyTemplate: wh.key, + actionType: wh.type, + deliverAs: "action", + }, + }); + } + } + + // Claim each listed channel connector: same agent-scoped endpoint, but deliverAs "message" so a + // verified event becomes a turn (the run maps it via the connector's inbound(), resolved by connectorId). + if (channels) { + for (const ch of channels) { + resourceCatalog.markSessionWebhookClaimed(ch.id); + resourceCatalog.registerWebhookMetadata({ + id: `${options.id}:${ch.id}`, + source: ch.source, + verifierArtifact: ch.verifierArtifact, + secretProvisioning: ch.secretProvisioning, + filter: ch.filter, + routingTarget: { + type: "session", + taskIdentifier: options.id, + keyTemplate: ch.key, + connectorId: ch.id, + deliverAs: "message", + startOn: ch.startOn, + }, + }); + } + } + return task; } @@ -10671,6 +11396,10 @@ async function mintPublicTokenWithOverride(args: { export const chat = { /** Create a chat agent. See {@link chatAgent}. */ agent: chatAgent, + /** Declare an inbound webhook event an agent claims via `chat.agent({ events })`. See {@link chatEvent}. */ + event: chatEvent, + /** Chat frontend connectors (Slack, etc.) an agent claims via `chat.agent({ channels })`. */ + channels: chatChannels, /** Create a custom agent with manual lifecycle control. See {@link chatCustomAgent}. */ customAgent: chatCustomAgent, /** Create a chat task with a fixed {@link UIMessage} subtype and optional default stream options. See {@link withUIMessage}. */ @@ -10685,6 +11414,8 @@ export const chat = { local: chatLocal, /** Create a public access token for a chat task. See {@link createChatAccessToken}. */ createAccessToken: createChatAccessToken, + /** Mint a read-only token to WATCH a session by externalId (cross-surface). See {@link createChatWatchToken}. */ + createWatchToken: createChatWatchToken, /** Override the turn timeout at runtime (duration string). See {@link setTurnTimeout}. */ setTurnTimeout, /** Override the turn timeout at runtime (seconds). See {@link setTurnTimeoutInSeconds}. */ diff --git a/packages/trigger-sdk/src/v3/channelReactions.test.ts b/packages/trigger-sdk/src/v3/channelReactions.test.ts new file mode 100644 index 00000000000..f9d806d0e05 --- /dev/null +++ b/packages/trigger-sdk/src/v3/channelReactions.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, it } from "vitest"; +import { resolveReactionChoice } from "./ai.js"; + +describe("resolveReactionChoice", () => { + it("returns a single string as-is", async () => { + expect(await resolveReactionChoice("eyes", {})).toBe("eyes"); + }); + + it("skips when absent or empty", async () => { + expect(await resolveReactionChoice(undefined, {})).toBeUndefined(); + expect(await resolveReactionChoice("", {})).toBeUndefined(); + expect(await resolveReactionChoice([], {})).toBeUndefined(); + expect(await resolveReactionChoice(() => undefined, {})).toBeUndefined(); + expect(await resolveReactionChoice(() => null, {})).toBeUndefined(); + }); + + it("picks a member of an array (randomly)", async () => { + const options = ["eyes", "hourglass", "thinking_face"]; + const seen = new Set(); + for (let i = 0; i < 60; i++) { + const picked = await resolveReactionChoice(options, {}); + expect(options).toContain(picked); + seen.add(picked!); + } + // Over 60 draws from 3 options, seeing only one is astronomically unlikely: proves it varies. + expect(seen.size).toBeGreaterThan(1); + }); + + it("resolves a function of the event, returning a string or an array", async () => { + const byKind = (e: unknown) => `emoji-${(e as { kind: string }).kind}`; + expect(await resolveReactionChoice(byKind, { kind: "bug" })).toBe("emoji-bug"); + const picked = await resolveReactionChoice(() => ["a", "b"], {}); + expect(["a", "b"]).toContain(picked); + }); + + it("awaits an async resolver", async () => { + expect(await resolveReactionChoice(async () => "shipit", {})).toBe("shipit"); + }); +}); diff --git a/packages/trigger-sdk/src/v3/chat.ts b/packages/trigger-sdk/src/v3/chat.ts index 85f2ac75156..66c320b3e6c 100644 --- a/packages/trigger-sdk/src/v3/chat.ts +++ b/packages/trigger-sdk/src/v3/chat.ts @@ -97,6 +97,24 @@ export type ChatTaskWirePayload a chat frontend like Slack). Carries the raw + * verified provider event; the run resolves the connector by `connectorId` from `chat.agent({ channels })` + * and applies its `inbound()` mapper to produce the turn's message. Present instead of `message`. + */ + channelEvent?: { + connectorId: string; + event: unknown; + source: string; + headers: Record; + deliveryId: string; + }; /** Whether this run is continuing an existing chat whose previous run ended. */ continuation?: boolean; /** The run ID of the previous run (only set when `continuation` is true). */ diff --git a/packages/trigger-sdk/src/v3/webhooks.ts b/packages/trigger-sdk/src/v3/webhooks.ts index 040b7ee6638..132d9563141 100644 --- a/packages/trigger-sdk/src/v3/webhooks.ts +++ b/packages/trigger-sdk/src/v3/webhooks.ts @@ -1,5 +1,28 @@ -import { Webhook } from "@trigger.dev/core/v3"; +import { Webhook, resourceCatalog } from "@trigger.dev/core/v3"; +import type { + WebhookSource, + InferWebhookEvent, + AnyWebhookSource, + WebhookRunPayload, + ValidateWebhookFilter, + ChatEvent, + ValidatedWebhookKey, + WebhookVerifierConfig, + StripeWebhookEvent, + GitHubWebhookEvent, + TaskRunContext, +} from "@trigger.dev/core/v3"; import { subtle } from "../imports/uncrypto.js"; +import { createTask, type Task } from "./shared.js"; +import { + discordVerifierConfig, + githubVerifierConfig, + squareVerifierConfig, + stripeVerifierConfig, + svixVerifierConfig, + webhookProviderConfigs, + type WebhookProviderId, +} from "@trigger.dev/core/webhooks"; /** * The type of error thrown when a webhook fails to parse or verify @@ -24,6 +47,275 @@ type ConstructEventOptions = { header: string | Buffer | Array; }; +// ── Source producers (presets carry the event type) ── +export const webhookSources = { + custom(config: WebhookVerifierConfig): WebhookSource { + // Roll-your-own webhooks: you control both ends, so offer paste AND generate. + return { + provider: "custom", + verifier: { kind: "config", config }, + secretProvisioning: "either", + }; + }, + + // Stripe: `Stripe-Signature: t=…,v1=…` (comma-kv), signed `{t}.{body}`, hex. + // Defaults to a minimal event shape; pass the official type for full typing: stripe(). + stripe(opts?: { toleranceSeconds?: number }): WebhookSource { + return { + provider: "stripe", + verifier: { kind: "preset", preset: "stripe", config: stripeVerifierConfig(opts) }, + secretProvisioning: "provider", + }; + }, + + // GitHub: `X-Hub-Signature-256: sha256=` (prefixed), signed raw body. + // Defaults to an open shape; pass your event type for full typing: github(). + github(): WebhookSource { + return { + provider: "github", + verifier: { kind: "preset", preset: "github", config: githubVerifierConfig() }, + secretProvisioning: "integrator", + }; + }, + + // Svix family (Svix, Clerk, Resend): `svix-signature: v1, v1,` (space-list), + // signed `{id}.{timestamp}.{body}`, base64; the `whsec_` secret is base64-decoded. + svix(): WebhookSource { + return { + provider: "svix", + verifier: { kind: "preset", preset: "svix", config: svixVerifierConfig() }, + secretProvisioning: "provider", + }; + }, + + // Square: bare base64 signature over `{notificationURL}{body}` (URL template var, no separator). + square(): WebhookSource { + return { + provider: "square", + verifier: { kind: "preset", preset: "square", config: squareVerifierConfig() }, + secretProvisioning: "provider", + }; + }, + + // Discord: asymmetric Ed25519 over `{timestamp}{body}`. The "secret" stored on the endpoint is + // the application PUBLIC KEY (hex by default). No shared secret. + discord(opts: { publicKeyEncoding?: "raw-hex" | "pem" } = {}): WebhookSource { + return { + provider: "discord", + verifier: { kind: "preset", preset: "discord", config: discordVerifierConfig(opts) }, + secretProvisioning: "provider", + }; + }, + + /** + * Per-provider producers over shared presets. Each is a thin wrapper: same verifier config as the + * preset it references, differing only in `provider` (routing + picker identity) and who provisions + * the secret. Pass the provider's own published type for full typing, e.g. clerk(). + */ + clerk(): WebhookSource { + return { + provider: "clerk", + verifier: { kind: "preset", preset: "svix", config: svixVerifierConfig() }, + secretProvisioning: "provider", + }; + }, + + resend(): WebhookSource { + return { + provider: "resend", + verifier: { kind: "preset", preset: "svix", config: svixVerifierConfig() }, + secretProvisioning: "provider", + }; + }, + + openai(): WebhookSource { + return { + provider: "openai", + verifier: { kind: "preset", preset: "svix", config: svixVerifierConfig() }, + secretProvisioning: "provider", + }; + }, + + replicate(): WebhookSource { + return { + provider: "replicate", + verifier: { kind: "preset", preset: "svix", config: svixVerifierConfig() }, + secretProvisioning: "provider", + }; + }, + + recallai(): WebhookSource { + return { + provider: "recall-ai", + verifier: { kind: "preset", preset: "svix", config: svixVerifierConfig() }, + secretProvisioning: "provider", + }; + }, + + brex(): WebhookSource { + return { + provider: "brex", + verifier: { kind: "preset", preset: "svix", config: svixVerifierConfig() }, + secretProvisioning: "provider", + }; + }, + + gitlab(): WebhookSource { + return { + provider: "gitlab", + verifier: { kind: "preset", preset: "svix", config: svixVerifierConfig() }, + secretProvisioning: "integrator", + }; + }, + + whatsapp(): WebhookSource { + return { + provider: "whatsapp", + verifier: { kind: "preset", preset: "github", config: githubVerifierConfig() }, + secretProvisioning: "integrator", + }; + }, +} as const; + +/** + * Per-provider producers generated from the core config table (kind "config"). Each carries the + * provider's own HMAC verifier config and stays in lockstep with the round-trip-tested configs. + */ +export type ProviderProducers = { + [K in WebhookProviderId]: () => WebhookSource; +}; + +export const providerProducers = Object.fromEntries( + Object.entries(webhookProviderConfigs).map(([provider, entry]) => [ + provider, + () => ({ + provider, + verifier: { kind: "config" as const, config: entry.config() }, + secretProvisioning: entry.secretProvisioning, + }), + ]) +) as ProviderProducers; + +// ── webhook() entry: single-callback IoC, infers event from source ── +export type WebhookOnEventParams = { + /** The verified event body, typed by the source (a preset type, or the `` you supply). */ + event: TEvent; + /** The inbound request headers (case-insensitive, Web `Headers`). e.g. headers.get("x-github-event"). */ + headers: Headers; + ctx: TaskRunContext; +}; + +export type WebhookOptions = { + id: TIdentifier; + source: TSource; + /** + * Optional server-side filter (a type-safe string DSL checked against the event shape). A delivery + * that doesn't match is received and recorded but not routed (no run). e.g. + * `"event.action == 'created' && event.repository.private == false"`. + */ + filter?: string; + onEvent: (params: WebhookOnEventParams>) => Promise | void; +}; + +export type WebhookHandle = Task; + +export function webhook< + TIdentifier extends string, + TSource extends AnyWebhookSource, + const TFilter extends string = string, +>( + options: WebhookOptions & { + filter?: TFilter & ValidateWebhookFilter, TFilter>; + } +): WebhookHandle> { + const { id, source, onEvent, filter } = options; + + // 1. The task half: webhook IS a first-class task kind (triggerSource "webhook"). + // The platform delivers a { event, headers } envelope; unwrap it for onEvent. The handle's + // payload type stays the event (webhook tasks are triggered by the ingress, not tasks.trigger). + const task = createTask, void>({ + id, + triggerSource: "webhook", + run: async (payload, runOptions) => { + const envelope = payload as unknown as WebhookRunPayload>; + await onEvent({ + event: envelope.event, + headers: new Headers(envelope.headers ?? {}), + ctx: runOptions.ctx, + }); + }, + }); + + // 2. The endpoint half: register the verifier + default routing target (this task) + filter. + resourceCatalog.registerWebhookMetadata({ + id, + source: source.provider, + verifierArtifact: source.verifier, + routingTarget: { type: "task", taskId: id }, + secretProvisioning: source.secretProvisioning, + filter, + }); + + return task; +} + +// ── chat.event(): declarative descriptor an agent claims via chat.agent({ events }). No handler. ── +// Carries the verifier (source), a validated string `key`, and a `type` discriminant. The `key` +// validates against the event/webhook/header namespaces and mirrors the stored {body.x} wire template. +export function chatEvent< + TSource extends AnyWebhookSource, + const TId extends string = string, + const TKey extends string = string, + const TType extends string = TId, + const TFilter extends string = string, +>(options: { + id: TId; + source: TSource; + key: ValidatedWebhookKey, TKey>; + /** The `action.type` the handler reads. Optional; defaults to `id`. */ + type?: TType; + /** Optional server-side filter (same type-safe DSL as `webhook()`); a non-match is recorded FILTERED and not routed. */ + filter?: TFilter & ValidateWebhookFilter, TFilter>; +}): ChatEvent> { + const { id, source, key, type, filter } = options; + const keyTemplate = normalizeKeyString(key as string); + + // Record the descriptor as declared so the indexer can flag it if no agent ever claims it. + resourceCatalog.registerDeclaredSessionWebhook(id); + + return { + id, + type: type ?? id, + key: keyTemplate, + source: source.provider, + verifierArtifact: source.verifier, + secretProvisioning: source.secretProvisioning, + filter, + } as ChatEvent>; +} + +// Public chat-event types (descriptor, the shared action union, and the key namespaces). +export type { + ChatEvent, + AnyChatEvent, + ChatEventAction, + ChatEventActions, + WebhookKeyMeta, +} from "@trigger.dev/core/v3"; + +// Brace placeholders without a recognized namespace default to the event body. webhook./header./body. +// pass through unchanged. +export function normalizeKeyString(key: string): string { + return key.replace(/\{([^}]+)\}/g, (_match, path: string) => + path.startsWith("webhook.") || path.startsWith("header.") || path.startsWith("body.") + ? `{${path}}` + : `{body.${path}}` + ); +} + +// P2 seam (TYPE only): +export type { CreateWebhookEndpointParams } from "@trigger.dev/core/v3"; + /** * Interface describing the webhook utilities */ @@ -50,14 +342,43 @@ interface Webhooks { /** Header name used for webhook signatures */ SIGNATURE_HEADER_NAME: string; + custom: typeof webhookSources.custom; + stripe: typeof webhookSources.stripe; + github: typeof webhookSources.github; + svix: typeof webhookSources.svix; + square: typeof webhookSources.square; + discord: typeof webhookSources.discord; + clerk: typeof webhookSources.clerk; + resend: typeof webhookSources.resend; + openai: typeof webhookSources.openai; + replicate: typeof webhookSources.replicate; + recallai: typeof webhookSources.recallai; + brex: typeof webhookSources.brex; + gitlab: typeof webhookSources.gitlab; + whatsapp: typeof webhookSources.whatsapp; } /** * Webhook utilities for handling incoming webhook requests */ -export const webhooks: Webhooks = { +export const webhooks: Webhooks & ProviderProducers = { + ...providerProducers, constructEvent, SIGNATURE_HEADER_NAME, + custom: webhookSources.custom, + stripe: webhookSources.stripe, + github: webhookSources.github, + svix: webhookSources.svix, + square: webhookSources.square, + discord: webhookSources.discord, + clerk: webhookSources.clerk, + resend: webhookSources.resend, + openai: webhookSources.openai, + replicate: webhookSources.replicate, + recallai: webhookSources.recallai, + brex: webhookSources.brex, + gitlab: webhookSources.gitlab, + whatsapp: webhookSources.whatsapp, }; async function constructEvent( diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a59d9b72d6c..a3afde9f135 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2229,6 +2229,28 @@ importers: specifier: ^1.7.0 version: 1.7.0 + packages/slack: + dependencies: + '@trigger.dev/core': + specifier: workspace:4.5.0-rc.7 + version: link:../core + devDependencies: + '@arethetypeswrong/cli': + specifier: ^0.18.5 + version: 0.18.5 + '@trigger.dev/sdk': + specifier: workspace:4.5.0-rc.7 + version: link:../trigger-sdk + rimraf: + specifier: 6.0.1 + version: 6.0.1 + tshy: + specifier: ^3.0.2 + version: 3.3.2 + tsx: + specifier: 4.17.0 + version: 4.17.0 + packages/trigger-sdk: dependencies: '@ai-sdk/otel': @@ -8271,48 +8293,95 @@ packages: '@types/ws@8.5.4': resolution: {integrity: sha512-zdQDHKUgcX/zBc4GrwsE/7dVdAD8JR4EuiAXiiUhhfyIJXXb2+PrGshFyeXWQPMmmZ2XxgaqclgpIC7eTXc1mg==} + '@typescript/native-preview-darwin-arm64@7.0.0-dev.20260603.1': + resolution: {integrity: sha512-BvaaQAHWaHA0nl26DsWTsXsKkCHqUTm7f5FMuNDyCU83Hvo7zHx0vpSTrzL+1KEWeWLUVVGA7U224dk+3yIosQ==} + engines: {node: '>=16.20.0'} + cpu: [arm64] + os: [darwin] + '@typescript/native-preview-darwin-arm64@7.0.0-dev.20260707.2': resolution: {integrity: sha512-wny2pgKjGbiZtnOIHVa3tXC1UfDqxNEFzyPGmiqybedG8hipG2Nfp0l5UxbaKCjkLacUpH/W5bP2hBOMVhCOzg==} engines: {node: '>=16.20.0'} cpu: [arm64] os: [darwin] + '@typescript/native-preview-darwin-x64@7.0.0-dev.20260603.1': + resolution: {integrity: sha512-a2JJezvJAqWXgTAD1tKdWs7iA/4s3EakLcCYx4rg3ptEbBF6ADWv7A9ySHxT/+CQWYCD0DYIb9du1JUWL85fRw==} + engines: {node: '>=16.20.0'} + cpu: [x64] + os: [darwin] + '@typescript/native-preview-darwin-x64@7.0.0-dev.20260707.2': resolution: {integrity: sha512-Afc7M5zOwo+GpfcYwz5Z8HMB2tPVsui7nNIqEuuFB73MPdVqNn/Wmpe4tP4MRri0AtJnJknoHBaTJ/VDAp/Jhw==} engines: {node: '>=16.20.0'} cpu: [x64] os: [darwin] + '@typescript/native-preview-linux-arm64@7.0.0-dev.20260603.1': + resolution: {integrity: sha512-MQ0JcRucLdcR6MT14wlrGNz9HaxJrFF8Axmo0IN6e5gSou2UrKKUvvAH1i8zU5Gm7jl8CWCLzzX/qGCi1TsinA==} + engines: {node: '>=16.20.0'} + cpu: [arm64] + os: [linux] + '@typescript/native-preview-linux-arm64@7.0.0-dev.20260707.2': resolution: {integrity: sha512-iITBa2WjjTI5N9t5l7Z4KoOSI+2zBlhbvFzsD/f8qX8QoKjz/Y4DPyBDgezYi8nkqjjksbgSOJ3/ykzhwrB9cg==} engines: {node: '>=16.20.0'} cpu: [arm64] os: [linux] + '@typescript/native-preview-linux-arm@7.0.0-dev.20260603.1': + resolution: {integrity: sha512-GLsTfJQiGfTN+r1ezlxMcTd5MNYRB/tADD6Y1j1jfLjZsFYSVkNfCnDQA/jwUG6GddBBF+0Um7tBUP16emej9w==} + engines: {node: '>=16.20.0'} + cpu: [arm] + os: [linux] + '@typescript/native-preview-linux-arm@7.0.0-dev.20260707.2': resolution: {integrity: sha512-hJm/UOqZTr9FHmR7uNm8VGX4oKtfWk0Jem0zPeJFNC8ckGUfSBueyiEYMZB+XmRc1aG4x1E46y3CplP4CLHvGQ==} engines: {node: '>=16.20.0'} cpu: [arm] os: [linux] + '@typescript/native-preview-linux-x64@7.0.0-dev.20260603.1': + resolution: {integrity: sha512-u6gvCiVGSDagdR2+GI5VJLPxJbGevATJgjZ2QFLKBLWI3re7liTGlPAuaINNDq/r0m9rUPj2rEn0zwqtcLK0nQ==} + engines: {node: '>=16.20.0'} + cpu: [x64] + os: [linux] + '@typescript/native-preview-linux-x64@7.0.0-dev.20260707.2': resolution: {integrity: sha512-du0dzi6y97Po5vDNdPJTyyijHCpaS22JLRnKZEJXBDaO9gCIymOv/5QQokFRuOlQm0bWl3i9PF4OVdGP6uAOQA==} engines: {node: '>=16.20.0'} cpu: [x64] os: [linux] + '@typescript/native-preview-win32-arm64@7.0.0-dev.20260603.1': + resolution: {integrity: sha512-slxm6HBYs+jk0GpIBC2o03uY5qHgW7wIH+OTjK8JHbd2sUCgYV7P7bfZHUVZYi/cJZcVrnDW6MxXx734TuFn+w==} + engines: {node: '>=16.20.0'} + cpu: [arm64] + os: [win32] + '@typescript/native-preview-win32-arm64@7.0.0-dev.20260707.2': resolution: {integrity: sha512-SsAwfhyHJ1akgBc+99z4+hwdbHsdWaKB8EwCNIMA6JfSLMeUjffrYvxu+vfMyxVtOVOz7RrRXRoiDiu4a2sCtg==} engines: {node: '>=16.20.0'} cpu: [arm64] os: [win32] + '@typescript/native-preview-win32-x64@7.0.0-dev.20260603.1': + resolution: {integrity: sha512-G7SDZJn2Z9+c1qsAFzI/JL0OsRjJ18diQ39ycWKmJikilZZeL7iS7j933bemWY4ODaLTfxhMRKPMHf9292gpDA==} + engines: {node: '>=16.20.0'} + cpu: [x64] + os: [win32] + '@typescript/native-preview-win32-x64@7.0.0-dev.20260707.2': resolution: {integrity: sha512-DL4u27stv0fo71sVhOzHSwE+YMZsbBijVI+kg5dLDLilSH79WFTJ8RSQ46vJrCMt+Gjlv/JOZP1PuLJDfioYeQ==} engines: {node: '>=16.20.0'} cpu: [x64] os: [win32] + '@typescript/native-preview@7.0.0-dev.20260603.1': + resolution: {integrity: sha512-CO519Ccw5rji4JIG0DGVMR5owraCeQhm94jM53eRhMdlzz0nAJcAZ63Y6m1u3dUwqGssqlYxh4CcwTFPxTpMYw==} + engines: {node: '>=16.20.0'} + hasBin: true + '@typescript/native-preview@7.0.0-dev.20260707.2': resolution: {integrity: sha512-oUGp+Rep/hqMhPunyinsALUwSlzHINSxitifPiSaeqoKOKD2OlR9NE3TaPqwsl4NlGslsOSUXI1JotWQzpYCPg==} engines: {node: '>=16.20.0'} @@ -15255,6 +15324,11 @@ packages: unrun: optional: true + tshy@3.3.2: + resolution: {integrity: sha512-vOIXkqMtBWNjKUR/c99+6N50LhWdnKG1xE3+5wf8IPdzxx2lcIFPvbGgFdBBgoTMbdNb8mz06MUm7hY+TFnJcw==} + engines: {node: 20 || >=22} + hasBin: true + tshy@4.1.3: resolution: {integrity: sha512-uEaLO1lFhu5X58KZxS5gKCabh+xd72MfXDOHhAIvnoreBAP1F4HNpbK2m0DUmrrzpcgxvXbsdXn1A0OaQxFYqw==} engines: {node: 20 || >=22} @@ -19493,7 +19567,7 @@ snapshots: json-parse-even-better-errors: 3.0.0 normalize-package-data: 5.0.0 proc-log: 3.0.0 - semver: 7.8.1 + semver: 7.8.5 transitivePeerDependencies: - bluebird @@ -23350,27 +23424,58 @@ snapshots: dependencies: '@types/node': 24.13.3 + '@typescript/native-preview-darwin-arm64@7.0.0-dev.20260603.1': + optional: true + '@typescript/native-preview-darwin-arm64@7.0.0-dev.20260707.2': optional: true + '@typescript/native-preview-darwin-x64@7.0.0-dev.20260603.1': + optional: true + '@typescript/native-preview-darwin-x64@7.0.0-dev.20260707.2': optional: true + '@typescript/native-preview-linux-arm64@7.0.0-dev.20260603.1': + optional: true + '@typescript/native-preview-linux-arm64@7.0.0-dev.20260707.2': optional: true + '@typescript/native-preview-linux-arm@7.0.0-dev.20260603.1': + optional: true + '@typescript/native-preview-linux-arm@7.0.0-dev.20260707.2': optional: true + '@typescript/native-preview-linux-x64@7.0.0-dev.20260603.1': + optional: true + '@typescript/native-preview-linux-x64@7.0.0-dev.20260707.2': optional: true + '@typescript/native-preview-win32-arm64@7.0.0-dev.20260603.1': + optional: true + '@typescript/native-preview-win32-arm64@7.0.0-dev.20260707.2': optional: true + '@typescript/native-preview-win32-x64@7.0.0-dev.20260603.1': + optional: true + '@typescript/native-preview-win32-x64@7.0.0-dev.20260707.2': optional: true + '@typescript/native-preview@7.0.0-dev.20260603.1': + optionalDependencies: + '@typescript/native-preview-darwin-arm64': 7.0.0-dev.20260603.1 + '@typescript/native-preview-darwin-x64': 7.0.0-dev.20260603.1 + '@typescript/native-preview-linux-arm': 7.0.0-dev.20260603.1 + '@typescript/native-preview-linux-arm64': 7.0.0-dev.20260603.1 + '@typescript/native-preview-linux-x64': 7.0.0-dev.20260603.1 + '@typescript/native-preview-win32-arm64': 7.0.0-dev.20260603.1 + '@typescript/native-preview-win32-x64': 7.0.0-dev.20260603.1 + '@typescript/native-preview@7.0.0-dev.20260707.2': optionalDependencies: '@typescript/native-preview-darwin-arm64': 7.0.0-dev.20260707.2 @@ -31458,6 +31563,22 @@ snapshots: - oxc-resolver - vue-tsc + tshy@3.3.2: + dependencies: + '@typescript/native-preview': 7.0.0-dev.20260603.1 + chalk: 5.6.2 + chokidar: 4.0.3 + foreground-child: 4.0.3 + jsonc-simple-parser: 3.0.0 + minimatch: 10.2.5 + mkdirp: 3.0.1 + polite-json: 5.0.0 + resolve-import: 2.4.0 + rimraf: 6.1.3 + sync-content: 2.0.4 + typescript: 5.9.3 + walk-up-path: 4.0.0 + tshy@4.1.3: dependencies: '@typescript/native-preview': 7.0.0-dev.20260707.2