@gea-ai/agent-sdk 0.1.260910-alpha.0 → 0.1.260910-alpha.2

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.
Files changed (51) hide show
  1. package/README.md +3 -1253
  2. package/dist/agent-channel-author-runtime.d.ts +79 -0
  3. package/dist/agent-channel-author-runtime.d.ts.map +1 -0
  4. package/dist/agent-channel-author-runtime.js +511 -0
  5. package/dist/agent-channel-author-runtime.js.map +1 -0
  6. package/dist/agent-channel-receiver.d.ts +46 -0
  7. package/dist/agent-channel-receiver.d.ts.map +1 -0
  8. package/dist/agent-channel-receiver.js +356 -0
  9. package/dist/agent-channel-receiver.js.map +1 -0
  10. package/dist/agent-channel-runtime.d.ts +6 -0
  11. package/dist/agent-channel-runtime.d.ts.map +1 -0
  12. package/dist/agent-channel-runtime.js +623 -0
  13. package/dist/agent-channel-runtime.js.map +1 -0
  14. package/dist/agent-channel-worker.d.ts +22 -0
  15. package/dist/agent-channel-worker.d.ts.map +1 -0
  16. package/dist/agent-channel-worker.js +335 -0
  17. package/dist/agent-channel-worker.js.map +1 -0
  18. package/dist/agent-session.d.ts.map +1 -1
  19. package/dist/agent-session.js +177 -4
  20. package/dist/agent-session.js.map +1 -1
  21. package/dist/agent-worker.d.ts +27 -1
  22. package/dist/agent-worker.d.ts.map +1 -1
  23. package/dist/agent-worker.js +867 -27
  24. package/dist/agent-worker.js.map +1 -1
  25. package/dist/channels/feishu-codec.d.ts +18 -0
  26. package/dist/channels/feishu-codec.d.ts.map +1 -0
  27. package/dist/channels/feishu-codec.js +109 -0
  28. package/dist/channels/feishu-codec.js.map +1 -0
  29. package/dist/channels/feishu-inbound.d.ts +35 -0
  30. package/dist/channels/feishu-inbound.d.ts.map +1 -0
  31. package/dist/channels/feishu-inbound.js +136 -0
  32. package/dist/channels/feishu-inbound.js.map +1 -0
  33. package/dist/channels/feishu-websocket.d.ts +5 -0
  34. package/dist/channels/feishu-websocket.d.ts.map +1 -0
  35. package/dist/channels/feishu-websocket.js +424 -0
  36. package/dist/channels/feishu-websocket.js.map +1 -0
  37. package/dist/channels/feishu.d.ts +32 -0
  38. package/dist/channels/feishu.d.ts.map +1 -0
  39. package/dist/channels/feishu.js +233 -0
  40. package/dist/channels/feishu.js.map +1 -0
  41. package/dist/channels/index.d.ts +180 -0
  42. package/dist/channels/index.d.ts.map +1 -0
  43. package/dist/channels/index.js +93 -0
  44. package/dist/channels/index.js.map +1 -0
  45. package/dist/index.d.ts +18 -0
  46. package/dist/index.d.ts.map +1 -1
  47. package/dist/index.js +38 -8
  48. package/dist/index.js.map +1 -1
  49. package/package.json +16 -3
  50. package/src/channels/README.md +298 -0
  51. package/src/channels/feishu.ts +295 -0
