@fastagent-sh/fastagent 0.12.0 → 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 +42 -36
- 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 +19 -8
- /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
|
@@ -0,0 +1,325 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Feishu Open API transport, reused by the Lark compatibility profile. ONE pipeline (`call`) carries every JSON call — per-method wire code
|
|
3
|
+
* does not exist, so a transport rule can never be missing from one call site. The transport invariants
|
|
4
|
+
* live here and nowhere else:
|
|
5
|
+
*
|
|
6
|
+
* 1. Every call has a per-attempt timeout (API_TIMEOUT_MS; resource bytes DOWNLOAD_TIMEOUT_MS) — a
|
|
7
|
+
* wedged connection cannot hang a turn or its session queue.
|
|
8
|
+
* 2. The tenant_access_token is fetched lazily, cached to its `expire` minus a refresh margin, and
|
|
9
|
+
* invalidated + refetched ONCE when the platform says the token is bad (auth codes below) — no
|
|
10
|
+
* other failure class is retried with a fresh token.
|
|
11
|
+
* 3. Only a rate-limit reject is retried (bounded attempts, linear backoff); nothing else — the
|
|
12
|
+
* request may have been processed, and a retried send would double-deliver.
|
|
13
|
+
* 4. Success requires the body's own `code === 0` — an intermediary's HTTP 200 is not a sent message.
|
|
14
|
+
* 5. Every failure is a {@link FeishuApiError} naming the call; self-description is a property of the
|
|
15
|
+
* error type, not per-call-site string assembly.
|
|
16
|
+
*
|
|
17
|
+
* On top of the pipeline sit thin typed methods (send/reply/edit/card/resource) — adding one is adding
|
|
18
|
+
* a wrapper, not wire code. SDK tripwire: if this surface ever needs WebSocket long-connection ingress
|
|
19
|
+
* or grows past ~a dozen methods, adopt @larksuiteoapi/node-sdk instead of growing it — the methods
|
|
20
|
+
* here are shape-compatible with the SDK's `client.im.*` style, so the policy layer survives that swap.
|
|
21
|
+
*/
|
|
22
|
+
import { mkdir, writeFile } from "node:fs/promises";
|
|
23
|
+
import { join } from "node:path";
|
|
24
|
+
import { utf8Prefix } from "./text.js";
|
|
25
|
+
/** Per-attempt timeout for a JSON API call — small JSON round-trips, so 30s is generous. */
|
|
26
|
+
const API_TIMEOUT_MS = 30_000;
|
|
27
|
+
/** Timeout for downloading resource bytes — sized for a slow link, not a JSON call. */
|
|
28
|
+
const DOWNLOAD_TIMEOUT_MS = 120_000;
|
|
29
|
+
/** How many rate-limit rejects one call absorbs before giving up. */
|
|
30
|
+
const RETRIES = 3;
|
|
31
|
+
/** Download sanity cap; a larger resource is rejected visibly (the engine resizes vision images
|
|
32
|
+
* anyway, so this is a transport guard, not a model limit). */
|
|
33
|
+
const MAX_DOWNLOAD_BYTES = 20 * 1024 * 1024;
|
|
34
|
+
/** Refresh the cached tenant token this long before its stated expiry. */
|
|
35
|
+
const TOKEN_REFRESH_MARGIN_S = 300;
|
|
36
|
+
/** The platform's "this token is bad" codes: expired/invalid tenant token → invalidate + refetch once. */
|
|
37
|
+
const AUTH_ERROR_CODES = new Set([99991661, 99991663, 99991664, 99991668]);
|
|
38
|
+
/** The platform's frequency-limit code (arrives with HTTP 429 or 400). */
|
|
39
|
+
const RATE_LIMIT_CODE = 99991400;
|
|
40
|
+
/** A named Open API failure. `status` 0 = the transport itself failed (network error / timeout) before
|
|
41
|
+
* any HTTP status existed; `code` is the platform's own error code (0 when none was readable).
|
|
42
|
+
* Module-private: no external caller matches on the type; the pipeline's own retry logic reads `code`. */
|
|
43
|
+
class FeishuApiError extends Error {
|
|
44
|
+
call;
|
|
45
|
+
status;
|
|
46
|
+
code;
|
|
47
|
+
description;
|
|
48
|
+
// No constructor parameter properties: the CLI runs source under Node's strip-only TS mode.
|
|
49
|
+
constructor(kind, call, status, code, description, options) {
|
|
50
|
+
super(status === 0
|
|
51
|
+
? `${kind} ${call}: ${description}`
|
|
52
|
+
: `${kind} ${call} failed: ${status}${code ? ` code ${code}` : ""} ${description}`.trim(), options);
|
|
53
|
+
this.name = "FeishuApiError";
|
|
54
|
+
this.call = call;
|
|
55
|
+
this.status = status;
|
|
56
|
+
this.code = code;
|
|
57
|
+
this.description = description;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
/** Whether an error is the platform's "card streaming closed/timed out" reject — preview.ts re-enables
|
|
61
|
+
* streaming once when it sees this. A plain code check on the typed error, exported instead of the
|
|
62
|
+
* class so no caller can construct/throw one. */
|
|
63
|
+
export function isCardStreamingClosed(e) {
|
|
64
|
+
return e instanceof FeishuApiError && (e.code === 200850 || e.code === 300309);
|
|
65
|
+
}
|
|
66
|
+
/** Whether the platform origin has no application-config route at all. Onboarding uses this narrow
|
|
67
|
+
* signal to fall back to a manual token/mode setup; auth/scope/network failures must remain visible. */
|
|
68
|
+
export function isFeishuConfigApiMissing(e) {
|
|
69
|
+
return e instanceof FeishuApiError && e.status === 404;
|
|
70
|
+
}
|
|
71
|
+
/** Whether a registration-PATCH failure is transient weather worth retrying: network/DNS/timeouts, or
|
|
72
|
+
* the platform's 210042 "request_url validation failed" while its own path to a fresh tunnel edge
|
|
73
|
+
* warms up. Everything else (scope, auth, app under review, the config-route 404 above) is definitive
|
|
74
|
+
* — the registrars and the token bootstrap share this one classifier. */
|
|
75
|
+
export function isTransientFeishuRegistrationError(e) {
|
|
76
|
+
return /resolve host|getaddrinfo|ENOTFOUND|fetch failed|ECONNRESET|timeout|210042|request_url/i.test(String(e));
|
|
77
|
+
}
|
|
78
|
+
/** Sleep on the GLOBAL timer (not `node:timers/promises`) so tests can drive it with fake timers. */
|
|
79
|
+
const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
80
|
+
/** The platform caps a text-message request body at 150 KB; stay well under it (the content is a JSON
|
|
81
|
+
* envelope around the text, and multi-byte characters inflate the byte count). */
|
|
82
|
+
export const FEISHU_MAX_TEXT_BYTES = 100 * 1024;
|
|
83
|
+
/** Split text into chunks whose UTF-8 size fits the message cap, preferring a newline boundary. */
|
|
84
|
+
export function chunkFeishuText(text, maxBytes = FEISHU_MAX_TEXT_BYTES) {
|
|
85
|
+
if (!Number.isSafeInteger(maxBytes) || maxBytes <= 0)
|
|
86
|
+
throw new RangeError("maxBytes must be a positive integer");
|
|
87
|
+
if (Buffer.byteLength(text, "utf8") <= maxBytes)
|
|
88
|
+
return [text];
|
|
89
|
+
const chunks = [];
|
|
90
|
+
let rest = text;
|
|
91
|
+
while (Buffer.byteLength(rest, "utf8") > maxBytes) {
|
|
92
|
+
// Find the largest CODE-POINT-aligned prefix under the cap, then prefer the last newline inside it.
|
|
93
|
+
// A cap smaller than one code point cannot satisfy both the byte limit and well-formed Unicode.
|
|
94
|
+
const prefix = utf8Prefix(rest, maxBytes);
|
|
95
|
+
if (prefix === "")
|
|
96
|
+
throw new RangeError("maxBytes is too small to contain the next Unicode code point");
|
|
97
|
+
let cut = prefix.lastIndexOf("\n");
|
|
98
|
+
if (cut <= 0)
|
|
99
|
+
cut = prefix.length;
|
|
100
|
+
chunks.push(rest.slice(0, cut));
|
|
101
|
+
rest = rest.slice(cut).replace(/^\n/, "");
|
|
102
|
+
}
|
|
103
|
+
if (rest.length > 0 || chunks.length === 0)
|
|
104
|
+
chunks.push(rest);
|
|
105
|
+
return chunks;
|
|
106
|
+
}
|
|
107
|
+
export function createFeishuApi(opts) {
|
|
108
|
+
const { kind = "feishu", baseUrl, appId, appSecret } = opts;
|
|
109
|
+
let cached;
|
|
110
|
+
/** Fetch (or reuse) the tenant_access_token — the one call that carries no Authorization header. */
|
|
111
|
+
const tenantToken = async () => {
|
|
112
|
+
if (cached && Date.now() < cached.expiresAt)
|
|
113
|
+
return cached.token;
|
|
114
|
+
const label = "tenant_access_token";
|
|
115
|
+
let res;
|
|
116
|
+
let raw;
|
|
117
|
+
try {
|
|
118
|
+
res = await fetch(`${baseUrl}/open-apis/auth/v3/tenant_access_token/internal`, {
|
|
119
|
+
method: "POST",
|
|
120
|
+
headers: { "content-type": "application/json; charset=utf-8" },
|
|
121
|
+
body: JSON.stringify({ app_id: appId, app_secret: appSecret }),
|
|
122
|
+
signal: AbortSignal.timeout(API_TIMEOUT_MS),
|
|
123
|
+
});
|
|
124
|
+
raw = await res.text();
|
|
125
|
+
}
|
|
126
|
+
catch (e) {
|
|
127
|
+
throw new FeishuApiError(kind, label, 0, 0, String(e), { cause: e });
|
|
128
|
+
}
|
|
129
|
+
let data;
|
|
130
|
+
try {
|
|
131
|
+
data = JSON.parse(raw);
|
|
132
|
+
}
|
|
133
|
+
catch {
|
|
134
|
+
data = {};
|
|
135
|
+
}
|
|
136
|
+
if (!res.ok || data.code !== 0 || typeof data.tenant_access_token !== "string") {
|
|
137
|
+
throw new FeishuApiError(kind, label, res.status, data.code ?? 0, data.msg ?? "response was not the expected JSON");
|
|
138
|
+
}
|
|
139
|
+
const ttlS = Math.max(60, (data.expire ?? 0) - TOKEN_REFRESH_MARGIN_S);
|
|
140
|
+
cached = { token: data.tenant_access_token, expiresAt: Date.now() + ttlS * 1000 };
|
|
141
|
+
return cached.token;
|
|
142
|
+
};
|
|
143
|
+
/** The pipeline: one JSON API call, carrying every transport invariant (module header). */
|
|
144
|
+
const call = async (label, method, path, body) => {
|
|
145
|
+
let refreshedAuth = false;
|
|
146
|
+
for (let attempt = 0;;) {
|
|
147
|
+
const token = await tenantToken();
|
|
148
|
+
let res;
|
|
149
|
+
let raw;
|
|
150
|
+
try {
|
|
151
|
+
res = await fetch(`${baseUrl}${path}`, {
|
|
152
|
+
method,
|
|
153
|
+
headers: {
|
|
154
|
+
authorization: `Bearer ${token}`,
|
|
155
|
+
"content-type": "application/json; charset=utf-8",
|
|
156
|
+
},
|
|
157
|
+
body: body === undefined ? undefined : JSON.stringify(body),
|
|
158
|
+
signal: AbortSignal.timeout(API_TIMEOUT_MS),
|
|
159
|
+
});
|
|
160
|
+
raw = await res.text(); // the body read shares the timeout — a mid-body stall is a transport failure too
|
|
161
|
+
}
|
|
162
|
+
catch (e) {
|
|
163
|
+
throw new FeishuApiError(kind, label, 0, 0, String(e), { cause: e });
|
|
164
|
+
}
|
|
165
|
+
let data;
|
|
166
|
+
try {
|
|
167
|
+
data = JSON.parse(raw);
|
|
168
|
+
}
|
|
169
|
+
catch {
|
|
170
|
+
data = {}; // only the parse is forgiven — the code-gate below turns it into a named failure
|
|
171
|
+
}
|
|
172
|
+
if (res.ok && data.code === 0)
|
|
173
|
+
return data;
|
|
174
|
+
const code = data.code ?? 0;
|
|
175
|
+
if (AUTH_ERROR_CODES.has(code) && !refreshedAuth) {
|
|
176
|
+
// The platform says the token is bad (expired mid-cache-window, or revoked): refetch ONCE and
|
|
177
|
+
// retry. Auth is the one failure class where a retry cannot double-deliver — the request was
|
|
178
|
+
// rejected before it acted.
|
|
179
|
+
refreshedAuth = true;
|
|
180
|
+
cached = undefined;
|
|
181
|
+
continue;
|
|
182
|
+
}
|
|
183
|
+
if ((res.status === 429 || code === RATE_LIMIT_CODE) && attempt < RETRIES) {
|
|
184
|
+
attempt++;
|
|
185
|
+
await wait(attempt * 1000);
|
|
186
|
+
continue;
|
|
187
|
+
}
|
|
188
|
+
const exhausted = res.status === 429 || code === RATE_LIMIT_CODE ? ` (gave up after ${attempt} retries)` : "";
|
|
189
|
+
throw new FeishuApiError(kind, label, res.status, code, `${data.msg ?? "response was not the expected JSON"}${exhausted}`);
|
|
190
|
+
}
|
|
191
|
+
};
|
|
192
|
+
const api = {
|
|
193
|
+
async verifyCredentials() {
|
|
194
|
+
await tenantToken();
|
|
195
|
+
},
|
|
196
|
+
async botInfo() {
|
|
197
|
+
// bot/v3/info answers at the TOP LEVEL (`bot`), not under `data` — an older API family.
|
|
198
|
+
const data = await call("botInfo", "GET", "/open-apis/bot/v3/info");
|
|
199
|
+
return { openId: data.bot?.open_id, appName: data.bot?.app_name };
|
|
200
|
+
},
|
|
201
|
+
async sendMessage(chatId, msgType, content) {
|
|
202
|
+
const data = await call("sendMessage", "POST", "/open-apis/im/v1/messages?receive_id_type=chat_id", { receive_id: chatId, msg_type: msgType, content });
|
|
203
|
+
return data.data?.message_id;
|
|
204
|
+
},
|
|
205
|
+
async replyMessage(messageId, msgType, content, opts2) {
|
|
206
|
+
const data = await call("replyMessage", "POST", `/open-apis/im/v1/messages/${encodeURIComponent(messageId)}/reply`, { msg_type: msgType, content, ...(opts2?.replyInThread ? { reply_in_thread: true } : {}) });
|
|
207
|
+
return data.data?.message_id;
|
|
208
|
+
},
|
|
209
|
+
async sendText(target, text) {
|
|
210
|
+
const chunks = chunkFeishuText(text);
|
|
211
|
+
let firstId;
|
|
212
|
+
let first = true;
|
|
213
|
+
for (const chunk of chunks) {
|
|
214
|
+
const content = JSON.stringify({ text: chunk });
|
|
215
|
+
// A normal group quote-replies only the first chunk — N reply-quotes would be noise. A topic
|
|
216
|
+
// must reply_in_thread on EVERY chunk; a plain chat send would leak continuations to the main group.
|
|
217
|
+
const reply = target.replyTo !== undefined && (first || target.replyInThread === true);
|
|
218
|
+
const id = reply
|
|
219
|
+
? await api.replyMessage(target.replyTo, "text", content, {
|
|
220
|
+
replyInThread: target.replyInThread,
|
|
221
|
+
})
|
|
222
|
+
: await api.sendMessage(target.chatId, "text", content);
|
|
223
|
+
if (first)
|
|
224
|
+
firstId = id;
|
|
225
|
+
first = false;
|
|
226
|
+
}
|
|
227
|
+
return firstId;
|
|
228
|
+
},
|
|
229
|
+
async editTextMessage(messageId, text) {
|
|
230
|
+
await call("editTextMessage", "PUT", `/open-apis/im/v1/messages/${encodeURIComponent(messageId)}`, {
|
|
231
|
+
msg_type: "text",
|
|
232
|
+
content: JSON.stringify({ text }),
|
|
233
|
+
});
|
|
234
|
+
},
|
|
235
|
+
async deleteMessage(messageId) {
|
|
236
|
+
await call("deleteMessage", "DELETE", `/open-apis/im/v1/messages/${encodeURIComponent(messageId)}`);
|
|
237
|
+
},
|
|
238
|
+
async getMessage(messageId) {
|
|
239
|
+
const data = await call("getMessage", "GET", `/open-apis/im/v1/messages/${encodeURIComponent(messageId)}`);
|
|
240
|
+
return data.data?.items?.[0];
|
|
241
|
+
},
|
|
242
|
+
async downloadResource(messageId, fileKey, type) {
|
|
243
|
+
// The byte download is the one non-JSON call, so it cannot ride the pipeline — same token +
|
|
244
|
+
// timeout + naming discipline, applied here once.
|
|
245
|
+
const label = "downloadResource";
|
|
246
|
+
const token = await tenantToken();
|
|
247
|
+
let res;
|
|
248
|
+
let buf;
|
|
249
|
+
let errBody;
|
|
250
|
+
try {
|
|
251
|
+
res = await fetch(`${baseUrl}/open-apis/im/v1/messages/${encodeURIComponent(messageId)}/resources/${encodeURIComponent(fileKey)}?type=${type}`, { headers: { authorization: `Bearer ${token}` }, signal: AbortSignal.timeout(DOWNLOAD_TIMEOUT_MS) });
|
|
252
|
+
buf = res.ok ? await res.arrayBuffer() : undefined;
|
|
253
|
+
errBody = res.ok ? undefined : await res.text(); // the error body self-describes (expired key etc.)
|
|
254
|
+
}
|
|
255
|
+
catch (e) {
|
|
256
|
+
throw new FeishuApiError(kind, label, 0, 0, String(e), { cause: e });
|
|
257
|
+
}
|
|
258
|
+
if (!res.ok || buf === undefined) {
|
|
259
|
+
let description;
|
|
260
|
+
let code = 0;
|
|
261
|
+
try {
|
|
262
|
+
const parsed = JSON.parse(errBody ?? "");
|
|
263
|
+
description = parsed.msg;
|
|
264
|
+
code = parsed.code ?? 0;
|
|
265
|
+
}
|
|
266
|
+
catch {
|
|
267
|
+
/* non-JSON error body — fall through to the generic description */
|
|
268
|
+
}
|
|
269
|
+
throw new FeishuApiError(kind, label, res.status, code, description ?? "response was not the expected bytes");
|
|
270
|
+
}
|
|
271
|
+
const bytes = Buffer.from(buf);
|
|
272
|
+
if (bytes.byteLength > MAX_DOWNLOAD_BYTES)
|
|
273
|
+
throw new Error("resource is too large (max 20 MB)");
|
|
274
|
+
return { bytes, contentType: res.headers.get("content-type") ?? undefined };
|
|
275
|
+
},
|
|
276
|
+
async fetchImage(messageId, fileKey) {
|
|
277
|
+
const { bytes, contentType } = await api.downloadResource(messageId, fileKey, "image");
|
|
278
|
+
const mime = contentType?.split(";")[0]?.trim();
|
|
279
|
+
return { mimeType: mime?.startsWith("image/") ? mime : "image/jpeg", data: bytes.toString("base64") };
|
|
280
|
+
},
|
|
281
|
+
async fetchFile(messageId, fileKey, name, chatId, filesDir) {
|
|
282
|
+
const { bytes } = await api.downloadResource(messageId, fileKey, "file");
|
|
283
|
+
// The name is external input destined for a filesystem path — keep only its basename-safe core.
|
|
284
|
+
const safe = name.replace(/[/\\]/g, "_").replace(/^\.+/, "_") || "file";
|
|
285
|
+
const dir = join(filesDir, chatId);
|
|
286
|
+
await mkdir(dir, { recursive: true });
|
|
287
|
+
const dest = join(dir, safe);
|
|
288
|
+
await writeFile(dest, bytes);
|
|
289
|
+
return { path: dest, name: safe, size: bytes.byteLength };
|
|
290
|
+
},
|
|
291
|
+
async getAppConfig(appId) {
|
|
292
|
+
// v6 app detail — the one read surface that returns the event-security material (under data.app).
|
|
293
|
+
const data = await call("getAppConfig", "GET", `/open-apis/application/v6/applications/${encodeURIComponent(appId)}?lang=zh_cn`);
|
|
294
|
+
return {
|
|
295
|
+
verificationToken: data.data?.app?.encryption?.verification_token,
|
|
296
|
+
encryptionKey: data.data?.app?.encryption?.encryption_key,
|
|
297
|
+
};
|
|
298
|
+
},
|
|
299
|
+
async updateEventSubscription(appId, cfg) {
|
|
300
|
+
await call("updateEventSubscription", "PATCH", `/open-apis/application/v7/applications/${encodeURIComponent(appId)}/config`, {
|
|
301
|
+
event: { subscription_type: cfg.subscriptionType, request_url: cfg.requestUrl },
|
|
302
|
+
});
|
|
303
|
+
},
|
|
304
|
+
async createCard(cardJson) {
|
|
305
|
+
const data = await call("createCard", "POST", "/open-apis/cardkit/v1/cards", {
|
|
306
|
+
type: "card_json",
|
|
307
|
+
data: cardJson,
|
|
308
|
+
});
|
|
309
|
+
const id = data.data?.card_id;
|
|
310
|
+
if (!id)
|
|
311
|
+
throw new FeishuApiError(kind, "createCard", 200, 0, "response carried no card_id");
|
|
312
|
+
return id;
|
|
313
|
+
},
|
|
314
|
+
async updateCardElement(cardId, elementId, content, sequence) {
|
|
315
|
+
await call("updateCardElement", "PUT", `/open-apis/cardkit/v1/cards/${encodeURIComponent(cardId)}/elements/${encodeURIComponent(elementId)}/content`, { content, sequence });
|
|
316
|
+
},
|
|
317
|
+
async updateCard(cardId, cardJson, sequence) {
|
|
318
|
+
await call("updateCard", "PUT", `/open-apis/cardkit/v1/cards/${encodeURIComponent(cardId)}`, {
|
|
319
|
+
card: { type: "card_json", data: cardJson },
|
|
320
|
+
sequence,
|
|
321
|
+
});
|
|
322
|
+
},
|
|
323
|
+
};
|
|
324
|
+
return api;
|
|
325
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import type { ChannelModule } from "../../host/node.ts";
|
|
2
|
+
import { type FeishuCloudProfile } from "./cloud.ts";
|
|
3
|
+
import { type FeishuMessage, type FeishuMessageEvent, type FeishuRoute, defaultFeishuRoute, feishuEnvelope } from "./parse.ts";
|
|
4
|
+
import { type FeishuFailure } from "./preview.ts";
|
|
5
|
+
export { defaultFeishuRoute, feishuEnvelope };
|
|
6
|
+
export type { FeishuFailure, FeishuMessage, FeishuMessageEvent, FeishuRoute };
|
|
7
|
+
export interface FeishuChannelOptions {
|
|
8
|
+
/** App ID (developer console → Credentials & Basic Info). */
|
|
9
|
+
appId: string;
|
|
10
|
+
/** App Secret (same page) — drives the tenant_access_token the replies ride on. */
|
|
11
|
+
appSecret: string;
|
|
12
|
+
/** Verification Token (console → Events & Callbacks) — authenticates PLAINTEXT events. */
|
|
13
|
+
verificationToken: string;
|
|
14
|
+
/** Encrypt Key (same page, optional there — recommended): when set, ordinary events arrive encrypted
|
|
15
|
+
* and signed; this channel then REFUSES plaintext events (fail closed — accepting both would let a
|
|
16
|
+
* forger skip the stronger check). Feishu explicitly excludes the encrypted `url_verification`
|
|
17
|
+
* handshake from event signature verification; that narrow path is authenticated after decryption
|
|
18
|
+
* by the Verification Token. Must match the console exactly. */
|
|
19
|
+
encryptKey?: string;
|
|
20
|
+
/** Policy: whether/where to answer an event (return null to ignore). Defaults to {@link defaultFeishuRoute}. */
|
|
21
|
+
route?: (event: FeishuMessageEvent) => FeishuRoute | null;
|
|
22
|
+
/** Customer-facing failure text for the chat (the dev-facing full `details` always go to the operator
|
|
23
|
+
* log). Return a string to send it, or undefined/"" to stay silent. Default: a neutral message keyed
|
|
24
|
+
* on `retryable`. A developer's own bot can surface the raw details, e.g. `(f) => `⚠️ ${f.details}``. */
|
|
25
|
+
onError?: (failed: FeishuFailure) => string | undefined;
|
|
26
|
+
/** API origin override (tests / self-hosted gateways). The kind fixes the default —
|
|
27
|
+
* `feishuChannel` → `https://open.feishu.cn`, `larkChannel` → `https://open.larksuite.com`. */
|
|
28
|
+
baseUrl?: string;
|
|
29
|
+
/** How long (ms) a turn waits before its reply-quoted "⏳ Queued" card mounts. Defaults to 0
|
|
30
|
+
* (immediate); the same card is later taken over by the live preview/final answer. */
|
|
31
|
+
queueNoticeDelayMs?: number;
|
|
32
|
+
}
|
|
33
|
+
/** Build the canonical Feishu channel. Lark calls the internal profile-bound builder below. */
|
|
34
|
+
export declare function feishuChannel(opts: FeishuChannelOptions): ChannelModule;
|
|
35
|
+
/** Internal compatibility seam: protocol behavior comes from Feishu; the profile binds cloud edges. */
|
|
36
|
+
export declare function buildFeishuChannel(profile: FeishuCloudProfile, { appId, appSecret, verificationToken, encryptKey, route, onError, baseUrl, queueNoticeDelayMs, }: FeishuChannelOptions, factoryName: string): ChannelModule;
|