@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.
- package/README.md +3 -1253
- package/dist/agent-channel-author-runtime.d.ts +79 -0
- package/dist/agent-channel-author-runtime.d.ts.map +1 -0
- package/dist/agent-channel-author-runtime.js +511 -0
- package/dist/agent-channel-author-runtime.js.map +1 -0
- package/dist/agent-channel-receiver.d.ts +46 -0
- package/dist/agent-channel-receiver.d.ts.map +1 -0
- package/dist/agent-channel-receiver.js +356 -0
- package/dist/agent-channel-receiver.js.map +1 -0
- package/dist/agent-channel-runtime.d.ts +6 -0
- package/dist/agent-channel-runtime.d.ts.map +1 -0
- package/dist/agent-channel-runtime.js +623 -0
- package/dist/agent-channel-runtime.js.map +1 -0
- package/dist/agent-channel-worker.d.ts +22 -0
- package/dist/agent-channel-worker.d.ts.map +1 -0
- package/dist/agent-channel-worker.js +335 -0
- package/dist/agent-channel-worker.js.map +1 -0
- package/dist/agent-session.d.ts.map +1 -1
- package/dist/agent-session.js +177 -4
- package/dist/agent-session.js.map +1 -1
- package/dist/agent-worker.d.ts +27 -1
- package/dist/agent-worker.d.ts.map +1 -1
- package/dist/agent-worker.js +867 -27
- package/dist/agent-worker.js.map +1 -1
- package/dist/channels/feishu-codec.d.ts +18 -0
- package/dist/channels/feishu-codec.d.ts.map +1 -0
- package/dist/channels/feishu-codec.js +109 -0
- package/dist/channels/feishu-codec.js.map +1 -0
- package/dist/channels/feishu-inbound.d.ts +35 -0
- package/dist/channels/feishu-inbound.d.ts.map +1 -0
- package/dist/channels/feishu-inbound.js +136 -0
- package/dist/channels/feishu-inbound.js.map +1 -0
- package/dist/channels/feishu-websocket.d.ts +5 -0
- package/dist/channels/feishu-websocket.d.ts.map +1 -0
- package/dist/channels/feishu-websocket.js +424 -0
- package/dist/channels/feishu-websocket.js.map +1 -0
- package/dist/channels/feishu.d.ts +32 -0
- package/dist/channels/feishu.d.ts.map +1 -0
- package/dist/channels/feishu.js +233 -0
- package/dist/channels/feishu.js.map +1 -0
- package/dist/channels/index.d.ts +180 -0
- package/dist/channels/index.d.ts.map +1 -0
- package/dist/channels/index.js +93 -0
- package/dist/channels/index.js.map +1 -0
- package/dist/index.d.ts +18 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +38 -8
- package/dist/index.js.map +1 -1
- package/package.json +16 -3
- package/src/channels/README.md +298 -0
- 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
|
+
}
|