@@ -0,0 +1,298 @@
1
+ # Programmable Channels
2
+
3
+ Last verified: 2026-09-10
4
+
5
+ This document is for Agent developers adding a transport or provider to a GEA Agent.
6
+ A Channel owns protocol validation, external identity, conversation addressing,
7
+ message conversion and output delivery. The framework owns installation authority,
8
+ stable Agent sessions, execution, the event journal and durable delivery.
9
+
10
+ ## File discovery and the executable definition
11
+
12
+ Default-export a Channel from `channels/**/*.ts` beside its owning `agent.ts`.
13
+ The CLI discovers it automatically. A multi-main application gives each Agent its
14
+ own channel directory. No manual registration in `agent.ts` is required.
15
+
16
+ ```ts
17
+ // channels/api.ts
18
+ import { defineChannel, POST } from "@gea-ai/agent-sdk/channels";
19
+ import { env } from "@gea-ai/agent-sdk";
20
+ import { z } from "zod";
21
+
22
+ const inputSchema = z.object({
23
+ principalId: z.string().min(1),
24
+ conversation: z.string().min(1),
25
+ id: z.string().min(1),
26
+ text: z.string().min(1),
27
+ });
28
+
29
+ export default defineChannel({
30
+ key: "api",
31
+ path: "/channels/api",
32
+ env: { CHANNEL_TOKEN: env.secret() },
33
+ routes: [
34
+ POST("/", async (request, ctx) => {
35
+ const token = ctx.env.CHANNEL_TOKEN;
36
+ if (
37
+ !token ||
38
+ request.headers.get("authorization") !== `Bearer ${token}`
39
+ ) {
40
+ return new Response("Unauthorized", { status: 401 });
41
+ }
42
+ const input = inputSchema.parse(await request.json());
43
+ const receipt = await ctx
44
+ .from(input.conversation, {
45
+ principal: { principalType: "user", principalId: input.principalId },
46
+ })
47
+ .send(input.text, {
48
+ operationId: input.id,
49
+ });
50
+ return Response.json(
51
+ {
52
+ operationId: receipt.operationId,
53
+ sessionId: receipt.session.sessionId,
54
+ duplicate: receipt.duplicate,
55
+ },
56
+ { status: 202 },
57
+ );
58
+ }),
59
+ ],
60
+ });
61
+ ```
62
+
63
+ The route base path belongs to the Channel; `/` is its base path and `/events`
64
+ becomes `<base>/events`. A WebSocket route occupies its GET endpoint. Routes,
65
+ callbacks and schemas remain executable code in the Worker bundle. Only the
66
+ `descriptor` is serialized into the package snapshot. Environment declarations
67
+ merge into the owning Agent; conflicting declarations fail composition. Secret
68
+ values never enter the snapshot. Custom Channels use `queue` and preserve stable
69
+ key, adapter version and state version independently from the filename.
70
+
71
+ ## Input and session handles
72
+
73
+ `from(address, { principal }).send(message, options)` waits for durable acceptance, not model
74
+ completion. Await it before acknowledging a provider webhook. External address
75
+ and operation IDs are converted into the installation's internal namespace.
76
+ Duplicate IDs and identical input return the same acceptance; reusing an ID with
77
+ different input fails.
78
+
79
+ A message is a string or one UI user message:
80
+
81
+ ```ts
82
+ { role: "user", parts: [
83
+ { type: "text", text: "Describe this image" },
84
+ { type: "file", mediaType: "image/png", url: "data:image/png;base64,..." },
85
+ ] }
86
+ ```
87
+
88
+ Remote provider files must be read through authorized `ctx.fetch` and converted
89
+ to inline data. Arbitrary remote URLs, uploaded history, system messages and
90
+ platform identity are not accepted. Inputs are limited to 32 parts and 8 MiB;
91
+ text parts are limited to 64 KiB.
92
+
93
+ `initialState` initializes a new address; `data` travels with one operation;
94
+ `actor` records external identity without granting GEA permissions.
95
+ The Channel must authenticate the sender before selecting a required external user
96
+ principal; `gea:` is reserved. The example token is a trusted-backend credential,
97
+ not an end-user credential. A session handle captures its principal. Addresses,
98
+ history and events are isolated by principal ID; attributes are not isolation keys.
99
+ Personal Connections use the existing Studio Project/environment principal scope.
100
+ Missing authorization returns the existing link in the private reply; after
101
+ authorizing, send a new operation rather than replaying the completed one. Keep reply
102
+ message IDs in `data`, never in a mutable "latest message" state field.
103
+
104
+ The receipt contains the accepted operation’s `executionTarget` and a stable session handle. New messages on that handle follow the current Worker deployment; accepted work keeps its target. A handle exposes
105
+ `send`, `history({ after, limit })` and `events({ after, signal })`.
106
+ `resolveSession(address, { principal })` returns the current handle, or null if the address has
107
+ never received input. A handle is not a bearer credential; calls still enforce
108
+ installation authority.
109
+
110
+ ## Journal, state and output
111
+
112
+ The journal exposes `run.started`, `run.succeeded` and `run.failed`, plus model
113
+ stream event types such as `text-delta` and Tool input/output events. Every event
114
+ has `id`, `sequence`, `operationId`, `runId`, `attempt`, `type` and JSON `data`.
115
+ `run.succeeded` carries `{ reply: { messageId, text } }` after the Agent result has
116
+ been committed; a model stream `finish` does not establish that guarantee.
117
+
118
+ Live consumers use the same cursor-based journal as durable event handlers. An
119
+ HTTP handler can return an SSE Response over `session.events()`; a `WS()` handler
120
+ can send any text or binary framing it chooses. Its optional third argument
121
+ `{ authorize(request, ctx), protocols }` authenticates the handshake before
122
+ upgrade; return a rejection Response to deny it. Socket callbacks can also inspect
123
+ `ctx.request`, the original handshake. Disconnecting a subscriber does
124
+ not cancel accepted work. Keep and resume from the last observed sequence.
125
+
126
+ Declare `state` and `stateSchema` for durable channel state; `context(state)`
127
+ reconstructs temporary values for each projection. Event callbacks receive
128
+ `{ state, context, input, outbox }` and are the only authors of persistent state.
129
+ They do not perform network requests. State, output intents and the event
130
+ checkpoint commit together. Invalid state fails rather than resetting silently.
131
+
132
+ Use `outbox.enqueue(key, payload)` in an event and validate it with
133
+ `outboundSchema`. The same event/key combination always denotes the same payload.
134
+ `outbound(delivery, ctx)` receives an immutable task with a stable UUID, attempt
135
+ and persisted first-attempt time. It returns one of:
136
+
137
+ - `sent`, optionally with a JSON provider receipt.
138
+ - `retryable`, for a known unsent failure, optionally with `retryAfterMs`.
139
+ - `failed`, for a definitive permanent failure.
140
+ - `unknown`, when the provider may have sent the message.
141
+
142
+ Declare `idempotencyWindowMs` only if the provider guarantees deduplication for
143
+ the supplied stable delivery ID. Expired or unsupported ambiguous deliveries
144
+ require an operator decision. An unknown send pauses later output for that address
145
+ while its accepted Agent inputs continue. The authorized operator can confirm
146
+ sent, retry explicitly or abandon; retry preserves the original UUID and first
147
+ attempt time. Delivery outcomes and operator confirmations appear in the same
148
+ journal as `delivery.sent`, `delivery.failed`, `delivery.retryable` and
149
+ `delivery.unknown`. Agent execution and output delivery are separate statuses.
150
+ Provider rules and error classification belong in the Channel.
151
+
152
+ ## Framework-managed receivers
153
+
154
+ Declare `receiver: { kind: "websocket", version: 1, start(ctx), timer?(name, ctx) }`
155
+ on `defineChannel()` to receive events without an inbound request. A receiver can
156
+ use `routes: []`; its Channel still owns its base path. The snapshot stores only
157
+ the receiver kind/version. Callbacks remain executable Worker code, and old HTTP
158
+ descriptors omit the receiver field entirely.
159
+
160
+ `start` performs one discovery and `await ctx.connectWebSocket(url, handlers)`
161
+ then returns. Never keep it pending for the lifetime of the connection. Native
162
+ Runtime retains the socket, dispatches finite `open`, `message`, `close` and
163
+ `error` callbacks, and exclusively schedules reconnects. Every callback receives
164
+ a fresh `ChannelReceiverContext`; retain protocol data keyed by the socket ID,
165
+ not a previous context. `ctx.signal` cancels operations on stop or loss of access.
166
+
167
+ Use `ctx.setTimer(name, delayMs)` and `ctx.clearTimer(name)` for protocol timers;
168
+ timers dispatch `receiver.timer(name, currentContext)` and are removed on cancel.
169
+ They can run while a message awaits admission. Call `ctx.ready()` only after the
170
+ provider protocol is usable, `ctx.retry({ code, message, retryAfterMs? })` for a
171
+ temporary failure, or `ctx.fail({ code, message })` for a configuration failure.
172
+ Keep error messages free of URLs, tickets and credentials. `connectWebSocket`
173
+ accepts `responseHeaders` to select handshake metadata needed by the adapter;
174
+ `ChannelWebSocketError` preserves status and only those selected headers.
175
+
176
+ Both HTTP and receiver contexts extend `ChannelInputContext` with the same
177
+ `from`/`resolveSession`, bounded fetch and declared env. A durable receipt can be
178
+ acknowledged without waiting for model completion. `ChannelInputError` exposes
179
+ the structured `code` and `status` for ACK classification. Retry the same
180
+ operation ID and payload after an ACK is lost; changing transport does not change
181
+ address-scoped deduplication or stable session identities.
182
+
183
+ Receiver artifacts require the explicit `channel-receiver-v1` Runtime capability.
184
+ An unsupported Host or Runtime rejects deployment. Transport changes use the
185
+ flow “disable installation → publish the whole Worker → configure the provider
186
+ → enable installation”. Channel builds also require `channel-operation-target-v1`, `channel-principal-v1`
187
+ and `controlled-do-call-v1`;
188
+ rollback artifacts must be built with the updated SDK/CLI. Internal protocol
189
+ upgrades require stopping ingress and receivers and draining all accepted work
190
+ and admission leases before replacing SDK, CLI, Host, Runtime and Worker builds. Ordinary compatible releases preserve installed identity;
191
+ already accepted execution and outbox work retain their deployment.
192
+
193
+ ## Built-in Feishu and extension example
194
+
195
+ The canonical [Feishu implementation](feishu.ts) uses only this public API. It is
196
+ both the built-in preset and the complete authoring example; there is no
197
+ privileged provider branch in the framework.
198
+
199
+ ```ts
200
+ // channels/feishu.ts
201
+ import { env } from "@gea-ai/agent-sdk";
202
+ import { feishuChannel } from "@gea-ai/agent-sdk/channels/feishu";
203
+
204
+ export default feishuChannel({
205
+ transport: "websocket",
206
+ env: {
207
+ FEISHU_APP_ID: env.value(),
208
+ FEISHU_APP_SECRET: env.secret(),
209
+ },
210
+ configure(ctx) {
211
+ return {
212
+ appId: ctx.env.FEISHU_APP_ID,
213
+ appSecret: ctx.env.FEISHU_APP_SECRET,
214
+ allowedSenders: "*",
215
+ };
216
+ },
217
+ });
218
+ ```
219
+
220
+ `env` declares configuration requirements and their secret classification. These
221
+ merge into the owning Agent snapshot and the CLI's generated bindings.
222
+ `configure(ctx)` receives the current invocation's `env` and `environment`
223
+ (`local`, `preview`, or `production`). Its `ctx.env` keys are inferred from this
224
+ Channel's declarations; values can be undefined until configured. It runs only
225
+ at HTTP input, receiver discovery/event handling, and outbound delivery, never
226
+ while discovering, inspecting or packing the project. It must synchronously
227
+ return configuration; do not perform side effects or retain the context.
228
+ The preset owns a statically discoverable route/receiver descriptor and uses the
229
+ ordinary `defineChannel` handlers to resolve configuration at those boundaries.
230
+
231
+ Configure the two values in the existing Agent environment settings, separately
232
+ for Preview and Production. Locally export them before `gea agent dev`; the CLI
233
+ does not automatically load a project `.env` file. The old zero-argument preset
234
+ and `appIdEnv`/`appSecretEnv` options are replaced by this explicit declaration and
235
+ callback. No default credential variable names are read implicitly.
236
+
237
+ `allowedSenders: "*"` accepts every authenticated sender supported by the preset.
238
+ The application identity, event authentication and matching tenant checks still
239
+ apply; each sender retains an isolated principal and session. To restrict access,
240
+ return a list instead:
241
+
242
+ ```ts
243
+ allowedSenders: [{ tenantKey: "your-tenant", openId: "ou_allowed-user" }];
244
+ ```
245
+
246
+ For a configurable policy, declare `FEISHU_ALLOWED_SENDERS: env.value()` and return
247
+ `allowedSenders: ctx.env.FEISHU_ALLOWED_SENDERS`. Its value can be `*` or a JSON
248
+ sender array. Omission, blank text and an empty list deny input. Malformed JSON
249
+ or sender entries produce an explicit input failure.
250
+
251
+ Publish the Agent and create an installation for its Preview or Production
252
+ environment. In `websocket` mode the Channel exposes no HTTP callback route and
253
+ does not use a Verification Token. Enable long-connection event delivery for an
254
+ enterprise self-built application in Feishu. The framework host must support
255
+ managed receivers. An open socket becomes ready after a provider pong; this
256
+ status alone does not prove event subscriptions or a real message round trip.
257
+ Use separate Feishu apps for preview and production: multiple clients of one app
258
+ compete for events rather than each receiving them.
259
+
260
+ For HTTP, set `transport: "http"` (the default), add
261
+ `FEISHU_VERIFICATION_TOKEN: env.secret()` to `env`, and return
262
+ `verificationToken: ctx.env.FEISHU_VERIFICATION_TOKEN` from `configure`.
263
+ Use the installation's returned callback URL in Feishu's event subscription
264
+ configuration. Enable the bot and the permissions for receiving private messages
265
+ and sending bot messages; subscribe to `im.message.receive_v1`. The preset supports
266
+ private text messages and unencrypted HTTP event callbacks. Encrypt Key callbacks,
267
+ groups, cards and provider attachments require another adapter extension.
268
+ Verification Token is checked for challenges and messages; app ID and tenant/open
269
+ ID are checked before accepting messages.
270
+
271
+ The preset obtains an endpoint through the controlled HTTP capability and
272
+ declares `*.feishu.cn` for dynamic China-site endpoints. Dialing requires WSS on
273
+ port 443 and native public-DNS validation, address pinning and TLS verification;
274
+ other domains are rejected. All
275
+ socket operations and protocol timers use Channel capabilities; framework
276
+ reconnection is the only reconnect loop. See [the receiver protocol notes](feishu-websocket.md)
277
+ for correlation, limits and acknowledgement behavior.
278
+
279
+ Outbound uses the reply API with the task UUID. Feishu documents a one-hour
280
+ UUID deduplication window in its [official SDK](https://github.com/larksuite/oapi-sdk-go/blob/v3_main/service/im/v1/model.go).
281
+ The preset does not restart that clock after process recovery. API references:
282
+ [reply message](https://open.feishu.cn/document/server-docs/im-v1/message/reply),
283
+ [event callback setup](https://open.feishu.cn/document/server-docs/event-subscription-guide/event-subscription-configure-/request-url-configuration-case).
284
+
285
+ Unit tests exercise challenge verification, durable acknowledgement, sender
286
+ filtering, event projection, stable UUIDs and ambiguous transport outcomes. A real
287
+ Feishu application and credentials are still required for deployment acceptance.
288
+
289
+ ## Changelog
290
+
291
+ [2026-09-07] #184 — Add executable Channel authoring, generic delivery contracts and the public-API Feishu preset.
292
+ [2026-09-07] #184 — Add the Feishu websocket transport with shared admission and framework-owned connections.
293
+
294
+ [2026-09-08] #184 — Use stable session handles and operation targets with unified Worker publication.
295
+
296
+ [2026-09-10] #184 — Require sender principals, isolate private sessions and reuse Studio authorization links.
297
+
298
+ [2026-09-10] #403 — Resolve explicit Feishu credentials through typed runtime configure(ctx), and support open sender access with `*` while retaining identity isolation.
@@ -0,0 +1,295 @@
1
+ import { z } from "zod";
2
+ import type { AgentEnvironmentRequirement } from "../index";
3
+ import {
4
+ defineChannel,
5
+ POST,
6
+ type AgentChannelOutboundResult,
7
+ type ChannelCapabilities,
8
+ } from "./index";
9
+ import {
10
+ acceptFeishuEvent,
11
+ feishuHeaderSchema,
12
+ feishuSenderSchema,
13
+ } from "./feishu-inbound";
14
+ import { createFeishuReceiver } from "./feishu-websocket";
15
+
16
+ const API_ORIGIN = "https://open.feishu.cn";
17
+ const IDENTITY_WINDOW_MS = 60 * 60 * 1000;
18
+ const envelopeSchema = z.object({
19
+ type: z.string().optional(),
20
+ token: z.string().optional(),
21
+ challenge: z.string().optional(),
22
+ encrypt: z.string().optional(),
23
+ header: feishuHeaderSchema.extend({ token: z.string() }).optional(),
24
+ event: z.unknown().optional(),
25
+ });
26
+ const inputDataSchema = z
27
+ .object({ chatId: z.string().min(1), messageId: z.string().min(1) })
28
+ .strict();
29
+ const replySchema = z
30
+ .object({ messageId: z.string().min(1), text: z.string().min(1) })
31
+ .strict();
32
+ const completedEventSchema = z.object({
33
+ reply: z.object({ text: z.string() }),
34
+ });
35
+ const tokenResponseSchema = z.object({
36
+ code: z.number(),
37
+ tenant_access_token: z.string().optional(),
38
+ });
39
+ const replyResponseSchema = z.object({
40
+ code: z.number(),
41
+ data: z.object({ message_id: z.string() }).optional(),
42
+ });
43
+
44
+ export type FeishuChannelConfiguration = {
45
+ appId: string | undefined;
46
+ appSecret: string | undefined;
47
+ verificationToken?: string;
48
+ /** A sender list, its JSON representation, or "*". Missing/empty denies all senders. */
49
+ allowedSenders?: string | readonly z.output<typeof feishuSenderSchema>[];
50
+ };
51
+
52
+ export type FeishuChannelOptions<TEnv extends string = string> = {
53
+ transport?: "http" | "websocket";
54
+ key?: string;
55
+ path?: string;
56
+ env: Readonly<Record<TEnv, AgentEnvironmentRequirement>>;
57
+ /** Runs with the current invocation's environment, never during package discovery. */
58
+ configure(context: {
59
+ readonly env: Readonly<Record<TEnv, string | undefined>>;
60
+ readonly environment: ChannelCapabilities["environment"];
61
+ }): FeishuChannelConfiguration;
62
+ };
63
+
64
+ async function equalToken(left: string, right: string): Promise<boolean> {
65
+ const [a, b] = await Promise.all(
66
+ [left, right].map((value) =>
67
+ crypto.subtle.digest("SHA-256", new TextEncoder().encode(value)),
68
+ ),
69
+ );
70
+ const aBytes = new Uint8Array(a);
71
+ const bBytes = new Uint8Array(b);
72
+ let difference = 0;
73
+ for (let i = 0; i < aBytes.length; i++) difference |= aBytes[i]! ^ bBytes[i]!;
74
+ return difference === 0;
75
+ }
76
+
77
+ /** The built-in adapter is an ordinary public Channel definition; it has no host-specific privileges. */
78
+ export function feishuChannel<TEnv extends string>(
79
+ options: FeishuChannelOptions<TEnv>,
80
+ ) {
81
+ const transport = options.transport ?? "http";
82
+ return defineChannel({
83
+ key: options.key ?? "feishu",
84
+ path: options.path ?? "/channels/feishu",
85
+ env: options.env,
86
+ networkHosts:
87
+ transport === "websocket" ? ["*.feishu.cn"] : ["open.feishu.cn"],
88
+ ...(transport === "websocket"
89
+ ? { receiver: createFeishuReceiver(options.configure) }
90
+ : {}),
91
+ stateSchema: z.object({ replies: z.number().int().nonnegative() }).strict(),
92
+ state: { replies: 0 },
93
+ dataSchema: inputDataSchema,
94
+ context(state) {
95
+ return { replyCount: state.replies };
96
+ },
97
+ routes:
98
+ transport === "websocket"
99
+ ? []
100
+ : [
101
+ POST("/", async (request, ctx) => {
102
+ // HTTP callback boundary translates malformed provider input and acceptance failures into HTTP status.
103
+ try {
104
+ const raw = await request.text();
105
+ if (new TextEncoder().encode(raw).byteLength > 128 * 1024)
106
+ return new Response("Event too large", { status: 413 });
107
+ const envelope = envelopeSchema.safeParse(JSON.parse(raw));
108
+ if (!envelope.success)
109
+ return new Response("Invalid event", { status: 400 });
110
+ if (envelope.data.encrypt)
111
+ return new Response(
112
+ "Encrypted events are not supported by this preset",
113
+ { status: 400 },
114
+ );
115
+ const configuration = options.configure(ctx);
116
+ const expectedToken = configuration.verificationToken;
117
+ if (!expectedToken)
118
+ return new Response(
119
+ "Channel verification is not configured",
120
+ {
121
+ status: 503,
122
+ },
123
+ );
124
+ if (
125
+ !(await equalToken(
126
+ envelope.data.header?.token ?? envelope.data.token ?? "",
127
+ expectedToken,
128
+ ))
129
+ )
130
+ return new Response("Invalid verification token", {
131
+ status: 401,
132
+ });
133
+ if (envelope.data.type === "url_verification") {
134
+ if (!envelope.data.challenge)
135
+ return new Response("Missing challenge", { status: 400 });
136
+ return Response.json({ challenge: envelope.data.challenge });
137
+ }
138
+ const accepted = await acceptFeishuEvent(
139
+ envelope.data,
140
+ configuration,
141
+ ctx,
142
+ );
143
+ return accepted.match({
144
+ ok: () => Response.json({ code: 0 }),
145
+ err: (error) =>
146
+ new Response(error.message, { status: error.status }),
147
+ });
148
+ } catch {
149
+ return new Response("Channel callback could not be accepted", {
150
+ status: 500,
151
+ });
152
+ }
153
+ }),
154
+ ],
155
+ events: {
156
+ "run.succeeded"(event, channel) {
157
+ const { reply } = completedEventSchema.parse(event.data);
158
+ if (!reply.text) return;
159
+ const data = inputDataSchema.parse(channel.input.data);
160
+ channel.outbox.enqueue("reply", {
161
+ messageId: data.messageId,
162
+ text: reply.text,
163
+ });
164
+ channel.state.replies += 1;
165
+ },
166
+ },
167
+ outboundSchema: replySchema,
168
+ idempotencyWindowMs: IDENTITY_WINDOW_MS,
169
+ async outbound(delivery, ctx): Promise<AgentChannelOutboundResult> {
170
+ let phase: "configuration" | "token" | "send" = "configuration";
171
+ // Provider callback boundary: once the send starts, a transport failure has an unknown outcome.
172
+ try {
173
+ const { appId, appSecret } = options.configure(ctx);
174
+ if (!appId || !appSecret)
175
+ return {
176
+ status: "failed",
177
+ error: {
178
+ code: "feishu_configuration",
179
+ message: "Feishu application credentials are missing.",
180
+ retryable: false,
181
+ },
182
+ };
183
+ phase = "token";
184
+ const tokenResponse = await ctx.fetch(
185
+ `${API_ORIGIN}/open-apis/auth/v3/tenant_access_token/internal`,
186
+ {
187
+ method: "POST",
188
+ headers: { "content-type": "application/json" },
189
+ body: JSON.stringify({ app_id: appId, app_secret: appSecret }),
190
+ signal: ctx.signal,
191
+ },
192
+ );
193
+ if (!tokenResponse.ok) return providerFailure(tokenResponse, false);
194
+ const token = tokenResponseSchema.parse(await tokenResponse.json());
195
+ if (token.code !== 0 || !token.tenant_access_token)
196
+ return {
197
+ status: "failed",
198
+ error: {
199
+ code: "feishu_auth",
200
+ message: `Feishu token request failed (${token.code}).`,
201
+ retryable: false,
202
+ },
203
+ };
204
+ phase = "send";
205
+ const response = await ctx.fetch(
206
+ `${API_ORIGIN}/open-apis/im/v1/messages/${encodeURIComponent(delivery.payload.messageId)}/reply`,
207
+ {
208
+ method: "POST",
209
+ headers: {
210
+ authorization: `Bearer ${token.tenant_access_token}`,
211
+ "content-type": "application/json",
212
+ },
213
+ body: JSON.stringify({
214
+ msg_type: "text",
215
+ content: JSON.stringify({ text: delivery.payload.text }),
216
+ uuid: delivery.id,
217
+ }),
218
+ signal: ctx.signal,
219
+ },
220
+ );
221
+ if (!response.ok) return providerFailure(response, true);
222
+ const body = replyResponseSchema.parse(await response.json());
223
+ if (body.code !== 0)
224
+ return {
225
+ status: "failed",
226
+ error: {
227
+ code: "feishu_message",
228
+ message: `Feishu rejected the message (${body.code}).`,
229
+ retryable: false,
230
+ },
231
+ };
232
+ if (!body.data)
233
+ return {
234
+ status: "unknown",
235
+ error: {
236
+ code: "feishu_receipt",
237
+ message: "Feishu returned no message receipt.",
238
+ retryable: false,
239
+ },
240
+ };
241
+ return { status: "sent", receipt: { messageId: body.data.message_id } };
242
+ } catch {
243
+ if (phase === "configuration")
244
+ return {
245
+ status: "failed",
246
+ error: {
247
+ code: "feishu_configuration",
248
+ message: "Feishu application configuration failed.",
249
+ retryable: false,
250
+ },
251
+ };
252
+ return {
253
+ status: phase === "send" ? "unknown" : "retryable",
254
+ error: {
255
+ code: "feishu_transport",
256
+ message:
257
+ phase === "send"
258
+ ? "The Feishu message delivery outcome is unknown."
259
+ : "The Feishu token request failed.",
260
+ retryable: phase !== "send",
261
+ },
262
+ };
263
+ }
264
+ },
265
+ });
266
+ }
267
+
268
+ function providerFailure(
269
+ response: Response,
270
+ sending: boolean,
271
+ ): AgentChannelOutboundResult {
272
+ const retryAfter = response.headers.get("retry-after");
273
+ const retryAfterMs =
274
+ retryAfter && /^\d+$/u.test(retryAfter)
275
+ ? Math.max(1, Number(retryAfter) * 1000)
276
+ : undefined;
277
+ if (response.status === 429 || (!sending && response.status >= 500))
278
+ return {
279
+ status: "retryable",
280
+ error: {
281
+ code: "feishu_http",
282
+ message: `Feishu returned HTTP ${response.status}.`,
283
+ retryable: true,
284
+ },
285
+ ...(retryAfterMs ? { retryAfterMs } : {}),
286
+ };
287
+ return {
288
+ status: sending && response.status >= 500 ? "unknown" : "failed",
289
+ error: {
290
+ code: "feishu_http",
291
+ message: `Feishu returned HTTP ${response.status}.`,
292
+ retryable: false,
293
+ },
294
+ };
295
+ }