@fastagent-sh/fastagent 0.12.1 → 0.13.0
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 +4 -2
- package/dist/channels/feishu/bootstrap-token.d.ts +42 -0
- package/dist/channels/feishu/bootstrap-token.js +94 -0
- package/dist/channels/feishu/card.d.ts +32 -0
- package/dist/channels/feishu/card.js +66 -0
- package/dist/channels/feishu/cloud.d.ts +17 -0
- package/dist/channels/feishu/cloud.js +19 -0
- package/dist/channels/feishu/crypto.d.ts +13 -0
- package/dist/channels/feishu/crypto.js +41 -0
- package/dist/channels/feishu/feishu-api.d.ts +108 -0
- package/dist/channels/feishu/feishu-api.js +325 -0
- package/dist/channels/feishu/feishu.d.ts +36 -0
- package/dist/channels/feishu/feishu.js +359 -0
- package/dist/channels/feishu/invoke-turn.d.ts +59 -0
- package/dist/channels/feishu/invoke-turn.js +106 -0
- package/dist/channels/feishu/parse.d.ts +125 -0
- package/dist/channels/feishu/parse.js +175 -0
- package/dist/channels/feishu/preview.d.ts +36 -0
- package/dist/channels/feishu/preview.js +387 -0
- package/dist/channels/feishu/register-app.d.ts +70 -0
- package/dist/channels/feishu/register-app.js +141 -0
- package/dist/channels/feishu/register-webhook.d.ts +22 -0
- package/dist/channels/feishu/register-webhook.js +106 -0
- package/dist/channels/feishu/scaffold/channel.ts +34 -0
- package/dist/channels/feishu/scaffold/feishu-send.ts +87 -0
- package/dist/channels/feishu/seen.d.ts +5 -0
- package/dist/channels/feishu/seen.js +47 -0
- package/dist/channels/feishu/text.d.ts +13 -0
- package/dist/channels/feishu/text.js +63 -0
- package/dist/channels/lark/lark.d.ts +15 -0
- package/dist/channels/lark/lark.js +10 -0
- package/dist/channels/lark/onboard.d.ts +39 -0
- package/dist/channels/lark/onboard.js +58 -0
- package/dist/channels/lark/scaffold/channel.ts +32 -0
- package/dist/channels/lark/scaffold/lark-send.ts +87 -0
- package/dist/channels/registration.d.ts +15 -0
- package/dist/channels/registration.js +1 -0
- package/dist/channels/{telegram/state.js → state.js} +6 -4
- package/dist/channels/telegram/context-buffer.js +1 -1
- package/dist/channels/telegram/register-webhook.d.ts +4 -1
- package/dist/channels/telegram/register-webhook.js +17 -26
- package/dist/channels/telegram/telegram.js +2 -2
- package/dist/channels/telegram/turn-store.d.ts +8 -21
- package/dist/channels/telegram/turn-store.js +11 -130
- package/dist/channels/{telegram/turn-queue.js → turn-queue.js} +3 -3
- package/dist/channels/turn-store.d.ts +42 -0
- package/dist/channels/turn-store.js +139 -0
- package/dist/channels/wait-health.d.ts +6 -0
- package/dist/channels/wait-health.js +27 -0
- package/dist/cli-add-feishu.d.ts +8 -0
- package/dist/cli-add-feishu.js +223 -0
- package/dist/cli.js +68 -23
- package/dist/deploy/container.js +10 -6
- package/dist/deploy/fly/plan.d.ts +1 -1
- package/dist/deploy/fly/plan.js +15 -4
- package/dist/deploy/fly/run.d.ts +7 -4
- package/dist/deploy/fly/run.js +26 -5
- package/dist/deploy/railway/plan.d.ts +1 -1
- package/dist/deploy/railway/plan.js +17 -5
- package/dist/deploy/railway/run.d.ts +6 -3
- package/dist/deploy/railway/run.js +26 -4
- package/dist/deploy/registration-gate.d.ts +20 -0
- package/dist/deploy/registration-gate.js +20 -0
- package/dist/deploy/secrets.d.ts +10 -9
- package/dist/deploy/secrets.js +15 -14
- package/dist/dev-supervisor.js +2 -1
- package/dist/engines/pi/chat.js +3 -3
- package/dist/engines/pi/create.d.ts +0 -1
- package/dist/engines/pi/create.js +8 -7
- package/dist/feishu.d.ts +2 -0
- package/dist/feishu.js +2 -0
- package/dist/lark.d.ts +3 -0
- package/dist/lark.js +3 -0
- package/dist/open-url.d.ts +2 -0
- package/dist/open-url.js +6 -0
- package/dist/scaffold/add-channel.d.ts +9 -5
- package/dist/scaffold/add-channel.js +73 -7
- package/dist/tunnel.d.ts +9 -6
- package/dist/tunnel.js +48 -31
- package/package.json +15 -4
- /package/dist/channels/{telegram/state.d.ts → state.d.ts} +0 -0
- /package/dist/channels/{telegram/turn-queue.d.ts → turn-queue.d.ts} +0 -0
package/README.md
CHANGED
|
@@ -145,7 +145,7 @@ const agent = createPiAgent({
|
|
|
145
145
|
| [Embedding](https://fastagent.sh/docs/embedding/) | Use FastAgent as a library inside your own app |
|
|
146
146
|
| [Channels](https://fastagent.sh/docs/channels/) | Add webhook/bot channels |
|
|
147
147
|
| [Deploy](https://fastagent.sh/docs/deploy/) | Ship the directory to Fly, Railway, or any Docker host |
|
|
148
|
-
| [GitHub](https://fastagent.sh/docs/github/) / [Telegram](https://fastagent.sh/docs/telegram/) | First-party channel guides |
|
|
148
|
+
| [GitHub](https://fastagent.sh/docs/github/) / [Telegram](https://fastagent.sh/docs/telegram/) / [Feishu and Lark](https://fastagent.sh/docs/feishu/) | First-party channel guides |
|
|
149
149
|
| [Channel development](https://fastagent.sh/docs/channel-development/) | Build custom channel adapters |
|
|
150
150
|
| [API reference](https://fastagent.sh/docs/api-reference/) | Public TypeScript API reference |
|
|
151
151
|
| [Troubleshooting](https://fastagent.sh/docs/troubleshooting/) | Common setup/runtime issues |
|
|
@@ -170,7 +170,9 @@ Subpath exports:
|
|
|
170
170
|
- `@fastagent-sh/fastagent/core` — engine-neutral contract, consumption helpers, channel/host kit, schedules;
|
|
171
171
|
- `@fastagent-sh/fastagent/pi` — the pi reference implementation;
|
|
172
172
|
- `@fastagent-sh/fastagent/github` — GitHub webhook channel;
|
|
173
|
-
- `@fastagent-sh/fastagent/telegram` — Telegram bot channel
|
|
173
|
+
- `@fastagent-sh/fastagent/telegram` — Telegram bot channel;
|
|
174
|
+
- `@fastagent-sh/fastagent/feishu` — canonical Feishu bot channel (飞书, open.feishu.cn);
|
|
175
|
+
- `@fastagent-sh/fastagent/lark` — Lark-international compatibility profile over the Feishu engine.
|
|
174
176
|
|
|
175
177
|
## Repository layout
|
|
176
178
|
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import type { FeishuCloudKind } from "./cloud.ts";
|
|
2
|
+
/** What the bootstrap needs from the API pipeline (subset of FeishuApi; injectable in tests). */
|
|
3
|
+
interface EventSubscriptionPatcher {
|
|
4
|
+
updateEventSubscription(appId: string, cfg: {
|
|
5
|
+
subscriptionType: "webhook";
|
|
6
|
+
requestUrl: string;
|
|
7
|
+
}): Promise<void>;
|
|
8
|
+
}
|
|
9
|
+
export interface FeishuBootstrapTokenOptions {
|
|
10
|
+
api: EventSubscriptionPatcher;
|
|
11
|
+
appId: string;
|
|
12
|
+
/** Canonical Feishu by default; Lark compatibility binds its own route explicitly. */
|
|
13
|
+
kind?: FeishuCloudKind;
|
|
14
|
+
/** Expose local `port` on a public URL (production: startCloudflareTunnel; tests: loopback). */
|
|
15
|
+
startTunnel: (port: number) => Promise<{
|
|
16
|
+
url: string;
|
|
17
|
+
close(): void;
|
|
18
|
+
} | undefined>;
|
|
19
|
+
/** Budget for the whole capture (the challenge normally lands within the PATCH round-trip). */
|
|
20
|
+
timeoutMs?: number;
|
|
21
|
+
/** PATCH attempts × delay — the PATCH is the one and only readiness probe (see below). */
|
|
22
|
+
patchAttempts?: number;
|
|
23
|
+
patchRetryMs?: number;
|
|
24
|
+
/** Retry classifier. Default: retry every PATCH failure (Feishu edge warm-up compatibility).
|
|
25
|
+
* Lark onboarding rejects a definitive config-route 404 immediately so it can fall back by hand. */
|
|
26
|
+
shouldRetryPatch?: (error: unknown) => boolean;
|
|
27
|
+
/** Progress hooks: production prints the assigned URL/retries; tests keep transport IO injected. */
|
|
28
|
+
onTunnelReady?: (url: string) => void;
|
|
29
|
+
onPatchRetry?: (info: {
|
|
30
|
+
error: unknown;
|
|
31
|
+
attempt: number;
|
|
32
|
+
attempts: number;
|
|
33
|
+
retryMs: number;
|
|
34
|
+
}) => void;
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Run the bootstrap (module header). Resolves with the app's Verification Token; rejects with a
|
|
38
|
+
* plain, actionable Error (tunnel unavailable, PATCH refused, challenge never arrived) — the caller
|
|
39
|
+
* degrades to the manual console instruction.
|
|
40
|
+
*/
|
|
41
|
+
export declare function bootstrapFeishuVerificationToken(options: FeishuBootstrapTokenOptions): Promise<string>;
|
|
42
|
+
export {};
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Verification-Token bootstrap — the token has NO read API (field-tested 2026-07: the v6 app detail
|
|
3
|
+
* never returns `encryption`, and the v7 config route only supports PATCH). The platform's only
|
|
4
|
+
* programmatic delivery is the `url_verification` challenge it POSTs to the request URL while an
|
|
5
|
+
* event-subscription PATCH is verified — the challenge body carries `token`, the app's Verification
|
|
6
|
+
* Token. So `add feishu`'s create flow finishes the job by: standing up a throwaway local responder,
|
|
7
|
+
* exposing it on an ephemeral quick tunnel, PATCHing the app's event subscription at it, capturing
|
|
8
|
+
* the token from the challenge, and tearing everything down. `dev --tunnel` / `deploy … --run`
|
|
9
|
+
* re-PATCH the request URL onto the real server later (the token is app-level and survives that).
|
|
10
|
+
*
|
|
11
|
+
* Security: the serving path keeps its strict non-empty-token requirement — no bootstrap mode there.
|
|
12
|
+
* The throwaway URL is random (unguessable), lives for seconds, and only the FIRST challenge is
|
|
13
|
+
* accepted, immediately after our own credential-authenticated PATCH.
|
|
14
|
+
*/
|
|
15
|
+
import { createServer } from "node:http";
|
|
16
|
+
/**
|
|
17
|
+
* Run the bootstrap (module header). Resolves with the app's Verification Token; rejects with a
|
|
18
|
+
* plain, actionable Error (tunnel unavailable, PATCH refused, challenge never arrived) — the caller
|
|
19
|
+
* degrades to the manual console instruction.
|
|
20
|
+
*/
|
|
21
|
+
export async function bootstrapFeishuVerificationToken(options) {
|
|
22
|
+
let capturedToken;
|
|
23
|
+
const token = new Promise((resolve) => {
|
|
24
|
+
capturedToken = resolve;
|
|
25
|
+
});
|
|
26
|
+
// The throwaway responder: answers /health (diagnostics) and the url_verification challenge
|
|
27
|
+
// (echo `challenge` back), capturing `token` from the FIRST challenge only.
|
|
28
|
+
const server = createServer((req, res) => {
|
|
29
|
+
if (req.method === "GET") {
|
|
30
|
+
res.end("ok");
|
|
31
|
+
return;
|
|
32
|
+
}
|
|
33
|
+
let body = "";
|
|
34
|
+
req.on("data", (d) => {
|
|
35
|
+
body += String(d);
|
|
36
|
+
});
|
|
37
|
+
req.on("end", () => {
|
|
38
|
+
try {
|
|
39
|
+
const j = JSON.parse(body);
|
|
40
|
+
if (j.type === "url_verification" && typeof j.challenge === "string" && typeof j.token === "string") {
|
|
41
|
+
capturedToken?.(j.token);
|
|
42
|
+
capturedToken = undefined; // first challenge only
|
|
43
|
+
res.setHeader("content-type", "application/json");
|
|
44
|
+
res.end(JSON.stringify({ challenge: j.challenge }));
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
catch {
|
|
49
|
+
/* not the challenge — fall through to the empty ACK */
|
|
50
|
+
}
|
|
51
|
+
res.end("{}");
|
|
52
|
+
});
|
|
53
|
+
});
|
|
54
|
+
await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
|
|
55
|
+
const address = server.address();
|
|
56
|
+
const port = typeof address === "object" && address !== null ? address.port : 0;
|
|
57
|
+
const tunnel = await options.startTunnel(port);
|
|
58
|
+
try {
|
|
59
|
+
if (!tunnel)
|
|
60
|
+
throw new Error("no tunnel came up (is cloudflared installed?)");
|
|
61
|
+
options.onTunnelReady?.(tunnel.url);
|
|
62
|
+
// Do NOT health-poll the edge here: "can WE reach it" says nothing about platform→edge, the path
|
|
63
|
+
// the challenge uses (field-tested: local negative-DNS caching failed while that path worked).
|
|
64
|
+
// PATCH immediately; it is the real probe because success requires the challenge round-trip.
|
|
65
|
+
const attempts = options.patchAttempts ?? 8;
|
|
66
|
+
for (let attempt = 1;; attempt++) {
|
|
67
|
+
try {
|
|
68
|
+
await options.api.updateEventSubscription(options.appId, {
|
|
69
|
+
subscriptionType: "webhook",
|
|
70
|
+
requestUrl: `${tunnel.url}/${options.kind ?? "feishu"}`,
|
|
71
|
+
});
|
|
72
|
+
break;
|
|
73
|
+
}
|
|
74
|
+
catch (e) {
|
|
75
|
+
if (attempt >= attempts || options.shouldRetryPatch?.(e) === false)
|
|
76
|
+
throw e;
|
|
77
|
+
const retryMs = options.patchRetryMs ?? 10_000;
|
|
78
|
+
options.onPatchRetry?.({ error: e, attempt, attempts, retryMs });
|
|
79
|
+
await new Promise((resolve) => setTimeout(resolve, retryMs));
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
const winner = await Promise.race([
|
|
83
|
+
token,
|
|
84
|
+
new Promise((resolve) => setTimeout(() => resolve(undefined), options.timeoutMs ?? 30_000)),
|
|
85
|
+
]);
|
|
86
|
+
if (winner === undefined)
|
|
87
|
+
throw new Error("the registration challenge never arrived");
|
|
88
|
+
return winner;
|
|
89
|
+
}
|
|
90
|
+
finally {
|
|
91
|
+
tunnel?.close();
|
|
92
|
+
server.close();
|
|
93
|
+
}
|
|
94
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Card JSON builders — PURE: the streaming-card entity (JSON 2.0) the live preview creates, the settled
|
|
3
|
+
* final card, and the message content that mounts a card entity into a chat. Kept out of preview.ts so
|
|
4
|
+
* the card DSL is data-in → string-out and testable without the pump.
|
|
5
|
+
*
|
|
6
|
+
* The preview is ONE markdown element (`element_id` below) inside a card with `streaming_mode` on:
|
|
7
|
+
* the pump PUTs full-text snapshots at that element (feishu-api.ts `updateCardElement`) and the client
|
|
8
|
+
* renders the typewriter effect. Settling replaces the whole entity (`updateCard`) with the same
|
|
9
|
+
* element, `streaming_mode` off — one write flips content and mode together.
|
|
10
|
+
*
|
|
11
|
+
* Budget: a card entity is capped at 30 KB, so the final answer's card chunk (and the live view) stay
|
|
12
|
+
* well under it; longer answers overflow into follow-up messages (preview.ts owns that policy).
|
|
13
|
+
*/
|
|
14
|
+
/** The one streamed element's id — shared by create (card.ts) and update (preview.ts). */
|
|
15
|
+
export declare const ANSWER_ELEMENT_ID = "answer";
|
|
16
|
+
/** Byte budget for markdown carried by ONE card (entity cap 30 KB minus JSON envelope + escaping room). */
|
|
17
|
+
export declare const CARD_MARKDOWN_MAX_BYTES: number;
|
|
18
|
+
/**
|
|
19
|
+
* The answer's first line as plain text — what the chat list and the push notification show for the
|
|
20
|
+
* settled card (`config.summary.content`). Without it a card message previews as a generic "[Card]"
|
|
21
|
+
* placeholder: the user's notification would never carry the actual answer. Markdown is stripped
|
|
22
|
+
* lightly (this is a one-line teaser, not a renderer): fenced code dropped, links/images → their text,
|
|
23
|
+
* emphasis/heading/list markers removed.
|
|
24
|
+
*/
|
|
25
|
+
export declare function cardSummary(markdown: string): string;
|
|
26
|
+
/** The live-preview card entity: streaming on, seeded with the placeholder/first view. */
|
|
27
|
+
export declare function streamingCardJson(initial: string): string;
|
|
28
|
+
/** The settled card: final markdown, streaming off (stops the client's streaming affordance), plus
|
|
29
|
+
* the answer-derived summary so the chat list / notification shows the reply, not "[Card]". */
|
|
30
|
+
export declare function finalCardJson(markdown: string): string;
|
|
31
|
+
/** The `interactive` message content that mounts a card ENTITY (vs an inline static card). */
|
|
32
|
+
export declare function cardEntityContent(cardId: string): string;
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Card JSON builders — PURE: the streaming-card entity (JSON 2.0) the live preview creates, the settled
|
|
3
|
+
* final card, and the message content that mounts a card entity into a chat. Kept out of preview.ts so
|
|
4
|
+
* the card DSL is data-in → string-out and testable without the pump.
|
|
5
|
+
*
|
|
6
|
+
* The preview is ONE markdown element (`element_id` below) inside a card with `streaming_mode` on:
|
|
7
|
+
* the pump PUTs full-text snapshots at that element (feishu-api.ts `updateCardElement`) and the client
|
|
8
|
+
* renders the typewriter effect. Settling replaces the whole entity (`updateCard`) with the same
|
|
9
|
+
* element, `streaming_mode` off — one write flips content and mode together.
|
|
10
|
+
*
|
|
11
|
+
* Budget: a card entity is capped at 30 KB, so the final answer's card chunk (and the live view) stay
|
|
12
|
+
* well under it; longer answers overflow into follow-up messages (preview.ts owns that policy).
|
|
13
|
+
*/
|
|
14
|
+
import { truncateCodePointPrefix } from "./text.js";
|
|
15
|
+
/** The one streamed element's id — shared by create (card.ts) and update (preview.ts). */
|
|
16
|
+
export const ANSWER_ELEMENT_ID = "answer";
|
|
17
|
+
/** Byte budget for markdown carried by ONE card (entity cap 30 KB minus JSON envelope + escaping room). */
|
|
18
|
+
export const CARD_MARKDOWN_MAX_BYTES = 20 * 1024;
|
|
19
|
+
/** Character budget for the settled card's summary (the chat-list / push-notification preview). */
|
|
20
|
+
const SUMMARY_MAX_CHARS = 60;
|
|
21
|
+
/**
|
|
22
|
+
* The answer's first line as plain text — what the chat list and the push notification show for the
|
|
23
|
+
* settled card (`config.summary.content`). Without it a card message previews as a generic "[Card]"
|
|
24
|
+
* placeholder: the user's notification would never carry the actual answer. Markdown is stripped
|
|
25
|
+
* lightly (this is a one-line teaser, not a renderer): fenced code dropped, links/images → their text,
|
|
26
|
+
* emphasis/heading/list markers removed.
|
|
27
|
+
*/
|
|
28
|
+
export function cardSummary(markdown) {
|
|
29
|
+
const line = markdown
|
|
30
|
+
.replace(/```[\s\S]*?(```|$)/g, " ") // fenced code is never a readable one-line preview
|
|
31
|
+
.split("\n")
|
|
32
|
+
.map((l) => l
|
|
33
|
+
.replace(/^\s{0,3}(?:#{1,6}|>|[-*+]|\d+[.)])\s+/, "") // heading / quote / list markers
|
|
34
|
+
.replace(/!\[([^\]]*)\]\([^)]*\)/g, "$1") // images → alt text
|
|
35
|
+
.replace(/\[([^\]]*)\]\([^)]*\)/g, "$1") // links → link text
|
|
36
|
+
.replace(/[*_~`]/g, "")
|
|
37
|
+
.trim())
|
|
38
|
+
.find((l) => l !== "") ?? "";
|
|
39
|
+
return truncateCodePointPrefix(line, SUMMARY_MAX_CHARS);
|
|
40
|
+
}
|
|
41
|
+
function cardJson(markdown, streaming, summary) {
|
|
42
|
+
return JSON.stringify({
|
|
43
|
+
schema: "2.0",
|
|
44
|
+
config: {
|
|
45
|
+
streaming_mode: streaming,
|
|
46
|
+
update_multi: true,
|
|
47
|
+
// Only the settled card sets a summary — while streaming, the platform's default (a localized
|
|
48
|
+
// "[Generating…]") is better than any fixed text we could pin.
|
|
49
|
+
...(summary ? { summary: { content: summary } } : {}),
|
|
50
|
+
},
|
|
51
|
+
body: { elements: [{ tag: "markdown", content: markdown, element_id: ANSWER_ELEMENT_ID }] },
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
/** The live-preview card entity: streaming on, seeded with the placeholder/first view. */
|
|
55
|
+
export function streamingCardJson(initial) {
|
|
56
|
+
return cardJson(initial, true);
|
|
57
|
+
}
|
|
58
|
+
/** The settled card: final markdown, streaming off (stops the client's streaming affordance), plus
|
|
59
|
+
* the answer-derived summary so the chat list / notification shows the reply, not "[Card]". */
|
|
60
|
+
export function finalCardJson(markdown) {
|
|
61
|
+
return cardJson(markdown, false, cardSummary(markdown));
|
|
62
|
+
}
|
|
63
|
+
/** The `interactive` message content that mounts a card ENTITY (vs an inline static card). */
|
|
64
|
+
export function cardEntityContent(cardId) {
|
|
65
|
+
return JSON.stringify({ type: "card", data: { card_id: cardId } });
|
|
66
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Feishu is the canonical implementation. Lark international speaks the same protocol but trails the
|
|
3
|
+
* primary cloud in control-plane capabilities, so it binds the Feishu engine through this explicit
|
|
4
|
+
* compatibility profile instead of becoming the engine's naming/design center.
|
|
5
|
+
*/
|
|
6
|
+
export type FeishuCloudKind = "feishu" | "lark";
|
|
7
|
+
export interface FeishuCloudProfile {
|
|
8
|
+
kind: FeishuCloudKind;
|
|
9
|
+
envPrefix: "FEISHU" | "LARK";
|
|
10
|
+
apiBase: string;
|
|
11
|
+
capabilities: {
|
|
12
|
+
appCreation: "scan-to-create" | "guided-console";
|
|
13
|
+
};
|
|
14
|
+
}
|
|
15
|
+
export declare const FEISHU_CLOUD: FeishuCloudProfile;
|
|
16
|
+
export declare const LARK_COMPAT_CLOUD: FeishuCloudProfile;
|
|
17
|
+
export declare function cloudFor(kind: FeishuCloudKind): FeishuCloudProfile;
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
export const FEISHU_CLOUD = {
|
|
2
|
+
kind: "feishu",
|
|
3
|
+
envPrefix: "FEISHU",
|
|
4
|
+
apiBase: "https://open.feishu.cn",
|
|
5
|
+
capabilities: {
|
|
6
|
+
appCreation: "scan-to-create",
|
|
7
|
+
},
|
|
8
|
+
};
|
|
9
|
+
export const LARK_COMPAT_CLOUD = {
|
|
10
|
+
kind: "lark",
|
|
11
|
+
envPrefix: "LARK",
|
|
12
|
+
apiBase: "https://open.larksuite.com",
|
|
13
|
+
capabilities: {
|
|
14
|
+
appCreation: "guided-console",
|
|
15
|
+
},
|
|
16
|
+
};
|
|
17
|
+
export function cloudFor(kind) {
|
|
18
|
+
return kind === "feishu" ? FEISHU_CLOUD : LARK_COMPAT_CLOUD;
|
|
19
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/** Constant-time string equality (padded to equal length first — timingSafeEqual demands it). */
|
|
2
|
+
export declare function timingSafeEqualStr(a: string, b: string): boolean;
|
|
3
|
+
/** Decrypt an `{"encrypt": …}` event payload to its plaintext JSON string. Throws on malformed input
|
|
4
|
+
* or a wrong key (bad padding) — the caller turns that into a 4xx, never a silent drop. */
|
|
5
|
+
export declare function decryptEvent(encryptKey: string, encryptB64: string): string;
|
|
6
|
+
/** The expected `X-Lark-Signature` for a request: sha256(timestamp + nonce + encryptKey + rawBody) hex. */
|
|
7
|
+
export declare function eventSignature(encryptKey: string, timestamp: string, nonce: string, rawBody: string): string;
|
|
8
|
+
/** Whether a request's signature headers verify against the raw body (constant-time). */
|
|
9
|
+
export declare function verifySignature(encryptKey: string, headers: {
|
|
10
|
+
timestamp: string;
|
|
11
|
+
nonce: string;
|
|
12
|
+
signature: string;
|
|
13
|
+
}, rawBody: string): boolean;
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Canonical Feishu webhook security (reused by Lark compatibility) — PURE: AES event decryption and request
|
|
3
|
+
* signature, exactly as the open platform defines them. When an Encrypt Key is configured in the
|
|
4
|
+
* developer console, every event arrives as `{"encrypt": "<base64>"}` with signature headers; without
|
|
5
|
+
* one, events arrive in plaintext and carry only the verification token in the body. feishu.ts owns the
|
|
6
|
+
* fail-closed policy (which checks run when); this module owns the math.
|
|
7
|
+
*
|
|
8
|
+
* - Decryption: AES-256-CBC. The key is sha256(encryptKey); the base64 payload is IV (16 bytes) ‖
|
|
9
|
+
* ciphertext; the plaintext is the event JSON (PKCS#7 padding handled by the cipher).
|
|
10
|
+
* - Signature: `X-Lark-Signature = sha256(timestamp + nonce + encryptKey + rawBody)` hex, where
|
|
11
|
+
* rawBody is the VERBATIM request body (the encrypted form) — computed over bytes, so the caller
|
|
12
|
+
* must pass the raw text, never a re-serialization.
|
|
13
|
+
*
|
|
14
|
+
* Comparisons are constant-time (timingSafeEqual) so neither the signature check nor the verification-
|
|
15
|
+
* token check leaks a timing signal.
|
|
16
|
+
*/
|
|
17
|
+
import { createDecipheriv, createHash, timingSafeEqual } from "node:crypto";
|
|
18
|
+
/** Constant-time string equality (padded to equal length first — timingSafeEqual demands it). */
|
|
19
|
+
export function timingSafeEqualStr(a, b) {
|
|
20
|
+
const ab = Buffer.from(a);
|
|
21
|
+
const bb = Buffer.from(b);
|
|
22
|
+
return ab.length === bb.length && timingSafeEqual(ab, bb);
|
|
23
|
+
}
|
|
24
|
+
/** Decrypt an `{"encrypt": …}` event payload to its plaintext JSON string. Throws on malformed input
|
|
25
|
+
* or a wrong key (bad padding) — the caller turns that into a 4xx, never a silent drop. */
|
|
26
|
+
export function decryptEvent(encryptKey, encryptB64) {
|
|
27
|
+
const key = createHash("sha256").update(encryptKey, "utf8").digest();
|
|
28
|
+
const buf = Buffer.from(encryptB64, "base64");
|
|
29
|
+
if (buf.length <= 16)
|
|
30
|
+
throw new Error("encrypted event payload is too short to carry an IV + ciphertext");
|
|
31
|
+
const decipher = createDecipheriv("aes-256-cbc", key, buf.subarray(0, 16));
|
|
32
|
+
return Buffer.concat([decipher.update(buf.subarray(16)), decipher.final()]).toString("utf8");
|
|
33
|
+
}
|
|
34
|
+
/** The expected `X-Lark-Signature` for a request: sha256(timestamp + nonce + encryptKey + rawBody) hex. */
|
|
35
|
+
export function eventSignature(encryptKey, timestamp, nonce, rawBody) {
|
|
36
|
+
return createHash("sha256").update(`${timestamp}${nonce}${encryptKey}${rawBody}`, "utf8").digest("hex");
|
|
37
|
+
}
|
|
38
|
+
/** Whether a request's signature headers verify against the raw body (constant-time). */
|
|
39
|
+
export function verifySignature(encryptKey, headers, rawBody) {
|
|
40
|
+
return timingSafeEqualStr(eventSignature(encryptKey, headers.timestamp, headers.nonce, rawBody), headers.signature);
|
|
41
|
+
}
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import type { ImageRef } from "../../agent.ts";
|
|
2
|
+
import type { FeishuCloudKind } from "./cloud.ts";
|
|
3
|
+
/** Where a reply goes: a chat, optionally quote-replying a message (in-thread in topic groups). */
|
|
4
|
+
export interface FeishuTarget {
|
|
5
|
+
chatId: string;
|
|
6
|
+
/** Message to reply to (the summoning message). Set in groups so the answer threads under the asker. */
|
|
7
|
+
replyTo?: string;
|
|
8
|
+
/** Reply into the message's topic thread (topic groups). */
|
|
9
|
+
replyInThread?: boolean;
|
|
10
|
+
}
|
|
11
|
+
/** A downloaded inbound file: an absolute local path the agent's tools (read/bash) can open. */
|
|
12
|
+
export interface DownloadedFile {
|
|
13
|
+
path: string;
|
|
14
|
+
name: string;
|
|
15
|
+
size: number;
|
|
16
|
+
}
|
|
17
|
+
/** Whether an error is the platform's "card streaming closed/timed out" reject — preview.ts re-enables
|
|
18
|
+
* streaming once when it sees this. A plain code check on the typed error, exported instead of the
|
|
19
|
+
* class so no caller can construct/throw one. */
|
|
20
|
+
export declare function isCardStreamingClosed(e: unknown): boolean;
|
|
21
|
+
/** Whether the platform origin has no application-config route at all. Onboarding uses this narrow
|
|
22
|
+
* signal to fall back to a manual token/mode setup; auth/scope/network failures must remain visible. */
|
|
23
|
+
export declare function isFeishuConfigApiMissing(e: unknown): boolean;
|
|
24
|
+
/** Whether a registration-PATCH failure is transient weather worth retrying: network/DNS/timeouts, or
|
|
25
|
+
* the platform's 210042 "request_url validation failed" while its own path to a fresh tunnel edge
|
|
26
|
+
* warms up. Everything else (scope, auth, app under review, the config-route 404 above) is definitive
|
|
27
|
+
* — the registrars and the token bootstrap share this one classifier. */
|
|
28
|
+
export declare function isTransientFeishuRegistrationError(e: unknown): boolean;
|
|
29
|
+
export interface FeishuApiOptions {
|
|
30
|
+
/** Branded diagnostics; defaults to the canonical Feishu cloud. */
|
|
31
|
+
kind?: FeishuCloudKind;
|
|
32
|
+
/** API origin: `https://open.feishu.cn` (Feishu) or `https://open.larksuite.com` (Lark intl). */
|
|
33
|
+
baseUrl: string;
|
|
34
|
+
appId: string;
|
|
35
|
+
appSecret: string;
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* The Feishu Open API client: one instance per channel, holding the token cache. Every method rides the
|
|
39
|
+
* single pipeline (module header). Throws {@link FeishuApiError} on any failure.
|
|
40
|
+
*/
|
|
41
|
+
export interface FeishuApi {
|
|
42
|
+
/** Validate appId/appSecret by acquiring the tenant token through this pipeline. Does not require
|
|
43
|
+
* the bot capability (unlike botInfo), so guided onboarding can fail before persisting a typo. */
|
|
44
|
+
verifyCredentials(): Promise<void>;
|
|
45
|
+
/** GET /bot/v3/info — the bot's own identity (open_id drives @mention summon). */
|
|
46
|
+
botInfo(): Promise<{
|
|
47
|
+
openId?: string;
|
|
48
|
+
appName?: string;
|
|
49
|
+
}>;
|
|
50
|
+
/** Send a message to a chat; returns the new message_id (undefined if the body carried none). */
|
|
51
|
+
sendMessage(chatId: string, msgType: string, content: string): Promise<string | undefined>;
|
|
52
|
+
/** Reply to a message (quote; `replyInThread` stays inside a topic group's thread). */
|
|
53
|
+
replyMessage(messageId: string, msgType: string, content: string, opts?: {
|
|
54
|
+
replyInThread?: boolean;
|
|
55
|
+
}): Promise<string | undefined>;
|
|
56
|
+
/** Send `text`, split at the platform's size cap: ordinary groups quote only the first chunk; topic
|
|
57
|
+
* groups reply_in_thread on every chunk. Returns the FIRST message_id. */
|
|
58
|
+
sendText(target: FeishuTarget, text: string): Promise<string | undefined>;
|
|
59
|
+
/** Edit a sent text message in place (PUT; the platform caps edits at 20 per message). */
|
|
60
|
+
editTextMessage(messageId: string, text: string): Promise<void>;
|
|
61
|
+
/** Recall (delete) a message the bot sent. */
|
|
62
|
+
deleteMessage(messageId: string): Promise<void>;
|
|
63
|
+
/** Fetch one message (the reply-referent path). Undefined when the API returns no item. */
|
|
64
|
+
getMessage(messageId: string): Promise<{
|
|
65
|
+
message_id?: string;
|
|
66
|
+
msg_type?: string;
|
|
67
|
+
body?: {
|
|
68
|
+
content?: string;
|
|
69
|
+
};
|
|
70
|
+
mentions?: unknown[];
|
|
71
|
+
sender?: unknown;
|
|
72
|
+
} | undefined>;
|
|
73
|
+
/** Download a message resource (image/file bytes). Caps at {@link MAX_DOWNLOAD_BYTES}. */
|
|
74
|
+
downloadResource(messageId: string, fileKey: string, type: "image" | "file"): Promise<{
|
|
75
|
+
bytes: Buffer;
|
|
76
|
+
contentType?: string;
|
|
77
|
+
}>;
|
|
78
|
+
/** Download an image resource as a vision ImageRef. */
|
|
79
|
+
fetchImage(messageId: string, fileKey: string): Promise<ImageRef>;
|
|
80
|
+
/** Download a file resource to `<filesDir>/<chat>/<name>`. */
|
|
81
|
+
fetchFile(messageId: string, fileKey: string, name: string, chatId: string, filesDir: string): Promise<DownloadedFile>;
|
|
82
|
+
/** Read the app's own event-security config (the platform-generated verification token / encrypt
|
|
83
|
+
* key) — the scan-to-create flow copies these into .env so the operator never opens the console. */
|
|
84
|
+
getAppConfig(appId: string): Promise<{
|
|
85
|
+
verificationToken?: string;
|
|
86
|
+
encryptionKey?: string;
|
|
87
|
+
}>;
|
|
88
|
+
/** Update the app's own event subscription (application-v7 config PATCH — tenant token can only
|
|
89
|
+
* operate on itself; the request-URL change takes effect immediately, no version publish). The
|
|
90
|
+
* platform VERIFIES `requestUrl` with a url_verification challenge during this call, so the server
|
|
91
|
+
* behind it must already be answering. */
|
|
92
|
+
updateEventSubscription(appId: string, cfg: {
|
|
93
|
+
subscriptionType: "webhook";
|
|
94
|
+
requestUrl: string;
|
|
95
|
+
}): Promise<void>;
|
|
96
|
+
/** Create a card entity (card JSON 2.0). Returns its card_id. */
|
|
97
|
+
createCard(cardJson: string): Promise<string>;
|
|
98
|
+
/** Stream-update a card element's text (full-content snapshot + strictly increasing sequence). */
|
|
99
|
+
updateCardElement(cardId: string, elementId: string, content: string, sequence: number): Promise<void>;
|
|
100
|
+
/** Replace a card entity's content (the settle write; also flips streaming_mode off via the JSON). */
|
|
101
|
+
updateCard(cardId: string, cardJson: string, sequence: number): Promise<void>;
|
|
102
|
+
}
|
|
103
|
+
/** The platform caps a text-message request body at 150 KB; stay well under it (the content is a JSON
|
|
104
|
+
* envelope around the text, and multi-byte characters inflate the byte count). */
|
|
105
|
+
export declare const FEISHU_MAX_TEXT_BYTES: number;
|
|
106
|
+
/** Split text into chunks whose UTF-8 size fits the message cap, preferring a newline boundary. */
|
|
107
|
+
export declare function chunkFeishuText(text: string, maxBytes?: number): string[];
|
|
108
|
+
export declare function createFeishuApi(opts: FeishuApiOptions): FeishuApi;
|