experimental-ash 0.9.0 → 0.10.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +24 -0
- package/dist/docs/public/channels/slack.md +23 -0
- package/dist/src/internal/application/package.js +1 -1
- package/dist/src/public/channels/slack/index.d.ts +1 -1
- package/dist/src/public/channels/slack/slack.d.ts +2 -1
- package/dist/src/public/channels/slack/slack.js +1 -0
- package/dist/src/public/channels/slack/slackChannel.d.ts +20 -1
- package/dist/src/public/channels/slack/slackChannel.js +78 -57
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,29 @@
|
|
|
1
1
|
# experimental-ash
|
|
2
2
|
|
|
3
|
+
## 0.10.1
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- 05ab89c: fix(slack): eliminate duplicate replies on follow-up mentions in a thread
|
|
8
|
+
|
|
9
|
+
`slackChannel` registered its `chat.onNewMention` listener inside the route handler, but the chat SDK's `onNewMention` _pushes_ handlers to an internal list (it does not replace). On warm serverless workers the cached `chat` instance accumulated one listener per inbound webhook, so the N-th `app_mention` dispatched N independent agent turns. Symptom: the first mention in a thread replied once, the next replied twice, the next three times, etc. — reset only by a cold start.
|
|
10
|
+
|
|
11
|
+
Registration is now done once per `chat` instance inside `getChat()`. The route handler threads its per-request `send` / `waitUntil` to the listener through a per-`slackChannel` `AsyncLocalStorage` — `requestContext.run({ send, waitUntil }, () => chat.webhooks.slack(req, ...))`. A naive shared module slot would race under concurrent webhook deliveries (the chat SDK yields the event loop on `await req.text()` inside `webhooks.slack`, so a second request can overwrite the slot before the first listener fires, causing request A's mention to dispatch with request B's `send`). ALS binds the store to the async call tree, so each concurrent route invocation gets its own isolated context.
|
|
12
|
+
|
|
13
|
+
The mention dispatch also now runs under `waitUntil(send(...).catch(log))` instead of `await send(...)`, mirroring the interaction path. This lets the webhook return `200 OK` within Slack's ~3s ACK window even on cold starts, avoiding the secondary duplication mode where Slack retries the same `app_mention` payload after a slow ACK.
|
|
14
|
+
|
|
15
|
+
## 0.10.0
|
|
16
|
+
|
|
17
|
+
### Minor Changes
|
|
18
|
+
|
|
19
|
+
- 5bc39de: feat(slack): pluggable `stateAdapter` for production persistence
|
|
20
|
+
|
|
21
|
+
`slackChannel` and `slack()` now accept a `stateAdapter` option implementing the chat SDK `StateAdapter` contract. Used for callback URLs on posted cards, `thread.state` / `thread.setState`, `thread.subscribe`, message queues, and locks. Defaults to an in-memory adapter (development only — loses state on restart, not shared across processes). Pass a Redis/Postgres-backed adapter for production.
|
|
22
|
+
|
|
23
|
+
The supplied instance is shared across the inbound webhook path (`Chat` constructor) and every event-handler context rebuild, so state stays coherent within a single agent run. The default in-memory adapter is now constructed once at channel-construction time and shared the same way, replacing the previous behavior where the inbound path and each rebuild call each got their own isolated memory adapter.
|
|
24
|
+
|
|
25
|
+
A new exported type alias `SlackStateAdapter` aliases the chat SDK's `StateAdapter` interface.
|
|
26
|
+
|
|
3
27
|
## 0.9.0
|
|
4
28
|
|
|
5
29
|
### Minor Changes
|
|
@@ -169,6 +169,29 @@ export default slackChannel({
|
|
|
169
169
|
(`turn.started`, `message.completed`, `session.failed`, etc.). They run inside the workflow context,
|
|
170
170
|
not on the inbound webhook side.
|
|
171
171
|
|
|
172
|
+
## State Persistence
|
|
173
|
+
|
|
174
|
+
The Slack channel uses a state backend for callback URLs on posted cards, `thread.state` /
|
|
175
|
+
`thread.setState`, `thread.subscribe`, message queues, and locks. By default this is an in-memory
|
|
176
|
+
adapter scoped to the current process, which loses state on restart and is not coherent across
|
|
177
|
+
processes. For deployed agents, pass a production adapter (Redis, Postgres, etc.) implementing the
|
|
178
|
+
chat SDK `StateAdapter` contract. Construct it once at module top-level so all requests share one
|
|
179
|
+
connection pool:
|
|
180
|
+
|
|
181
|
+
```ts
|
|
182
|
+
import { createRedisState } from "@chat-adapter/state-redis";
|
|
183
|
+
import { slack } from "experimental-ash/channels/slack";
|
|
184
|
+
|
|
185
|
+
const stateAdapter = createRedisState({ url: process.env.REDIS_URL! });
|
|
186
|
+
|
|
187
|
+
export default slack({
|
|
188
|
+
stateAdapter,
|
|
189
|
+
});
|
|
190
|
+
```
|
|
191
|
+
|
|
192
|
+
The same instance is reused across the inbound webhook path and every event-handler context rebuild,
|
|
193
|
+
so state stays coherent within a single agent run.
|
|
194
|
+
|
|
172
195
|
## Typing Indicators
|
|
173
196
|
|
|
174
197
|
Out of the box, `slack()` posts typing statuses so the user sees feedback before the agent starts
|
|
@@ -6,7 +6,7 @@ import { ASH_PACKAGE_NAME } from "#package-name.js";
|
|
|
6
6
|
let cachedPackageInfo;
|
|
7
7
|
// The package build stamps the published version into `dist` so bundled
|
|
8
8
|
// deployments can still report package metadata without resolving package.json.
|
|
9
|
-
const BUNDLED_FALLBACK_PACKAGE_VERSION = "0.
|
|
9
|
+
const BUNDLED_FALLBACK_PACKAGE_VERSION = "0.10.1";
|
|
10
10
|
const BUNDLED_FALLBACK_PACKAGE_VERSION_PLACEHOLDER = "__ASH_PACKAGE_VERSION_PLACEHOLDER__";
|
|
11
11
|
const WORKFLOW_MODULE_ALIASES = {
|
|
12
12
|
"workflow/api": "src/compiled/@workflow/core/runtime.js",
|
|
@@ -1,4 +1,4 @@
|
|
|
1
1
|
export { slack, type SlackOptions } from "#public/channels/slack/slack.js";
|
|
2
|
-
export { slackChannel, type SlackApiHandle, type SlackApiResponse, type SlackChannel, type SlackChannelConfig, type SlackChannelEvents, type SlackChannelCredentials, type SlackChannelState, type SlackContext, type SlackInteractionAction, type SlackReceiveArgs, type SlackWebhookVerifier, } from "#public/channels/slack/slackChannel.js";
|
|
2
|
+
export { slackChannel, type SlackApiHandle, type SlackApiResponse, type SlackChannel, type SlackChannelConfig, type SlackChannelEvents, type SlackChannelCredentials, type SlackChannelState, type SlackContext, type SlackInteractionAction, type SlackReceiveArgs, type SlackStateAdapter, type SlackWebhookVerifier, } from "#public/channels/slack/slackChannel.js";
|
|
3
3
|
export { Actions, Button, Card, CardText, Divider, Fields, Image, LinkButton, Modal, RadioSelect, Section, Select, SelectOption, Table, TextInput, } from "#compiled/chat/index.js";
|
|
4
4
|
export type { AdapterPostableMessage, Attachment, Author, CardElement, FileUpload, Message, PostableMessage, SentMessage, Thread, } from "#compiled/chat/index.js";
|
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
import type { SessionAuthContext } from "#channel/types.js";
|
|
2
2
|
import type { Channel } from "#public/definitions/defineChannel.js";
|
|
3
|
-
import { type SlackChannelCredentials, type SlackChannelState } from "#public/channels/slack/slackChannel.js";
|
|
3
|
+
import { type SlackChannelCredentials, type SlackChannelState, type SlackStateAdapter } from "#public/channels/slack/slackChannel.js";
|
|
4
4
|
export interface SlackOptions {
|
|
5
5
|
readonly auth?: (message: import("#compiled/chat/index.js").Message) => SessionAuthContext | null;
|
|
6
6
|
readonly credentials?: SlackChannelCredentials;
|
|
7
7
|
readonly botName?: string;
|
|
8
|
+
readonly stateAdapter?: SlackStateAdapter;
|
|
8
9
|
}
|
|
9
10
|
export declare function slack(options?: SlackOptions): Channel<SlackChannelState>;
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { type SlackBotToken } from "#compiled/@chat-adapter/slack/index.js";
|
|
2
|
-
import { type Message, type SerializedThread, type Thread } from "#compiled/chat/index.js";
|
|
2
|
+
import { type Message, type SerializedThread, type StateAdapter, type Thread } from "#compiled/chat/index.js";
|
|
3
3
|
import type { HandleMessageStreamEvent } from "#protocol/message.js";
|
|
4
4
|
import { type UploadPolicy } from "#public/channels/upload-policy.js";
|
|
5
5
|
import { type Channel } from "#public/definitions/defineChannel.js";
|
|
@@ -36,6 +36,14 @@ export interface SlackChannelState {
|
|
|
36
36
|
* with Vercel OIDC instead of Slack's signing secret.
|
|
37
37
|
*/
|
|
38
38
|
export type SlackWebhookVerifier = (request: Request, body: string) => unknown | Promise<unknown>;
|
|
39
|
+
/**
|
|
40
|
+
* State backend used by the chat SDK for callback URL persistence on
|
|
41
|
+
* posted cards, `thread.state`/`setState`, `thread.subscribe`, message
|
|
42
|
+
* queues, and locks. Any value implementing the chat SDK
|
|
43
|
+
* `StateAdapter` contract works — `@chat-adapter/state-memory` for
|
|
44
|
+
* local development, a Redis/Postgres-backed adapter for production.
|
|
45
|
+
*/
|
|
46
|
+
export type SlackStateAdapter = StateAdapter;
|
|
39
47
|
export interface SlackChannelCredentials {
|
|
40
48
|
readonly botToken?: SlackBotToken;
|
|
41
49
|
/**
|
|
@@ -93,6 +101,17 @@ export interface SlackChannelEvents {
|
|
|
93
101
|
export interface SlackChannelConfig {
|
|
94
102
|
readonly credentials?: SlackChannelCredentials;
|
|
95
103
|
readonly botName?: string;
|
|
104
|
+
/**
|
|
105
|
+
* State backend for chat SDK persistence — callback URLs on posted
|
|
106
|
+
* cards, `thread.state` / `thread.setState`, `thread.subscribe`,
|
|
107
|
+
* message queues, locks. Defaults to an in-memory adapter scoped to
|
|
108
|
+
* the current process, which loses state on restart and is not
|
|
109
|
+
* shared across processes. Pass a production adapter (Redis,
|
|
110
|
+
* Postgres, etc.) for deployed agents. The same instance is reused
|
|
111
|
+
* across the inbound webhook path and every event-handler context
|
|
112
|
+
* rebuild, keeping state coherent within a single agent run.
|
|
113
|
+
*/
|
|
114
|
+
readonly stateAdapter?: SlackStateAdapter;
|
|
96
115
|
/**
|
|
97
116
|
* Override the default webhook route path (`/ash/v1/slack`).
|
|
98
117
|
*/
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { AsyncLocalStorage } from "node:async_hooks";
|
|
1
2
|
import { createSlackAdapter } from "#compiled/@chat-adapter/slack/index.js";
|
|
2
3
|
import { createMemoryState } from "#compiled/@chat-adapter/state-memory/index.js";
|
|
3
4
|
import { ThreadImpl, } from "#compiled/chat/index.js";
|
|
@@ -5,7 +6,7 @@ import { createLogger } from "#internal/logging.js";
|
|
|
5
6
|
import { buildSlackTurnMessage, collectSlackFileParts, createSlackFetchFile, } from "#public/channels/slack/attachments.js";
|
|
6
7
|
import { deriveHitlResponse, isHitlAction, renderInputRequestBlocks, } from "#public/channels/slack/hitl.js";
|
|
7
8
|
import { mergeUploadPolicy } from "#public/channels/upload-policy.js";
|
|
8
|
-
import { defineChannel, POST } from "#public/definitions/defineChannel.js";
|
|
9
|
+
import { defineChannel, POST, } from "#public/definitions/defineChannel.js";
|
|
9
10
|
const log = createLogger("slack.channel");
|
|
10
11
|
function decodeThreadId(id) {
|
|
11
12
|
const parts = id.replace(/^slack:/u, "").split(":");
|
|
@@ -94,7 +95,7 @@ function resolveSlackAdapterCredentials(credentials) {
|
|
|
94
95
|
const signingSecret = credentials?.signingSecret ?? (webhookVerifier ? undefined : process.env.SLACK_SIGNING_SECRET);
|
|
95
96
|
return { botToken, signingSecret, webhookVerifier };
|
|
96
97
|
}
|
|
97
|
-
function rebuildSlackContext(state, credentials) {
|
|
98
|
+
function rebuildSlackContext(state, credentials, stateAdapter) {
|
|
98
99
|
const { botToken, signingSecret, webhookVerifier } = resolveSlackAdapterCredentials(credentials);
|
|
99
100
|
const adapter = createSlackAdapter({
|
|
100
101
|
botToken,
|
|
@@ -109,20 +110,8 @@ function rebuildSlackContext(state, credentials) {
|
|
|
109
110
|
id: "slack::",
|
|
110
111
|
isDM: false,
|
|
111
112
|
});
|
|
112
|
-
// Pin both private adapter slots so the thread is fully self-
|
|
113
|
-
// contained — no `Chat.getSingleton()` lookup needed when handlers
|
|
114
|
-
// call `thread.post()`, `thread.startTyping()`, `thread.setState()`,
|
|
115
|
-
// `thread.subscribe()`, etc. The `_adapter` line matches the runtime
|
|
116
|
-
// behavior chat itself relies on for `ThreadImpl.fromJSON(json,
|
|
117
|
-
// adapter)`. `ThreadImpl.fromJSON` does NOT pin a state adapter, so
|
|
118
|
-
// without `_stateAdapterInstance` set, state-touching paths (cards,
|
|
119
|
-
// callback URLs, `state`/`setState`, `subscribe()`/`isSubscribed()`)
|
|
120
|
-
// would fall through to the singleton and throw — we use an in-
|
|
121
|
-
// memory state adapter scoped to this rebuild so the asymmetry with
|
|
122
|
-
// the fallback branch goes away and both branches converge to a
|
|
123
|
-
// fully-pinned thread.
|
|
124
113
|
Reflect.set(thread, "_adapter", adapter);
|
|
125
|
-
Reflect.set(thread, "_stateAdapterInstance",
|
|
114
|
+
Reflect.set(thread, "_stateAdapterInstance", stateAdapter);
|
|
126
115
|
return {
|
|
127
116
|
thread,
|
|
128
117
|
slack: buildSlackApiHandle(thread, botToken, state.teamId ?? undefined),
|
|
@@ -152,6 +141,15 @@ export function slackChannel(config = {}) {
|
|
|
152
141
|
const slackFetchFile = createSlackFetchFile({
|
|
153
142
|
botToken: config.credentials?.botToken,
|
|
154
143
|
});
|
|
144
|
+
const stateAdapter = config.stateAdapter ?? createMemoryState();
|
|
145
|
+
// Threads per-request `send`/`waitUntil` to the once-registered
|
|
146
|
+
// `onNewMention` listener. A shared mutable slot would race under
|
|
147
|
+
// concurrent webhook deliveries — `webhooks.slack` yields on
|
|
148
|
+
// `await req.text()`, so request B could overwrite A's slot before A's
|
|
149
|
+
// listener fires. ALS scopes the store to each route's async call tree.
|
|
150
|
+
// Allowlisted in `scripts/guard-agents-rules-baseline.json` (rule 19);
|
|
151
|
+
// this is channel-local request state, not Ash runtime state.
|
|
152
|
+
const requestContext = new AsyncLocalStorage();
|
|
155
153
|
let chatPromise = null;
|
|
156
154
|
async function getChat() {
|
|
157
155
|
if (chatPromise)
|
|
@@ -161,9 +159,8 @@ export function slackChannel(config = {}) {
|
|
|
161
159
|
if (!botToken) {
|
|
162
160
|
throw new Error("slackChannel requires a bot token. Pass credentials.botToken or set SLACK_BOT_TOKEN.");
|
|
163
161
|
}
|
|
164
|
-
const [slackModule,
|
|
162
|
+
const [slackModule, chatModule] = await Promise.all([
|
|
165
163
|
import("#compiled/@chat-adapter/slack/index.js"),
|
|
166
|
-
import("#compiled/@chat-adapter/state-memory/index.js"),
|
|
167
164
|
import("#compiled/chat/index.js"),
|
|
168
165
|
]);
|
|
169
166
|
const slackAdapter = slackModule.createSlackAdapter({
|
|
@@ -174,10 +171,69 @@ export function slackChannel(config = {}) {
|
|
|
174
171
|
});
|
|
175
172
|
const chat = new chatModule.Chat({
|
|
176
173
|
adapters: { slack: slackAdapter },
|
|
177
|
-
state:
|
|
174
|
+
state: stateAdapter,
|
|
178
175
|
userName: config.botName ?? "ash-agent",
|
|
179
176
|
});
|
|
180
177
|
await chat.initialize();
|
|
178
|
+
// Register exactly once per chat instance — the chat SDK's
|
|
179
|
+
// `onNewMention` pushes handlers (does not replace), so registering
|
|
180
|
+
// on every webhook accumulates listeners on warm workers and the
|
|
181
|
+
// N-th `app_mention` dispatches N times.
|
|
182
|
+
chat.onNewMention(async (thread, message) => {
|
|
183
|
+
const rawEvent = message.raw;
|
|
184
|
+
// Slack sends both `app_mention` and `message.channels` for the same
|
|
185
|
+
// utterance. The Chat SDK dedup relies on in-memory state that doesn't
|
|
186
|
+
// survive serverless invocations, so both events reach this handler.
|
|
187
|
+
// Only process `app_mention` to prevent duplicate runs.
|
|
188
|
+
if (rawEvent?.type !== "app_mention")
|
|
189
|
+
return;
|
|
190
|
+
const ctx = requestContext.getStore();
|
|
191
|
+
if (!ctx) {
|
|
192
|
+
// Defensive: the chat SDK invokes handlers inside the
|
|
193
|
+
// `requestContext.run(...)` tree below, so this should never
|
|
194
|
+
// miss. Log and drop in case a future SDK ever defers
|
|
195
|
+
// invocation (e.g. via a queue or `setImmediate`).
|
|
196
|
+
log.warn("slack mention received but no request context is active");
|
|
197
|
+
return;
|
|
198
|
+
}
|
|
199
|
+
const teamId = rawEvent.team_id ?? rawEvent.team;
|
|
200
|
+
const slackCtx = {
|
|
201
|
+
thread,
|
|
202
|
+
slack: buildSlackApiHandle(thread, config.credentials?.botToken, teamId),
|
|
203
|
+
};
|
|
204
|
+
const runOpts = config.run ? await config.run(slackCtx, message) : { auth: null };
|
|
205
|
+
if (runOpts === null)
|
|
206
|
+
return;
|
|
207
|
+
if (config.onMention) {
|
|
208
|
+
try {
|
|
209
|
+
await config.onMention(slackCtx, message);
|
|
210
|
+
}
|
|
211
|
+
catch (error) {
|
|
212
|
+
log.error("onMention handler failed", { error });
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
const decoded = decodeThreadId(thread.id ?? "");
|
|
216
|
+
const continuationToken = `slack:${decoded.channelId}:${decoded.threadTs}`;
|
|
217
|
+
const fileParts = collectSlackFileParts(message, uploadPolicy);
|
|
218
|
+
const turnMessage = buildSlackTurnMessage(message.text, fileParts);
|
|
219
|
+
// Dispatch via `waitUntil` so the webhook ACKs within Slack's ~3s
|
|
220
|
+
// window even on cold starts. Awaiting `send` here would let a
|
|
221
|
+
// slow dispatch push the response past 3s and trigger Slack
|
|
222
|
+
// retries of the same `app_mention` (which the type filter
|
|
223
|
+
// cannot dedupe). Errors are logged; Slack already got its 200.
|
|
224
|
+
ctx.waitUntil(ctx
|
|
225
|
+
.send(turnMessage, {
|
|
226
|
+
auth: runOpts.auth,
|
|
227
|
+
continuationToken,
|
|
228
|
+
state: {
|
|
229
|
+
serializedThread: thread.toJSON(),
|
|
230
|
+
teamId: teamId ?? null,
|
|
231
|
+
},
|
|
232
|
+
})
|
|
233
|
+
.catch((error) => {
|
|
234
|
+
log.error("mention delivery failed", { error });
|
|
235
|
+
}));
|
|
236
|
+
});
|
|
181
237
|
return { chat };
|
|
182
238
|
})();
|
|
183
239
|
chatPromise = promise;
|
|
@@ -191,7 +247,7 @@ export function slackChannel(config = {}) {
|
|
|
191
247
|
state: { serializedThread: null, teamId: null },
|
|
192
248
|
fetchFile: slackFetchFile,
|
|
193
249
|
context(state) {
|
|
194
|
-
return rebuildSlackContext(state, config.credentials);
|
|
250
|
+
return rebuildSlackContext(state, config.credentials, stateAdapter);
|
|
195
251
|
},
|
|
196
252
|
routes: [
|
|
197
253
|
POST(config.route ?? "/ash/v1/slack", async (req, { send, waitUntil }) => {
|
|
@@ -255,44 +311,9 @@ export function slackChannel(config = {}) {
|
|
|
255
311
|
}
|
|
256
312
|
return new Response("ok", { status: 200 });
|
|
257
313
|
}
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
// utterance. The Chat SDK dedup relies on in-memory state that doesn't
|
|
262
|
-
// survive serverless invocations, so both events reach this handler.
|
|
263
|
-
// Only process `app_mention` to prevent duplicate runs.
|
|
264
|
-
if (rawEvent?.type !== "app_mention")
|
|
265
|
-
return;
|
|
266
|
-
const teamId = rawEvent.team_id ?? rawEvent.team;
|
|
267
|
-
const slackCtx = {
|
|
268
|
-
thread,
|
|
269
|
-
slack: buildSlackApiHandle(thread, config.credentials?.botToken, teamId),
|
|
270
|
-
};
|
|
271
|
-
const runOpts = config.run ? await config.run(slackCtx, message) : { auth: null };
|
|
272
|
-
if (runOpts === null)
|
|
273
|
-
return;
|
|
274
|
-
if (config.onMention) {
|
|
275
|
-
try {
|
|
276
|
-
await config.onMention(slackCtx, message);
|
|
277
|
-
}
|
|
278
|
-
catch (error) {
|
|
279
|
-
log.error("onMention handler failed", { error });
|
|
280
|
-
}
|
|
281
|
-
}
|
|
282
|
-
const decoded = decodeThreadId(thread.id ?? "");
|
|
283
|
-
const continuationToken = `slack:${decoded.channelId}:${decoded.threadTs}`;
|
|
284
|
-
const fileParts = collectSlackFileParts(message, uploadPolicy);
|
|
285
|
-
const turnMessage = buildSlackTurnMessage(message.text, fileParts);
|
|
286
|
-
await send(turnMessage, {
|
|
287
|
-
auth: runOpts.auth,
|
|
288
|
-
continuationToken,
|
|
289
|
-
state: {
|
|
290
|
-
serializedThread: thread.toJSON(),
|
|
291
|
-
teamId: teamId ?? null,
|
|
292
|
-
},
|
|
293
|
-
});
|
|
294
|
-
});
|
|
295
|
-
return await chat.webhooks.slack(req, { waitUntil });
|
|
314
|
+
// Scope `send` / `waitUntil` to this request's async call tree so
|
|
315
|
+
// the `onNewMention` listener picks them up via `getStore()`.
|
|
316
|
+
return await requestContext.run({ send, waitUntil }, () => chat.webhooks.slack(req, { waitUntil }));
|
|
296
317
|
}),
|
|
297
318
|
],
|
|
298
319
|
async receive(input, { send }) {
|