@minitool/feishu-bot 0.1.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/CHANGELOG.md +17 -0
- package/LICENSE +21 -0
- package/README.md +221 -0
- package/dist/index.cjs +495 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.ts +345 -0
- package/dist/index.js +482 -0
- package/dist/index.js.map +1 -0
- package/package.json +64 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,482 @@
|
|
|
1
|
+
import { readFile } from "node:fs/promises";
|
|
2
|
+
import { basename } from "node:path";
|
|
3
|
+
import { createHmac } from "node:crypto";
|
|
4
|
+
//#region src/env.ts
|
|
5
|
+
/**
|
|
6
|
+
* 安全读取 process.env。在不存在 process 的环境(如浏览器)里返回 undefined,不会崩溃。
|
|
7
|
+
* SDK 本身不引入 dotenv,调用方可自行用 `node --env-file=.env` 或 `dotenv/config` 预加载。
|
|
8
|
+
*/
|
|
9
|
+
function readEnv(key) {
|
|
10
|
+
if (typeof process === "undefined" || !process.env) return;
|
|
11
|
+
const value = process.env[key];
|
|
12
|
+
if (value === void 0 || value === "") return;
|
|
13
|
+
return value;
|
|
14
|
+
}
|
|
15
|
+
//#endregion
|
|
16
|
+
//#region src/errors.ts
|
|
17
|
+
/**
|
|
18
|
+
* 所有飞书机器人相关错误的基类。
|
|
19
|
+
*/
|
|
20
|
+
var FeishuBotError = class extends Error {
|
|
21
|
+
constructor(message) {
|
|
22
|
+
super(message);
|
|
23
|
+
this.name = "FeishuBotError";
|
|
24
|
+
Object.setPrototypeOf(this, new.target.prototype);
|
|
25
|
+
}
|
|
26
|
+
};
|
|
27
|
+
/**
|
|
28
|
+
* 配置相关错误:如未提供 webhook、secret、appId、appSecret 等。
|
|
29
|
+
* 构造 FeishuBot 实例时不会抛;延迟到 send/upload 调用时才抛。
|
|
30
|
+
*/
|
|
31
|
+
var FeishuConfigError = class extends FeishuBotError {
|
|
32
|
+
constructor(message) {
|
|
33
|
+
super(message);
|
|
34
|
+
this.name = "FeishuConfigError";
|
|
35
|
+
}
|
|
36
|
+
};
|
|
37
|
+
/**
|
|
38
|
+
* 调用飞书 OpenAPI 或 webhook 后,返回 code !== 0 或 HTTP 非 2xx 时抛出。
|
|
39
|
+
*/
|
|
40
|
+
var FeishuApiError = class extends FeishuBotError {
|
|
41
|
+
code;
|
|
42
|
+
response;
|
|
43
|
+
constructor(message, code, response) {
|
|
44
|
+
super(message);
|
|
45
|
+
this.name = "FeishuApiError";
|
|
46
|
+
this.code = code;
|
|
47
|
+
this.response = response;
|
|
48
|
+
}
|
|
49
|
+
};
|
|
50
|
+
//#endregion
|
|
51
|
+
//#region src/http.ts
|
|
52
|
+
var DEFAULT_TIMEOUT = 1e4;
|
|
53
|
+
function resolveFetch(customFetch) {
|
|
54
|
+
const fn = customFetch ?? globalThis.fetch;
|
|
55
|
+
if (typeof fn !== "function") throw new FeishuApiError("global fetch is not available. Please use Node.js >= 18 or provide a custom fetch.", -1, null);
|
|
56
|
+
return fn;
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* 通用请求执行器:处理 timeout + 错误归一化。
|
|
60
|
+
* 为了让 timeout 覆盖整个 body 读取过程,在 clearTimeout 之前就完成 response.text()。
|
|
61
|
+
* 返回结构化结果,由调用方自行决定是否解析 JSON。
|
|
62
|
+
*/
|
|
63
|
+
async function request(url, init, options = {}) {
|
|
64
|
+
const fetchImpl = resolveFetch(options.fetch);
|
|
65
|
+
const timeout = options.timeout ?? DEFAULT_TIMEOUT;
|
|
66
|
+
const controller = new AbortController();
|
|
67
|
+
const timer = setTimeout(() => controller.abort(), timeout);
|
|
68
|
+
try {
|
|
69
|
+
const response = await fetchImpl(url, {
|
|
70
|
+
...init,
|
|
71
|
+
signal: controller.signal
|
|
72
|
+
});
|
|
73
|
+
const text = await response.text();
|
|
74
|
+
return {
|
|
75
|
+
status: response.status,
|
|
76
|
+
statusText: response.statusText,
|
|
77
|
+
ok: response.ok,
|
|
78
|
+
text
|
|
79
|
+
};
|
|
80
|
+
} catch (err) {
|
|
81
|
+
if (err instanceof Error && err.name === "AbortError") throw new FeishuApiError(`Request timed out after ${timeout}ms: ${url}`, -1, null);
|
|
82
|
+
if (err instanceof FeishuApiError) throw err;
|
|
83
|
+
throw new FeishuApiError(`Network error: ${err instanceof Error ? err.message : String(err)}`, -1, null);
|
|
84
|
+
} finally {
|
|
85
|
+
clearTimeout(timer);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
function parseJsonBody(raw) {
|
|
89
|
+
if (!raw.text) throw new FeishuApiError(`Empty response body (HTTP ${raw.status})`, -1, null);
|
|
90
|
+
try {
|
|
91
|
+
return JSON.parse(raw.text);
|
|
92
|
+
} catch {
|
|
93
|
+
throw new FeishuApiError(`Failed to parse JSON response (HTTP ${raw.status}): ${raw.text.slice(0, 200)}`, -1, raw.text);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
function throwIfHttpError(raw) {
|
|
97
|
+
if (!raw.ok) throw new FeishuApiError(`HTTP ${raw.status} ${raw.statusText}: ${raw.text.slice(0, 200)}`, raw.status, raw.text);
|
|
98
|
+
}
|
|
99
|
+
/**
|
|
100
|
+
* POST JSON 请求,返回已解析的 JSON。HTTP 非 2xx 或解析失败时抛 FeishuApiError。
|
|
101
|
+
* 注意:业务层 code !== 0 的判断由调用方处理(不同接口含义不同)。
|
|
102
|
+
*/
|
|
103
|
+
async function postJson(url, body, options = {}) {
|
|
104
|
+
const raw = await request(url, {
|
|
105
|
+
method: "POST",
|
|
106
|
+
headers: {
|
|
107
|
+
"Content-Type": "application/json; charset=utf-8",
|
|
108
|
+
...options.headers
|
|
109
|
+
},
|
|
110
|
+
body: JSON.stringify(body)
|
|
111
|
+
}, options);
|
|
112
|
+
throwIfHttpError(raw);
|
|
113
|
+
return parseJsonBody(raw);
|
|
114
|
+
}
|
|
115
|
+
/**
|
|
116
|
+
* POST 一个 FormData(multipart/form-data)。用于图片上传。
|
|
117
|
+
* 注意:绝不要手动设置 Content-Type,让 fetch/undici 自动带 boundary。
|
|
118
|
+
*/
|
|
119
|
+
async function postForm(url, form, options = {}) {
|
|
120
|
+
const raw = await request(url, {
|
|
121
|
+
method: "POST",
|
|
122
|
+
headers: { ...options.headers },
|
|
123
|
+
body: form
|
|
124
|
+
}, options);
|
|
125
|
+
throwIfHttpError(raw);
|
|
126
|
+
return parseJsonBody(raw);
|
|
127
|
+
}
|
|
128
|
+
//#endregion
|
|
129
|
+
//#region src/image-uploader.ts
|
|
130
|
+
var DEFAULT_BASE_URL$2 = "https://open.feishu.cn";
|
|
131
|
+
var UPLOAD_PATH = "/open-apis/im/v1/images";
|
|
132
|
+
/**
|
|
133
|
+
* 图片上传器:调用 im/v1/images 接口,返回 image_key。
|
|
134
|
+
* - string: 作为文件路径用 fs/promises.readFile 读成 Buffer
|
|
135
|
+
* - Buffer/Uint8Array: 直接作为 Blob 数据
|
|
136
|
+
* 用 globalThis 的 FormData + Blob(Node 18+ 内置),不依赖 form-data 包。
|
|
137
|
+
*/
|
|
138
|
+
var ImageUploader = class {
|
|
139
|
+
tokenManager;
|
|
140
|
+
fetchImpl;
|
|
141
|
+
timeout;
|
|
142
|
+
baseUrl;
|
|
143
|
+
constructor(options) {
|
|
144
|
+
this.tokenManager = options.tokenManager;
|
|
145
|
+
this.fetchImpl = options.fetch;
|
|
146
|
+
this.timeout = options.timeout;
|
|
147
|
+
this.baseUrl = options.baseUrl ?? DEFAULT_BASE_URL$2;
|
|
148
|
+
}
|
|
149
|
+
/**
|
|
150
|
+
* 上传图片,返回 image_key。
|
|
151
|
+
*/
|
|
152
|
+
async uploadImage(file) {
|
|
153
|
+
const { bytes, filename } = await this.resolveSource(file);
|
|
154
|
+
const token = await this.tokenManager.getToken();
|
|
155
|
+
const form = new FormData();
|
|
156
|
+
form.append("image_type", "message");
|
|
157
|
+
const blob = new Blob([bytes.slice()], { type: "application/octet-stream" });
|
|
158
|
+
form.append("image", blob, filename);
|
|
159
|
+
const response = await postForm(`${this.baseUrl}${UPLOAD_PATH}`, form, {
|
|
160
|
+
fetch: this.fetchImpl,
|
|
161
|
+
timeout: this.timeout,
|
|
162
|
+
headers: { Authorization: `Bearer ${token}` }
|
|
163
|
+
});
|
|
164
|
+
if (response.code !== 0 || !response.data?.image_key) throw new FeishuApiError(`Failed to upload image: ${response.msg ?? "unknown error"}`, response.code ?? -1, response);
|
|
165
|
+
return response.data.image_key;
|
|
166
|
+
}
|
|
167
|
+
async resolveSource(file) {
|
|
168
|
+
if (typeof file === "string") {
|
|
169
|
+
const buf = await readFile(file);
|
|
170
|
+
return {
|
|
171
|
+
bytes: new Uint8Array(buf),
|
|
172
|
+
filename: basename(file)
|
|
173
|
+
};
|
|
174
|
+
}
|
|
175
|
+
if (file instanceof Uint8Array) return {
|
|
176
|
+
bytes: file,
|
|
177
|
+
filename: "image"
|
|
178
|
+
};
|
|
179
|
+
throw new FeishuApiError("Unsupported image source type. Expected string path, Buffer, or Uint8Array.", -1, null);
|
|
180
|
+
}
|
|
181
|
+
};
|
|
182
|
+
//#endregion
|
|
183
|
+
//#region src/messages/image.ts
|
|
184
|
+
/**
|
|
185
|
+
* 构造 image 消息。
|
|
186
|
+
*
|
|
187
|
+
* 注意:自定义机器人直发 image 消息只认 image_key(形如 `img_xxx`)。
|
|
188
|
+
* 想要直接发送本地文件,请使用 FeishuBot.sendImage() 或 FeishuBot.uploadImage()。
|
|
189
|
+
*/
|
|
190
|
+
function buildImage(imageKey) {
|
|
191
|
+
return {
|
|
192
|
+
msg_type: "image",
|
|
193
|
+
content: { image_key: imageKey }
|
|
194
|
+
};
|
|
195
|
+
}
|
|
196
|
+
//#endregion
|
|
197
|
+
//#region src/messages/interactive.ts
|
|
198
|
+
/**
|
|
199
|
+
* 构造卡片(interactive)消息。
|
|
200
|
+
*
|
|
201
|
+
* 直接透传 card 结构。支持 card schema 2.0 或旧版 header/elements 格式:
|
|
202
|
+
*
|
|
203
|
+
* buildInteractive({
|
|
204
|
+
* schema: "2.0",
|
|
205
|
+
* header: { title: { tag: "plain_text", content: "标题" } },
|
|
206
|
+
* body: { elements: [...] },
|
|
207
|
+
* });
|
|
208
|
+
*
|
|
209
|
+
* // 或旧版:
|
|
210
|
+
* buildInteractive({
|
|
211
|
+
* config: { wide_screen_mode: true },
|
|
212
|
+
* header: { template: "blue", title: { tag: "plain_text", content: "标题" } },
|
|
213
|
+
* elements: [...],
|
|
214
|
+
* });
|
|
215
|
+
*/
|
|
216
|
+
function buildInteractive(card) {
|
|
217
|
+
return {
|
|
218
|
+
msg_type: "interactive",
|
|
219
|
+
card
|
|
220
|
+
};
|
|
221
|
+
}
|
|
222
|
+
//#endregion
|
|
223
|
+
//#region src/messages/post.ts
|
|
224
|
+
/**
|
|
225
|
+
* 构造富文本(post)消息。
|
|
226
|
+
*
|
|
227
|
+
* 用户构造 PostContent(支持 zh_cn/en_us/ja_jp 三语言),每个语言下是 `content: PostTag[][]` 的二维数组:
|
|
228
|
+
* 外层是段落(行),内层是行内的标签(text/a/at/img)。
|
|
229
|
+
*
|
|
230
|
+
* 示例:
|
|
231
|
+
* buildPost({
|
|
232
|
+
* zh_cn: {
|
|
233
|
+
* title: "标题",
|
|
234
|
+
* content: [
|
|
235
|
+
* [{ tag: "text", text: "第一段: " }, { tag: "a", text: "点这里", href: "https://..." }],
|
|
236
|
+
* [{ tag: "img", image_key: "img_xxx" }],
|
|
237
|
+
* ],
|
|
238
|
+
* },
|
|
239
|
+
* });
|
|
240
|
+
*/
|
|
241
|
+
function buildPost(post) {
|
|
242
|
+
return {
|
|
243
|
+
msg_type: "post",
|
|
244
|
+
content: { post }
|
|
245
|
+
};
|
|
246
|
+
}
|
|
247
|
+
//#endregion
|
|
248
|
+
//#region src/messages/share-chat.ts
|
|
249
|
+
/**
|
|
250
|
+
* 构造分享群名片(share_chat)消息。
|
|
251
|
+
*
|
|
252
|
+
* @param shareChatId 群 chat_id(形如 `oc_xxx`)
|
|
253
|
+
*/
|
|
254
|
+
function buildShareChat(shareChatId) {
|
|
255
|
+
return {
|
|
256
|
+
msg_type: "share_chat",
|
|
257
|
+
content: { share_chat_id: shareChatId }
|
|
258
|
+
};
|
|
259
|
+
}
|
|
260
|
+
//#endregion
|
|
261
|
+
//#region src/messages/text.ts
|
|
262
|
+
/**
|
|
263
|
+
* 构造 text 消息。
|
|
264
|
+
*
|
|
265
|
+
* @-提醒说明(来自飞书文档):
|
|
266
|
+
* - @ 所有人:`<at user_id="all">所有人</at>`(仅群里能用,必须机器人所在群支持)
|
|
267
|
+
* - @ 指定用户(需已知 open_id):`<at user_id="ou_xxx"></at>`
|
|
268
|
+
*
|
|
269
|
+
* 示例:
|
|
270
|
+
* buildText("hello", { atAll: true })
|
|
271
|
+
* // => { msg_type: "text", content: { text: "hello <at user_id=\"all\">所有人</at>" } }
|
|
272
|
+
*/
|
|
273
|
+
function buildText(text, opts = {}) {
|
|
274
|
+
const parts = [];
|
|
275
|
+
if (text) parts.push(text);
|
|
276
|
+
if (opts.atUserIds && opts.atUserIds.length > 0) for (const id of opts.atUserIds) parts.push(`<at user_id="${id}"></at>`);
|
|
277
|
+
if (opts.atAll) parts.push("<at user_id=\"all\">所有人</at>");
|
|
278
|
+
return {
|
|
279
|
+
msg_type: "text",
|
|
280
|
+
content: { text: parts.join(" ") }
|
|
281
|
+
};
|
|
282
|
+
}
|
|
283
|
+
//#endregion
|
|
284
|
+
//#region src/signer.ts
|
|
285
|
+
/**
|
|
286
|
+
* 生成飞书自定义机器人签名。
|
|
287
|
+
*
|
|
288
|
+
* 算法(来自飞书官方文档,反直觉之处:HMAC 的 key 是 stringToSign 本身,data 是空字符串):
|
|
289
|
+
* stringToSign = `${timestamp}\n${secret}`
|
|
290
|
+
* sign = Base64(HmacSHA256(key = stringToSign, data = ''))
|
|
291
|
+
*
|
|
292
|
+
* @param timestamp Unix 秒时间戳(飞书要求 ±1 小时窗口)
|
|
293
|
+
* @param secret 机器人「安全设置 → 签名校验」得到的 secret
|
|
294
|
+
*/
|
|
295
|
+
function genSign(timestamp, secret) {
|
|
296
|
+
return createHmac("sha256", `${timestamp}\n${secret}`).update("").digest("base64");
|
|
297
|
+
}
|
|
298
|
+
/**
|
|
299
|
+
* 获取当前 Unix 秒时间戳。
|
|
300
|
+
*/
|
|
301
|
+
function currentTimestamp() {
|
|
302
|
+
return Math.floor(Date.now() / 1e3);
|
|
303
|
+
}
|
|
304
|
+
//#endregion
|
|
305
|
+
//#region src/token-manager.ts
|
|
306
|
+
var DEFAULT_BASE_URL$1 = "https://open.feishu.cn";
|
|
307
|
+
var TENANT_TOKEN_PATH = "/open-apis/auth/v3/tenant_access_token/internal";
|
|
308
|
+
/** 剩余有效时间小于 30 分钟就刷新 */
|
|
309
|
+
var REFRESH_THRESHOLD_MS = 1800 * 1e3;
|
|
310
|
+
/**
|
|
311
|
+
* tenant_access_token 缓存与自动刷新。
|
|
312
|
+
* 并发去重:多次 getToken() 在 in-flight 期间共享同一个 Promise,避免重复请求。
|
|
313
|
+
*/
|
|
314
|
+
var TokenManager = class {
|
|
315
|
+
appId;
|
|
316
|
+
appSecret;
|
|
317
|
+
fetchImpl;
|
|
318
|
+
timeout;
|
|
319
|
+
baseUrl;
|
|
320
|
+
cached = null;
|
|
321
|
+
inflight = null;
|
|
322
|
+
constructor(options) {
|
|
323
|
+
if (!options.appId || !options.appSecret) throw new FeishuConfigError("appId and appSecret are required for TokenManager");
|
|
324
|
+
this.appId = options.appId;
|
|
325
|
+
this.appSecret = options.appSecret;
|
|
326
|
+
this.fetchImpl = options.fetch;
|
|
327
|
+
this.timeout = options.timeout;
|
|
328
|
+
this.baseUrl = options.baseUrl ?? DEFAULT_BASE_URL$1;
|
|
329
|
+
}
|
|
330
|
+
/**
|
|
331
|
+
* 获取有效 token。优先使用缓存;过期/即将过期时刷新。
|
|
332
|
+
*/
|
|
333
|
+
async getToken() {
|
|
334
|
+
if (this.isCacheFresh()) return this.cached.token;
|
|
335
|
+
if (this.inflight) return this.inflight;
|
|
336
|
+
this.inflight = this.fetchToken().finally(() => {
|
|
337
|
+
this.inflight = null;
|
|
338
|
+
});
|
|
339
|
+
return this.inflight;
|
|
340
|
+
}
|
|
341
|
+
isCacheFresh() {
|
|
342
|
+
if (!this.cached) return false;
|
|
343
|
+
return this.cached.expiresAt - Date.now() > REFRESH_THRESHOLD_MS;
|
|
344
|
+
}
|
|
345
|
+
async fetchToken() {
|
|
346
|
+
const response = await postJson(`${this.baseUrl}${TENANT_TOKEN_PATH}`, {
|
|
347
|
+
app_id: this.appId,
|
|
348
|
+
app_secret: this.appSecret
|
|
349
|
+
}, {
|
|
350
|
+
fetch: this.fetchImpl,
|
|
351
|
+
timeout: this.timeout
|
|
352
|
+
});
|
|
353
|
+
if (response.code !== 0 || !response.tenant_access_token) throw new FeishuApiError(`Failed to fetch tenant_access_token: ${response.msg ?? "unknown error"}`, response.code ?? -1, response);
|
|
354
|
+
const expireSeconds = response.expire ?? 7200;
|
|
355
|
+
this.cached = {
|
|
356
|
+
token: response.tenant_access_token,
|
|
357
|
+
expiresAt: Date.now() + expireSeconds * 1e3
|
|
358
|
+
};
|
|
359
|
+
return this.cached.token;
|
|
360
|
+
}
|
|
361
|
+
};
|
|
362
|
+
//#endregion
|
|
363
|
+
//#region src/client.ts
|
|
364
|
+
var DEFAULT_BASE_URL = "https://open.feishu.cn";
|
|
365
|
+
/**
|
|
366
|
+
* 飞书自定义机器人 SDK 主类。
|
|
367
|
+
*
|
|
368
|
+
* 构造期不会报错;缺失配置时延迟到 send/upload 调用时抛出 FeishuConfigError,
|
|
369
|
+
* 便于「先 new 再注入配置」的使用模式。
|
|
370
|
+
*
|
|
371
|
+
* 使用示例:
|
|
372
|
+
* const bot = new FeishuBot(); // 从 env 读配置
|
|
373
|
+
* await bot.sendText("hello", { atAll: true });
|
|
374
|
+
* await bot.sendImage("./banner.png"); // 自动上传得到 image_key 再发送
|
|
375
|
+
*/
|
|
376
|
+
var FeishuBot = class {
|
|
377
|
+
webhook;
|
|
378
|
+
secret;
|
|
379
|
+
appId;
|
|
380
|
+
appSecret;
|
|
381
|
+
fetchImpl;
|
|
382
|
+
timeout;
|
|
383
|
+
baseUrl;
|
|
384
|
+
tokenManager = null;
|
|
385
|
+
imageUploader = null;
|
|
386
|
+
constructor(options = {}) {
|
|
387
|
+
this.webhook = options.webhook ?? readEnv("FEISHU_BOT_WEBHOOK");
|
|
388
|
+
this.secret = options.secret ?? readEnv("FEISHU_BOT_SECRET");
|
|
389
|
+
this.appId = options.appId ?? readEnv("FEISHU_APP_ID");
|
|
390
|
+
this.appSecret = options.appSecret ?? readEnv("FEISHU_APP_SECRET");
|
|
391
|
+
this.fetchImpl = options.fetch;
|
|
392
|
+
this.timeout = options.timeout;
|
|
393
|
+
this.baseUrl = options.baseUrl ?? DEFAULT_BASE_URL;
|
|
394
|
+
}
|
|
395
|
+
/**
|
|
396
|
+
* 原子发送:接收已构造好的 payload,负责注入签名并 POST 到 webhook。
|
|
397
|
+
* code !== 0 时抛 FeishuApiError。
|
|
398
|
+
*/
|
|
399
|
+
async send(payload) {
|
|
400
|
+
const webhook = this.ensureWebhook();
|
|
401
|
+
const finalPayload = { ...payload };
|
|
402
|
+
if (this.secret) {
|
|
403
|
+
const timestamp = currentTimestamp();
|
|
404
|
+
finalPayload.timestamp = String(timestamp);
|
|
405
|
+
finalPayload.sign = genSign(timestamp, this.secret);
|
|
406
|
+
}
|
|
407
|
+
const response = await postJson(webhook, finalPayload, {
|
|
408
|
+
fetch: this.fetchImpl,
|
|
409
|
+
timeout: this.timeout
|
|
410
|
+
});
|
|
411
|
+
if (response.code !== 0) throw new FeishuApiError(`Feishu webhook error: ${response.msg ?? "unknown"} (code=${response.code})`, response.code, response);
|
|
412
|
+
return response;
|
|
413
|
+
}
|
|
414
|
+
sendText(text, opts) {
|
|
415
|
+
return this.send(buildText(text, opts));
|
|
416
|
+
}
|
|
417
|
+
sendPost(post) {
|
|
418
|
+
return this.send(buildPost(post));
|
|
419
|
+
}
|
|
420
|
+
sendShareChat(shareChatId) {
|
|
421
|
+
return this.send(buildShareChat(shareChatId));
|
|
422
|
+
}
|
|
423
|
+
sendInteractive(card) {
|
|
424
|
+
return this.send(buildInteractive(card));
|
|
425
|
+
}
|
|
426
|
+
/**
|
|
427
|
+
* 发送图片。智能识别三种入参:
|
|
428
|
+
* - string 且以 `img_` 开头 → 直接当 image_key 使用
|
|
429
|
+
* - string 否则 → 视为本地文件路径,先上传再发送
|
|
430
|
+
* - Buffer / Uint8Array → 直接上传再发送
|
|
431
|
+
*/
|
|
432
|
+
async sendImage(input) {
|
|
433
|
+
let imageKey;
|
|
434
|
+
if (typeof input === "string" && input.startsWith("img_")) imageKey = input;
|
|
435
|
+
else imageKey = await this.uploadImage(input);
|
|
436
|
+
return this.send(buildImage(imageKey));
|
|
437
|
+
}
|
|
438
|
+
/**
|
|
439
|
+
* 暴露底层图片上传,便于调用方复用 image_key。
|
|
440
|
+
* 需要 appId / appSecret 配置。
|
|
441
|
+
*/
|
|
442
|
+
async uploadImage(file) {
|
|
443
|
+
return this.getImageUploader().uploadImage(file);
|
|
444
|
+
}
|
|
445
|
+
ensureWebhook() {
|
|
446
|
+
if (!this.webhook) throw new FeishuConfigError("webhook is required. Provide `webhook` in options or set FEISHU_BOT_WEBHOOK env.");
|
|
447
|
+
return this.webhook;
|
|
448
|
+
}
|
|
449
|
+
ensureAppCredentials() {
|
|
450
|
+
if (!this.appId || !this.appSecret) throw new FeishuConfigError("appId and appSecret are required for image upload. Provide them in options or set FEISHU_APP_ID / FEISHU_APP_SECRET env.");
|
|
451
|
+
return {
|
|
452
|
+
appId: this.appId,
|
|
453
|
+
appSecret: this.appSecret
|
|
454
|
+
};
|
|
455
|
+
}
|
|
456
|
+
getTokenManager() {
|
|
457
|
+
if (!this.tokenManager) {
|
|
458
|
+
const { appId, appSecret } = this.ensureAppCredentials();
|
|
459
|
+
this.tokenManager = new TokenManager({
|
|
460
|
+
appId,
|
|
461
|
+
appSecret,
|
|
462
|
+
fetch: this.fetchImpl,
|
|
463
|
+
timeout: this.timeout,
|
|
464
|
+
baseUrl: this.baseUrl
|
|
465
|
+
});
|
|
466
|
+
}
|
|
467
|
+
return this.tokenManager;
|
|
468
|
+
}
|
|
469
|
+
getImageUploader() {
|
|
470
|
+
if (!this.imageUploader) this.imageUploader = new ImageUploader({
|
|
471
|
+
tokenManager: this.getTokenManager(),
|
|
472
|
+
fetch: this.fetchImpl,
|
|
473
|
+
timeout: this.timeout,
|
|
474
|
+
baseUrl: this.baseUrl
|
|
475
|
+
});
|
|
476
|
+
return this.imageUploader;
|
|
477
|
+
}
|
|
478
|
+
};
|
|
479
|
+
//#endregion
|
|
480
|
+
export { FeishuApiError, FeishuBot, FeishuBotError, FeishuConfigError, ImageUploader, TokenManager, buildImage, buildInteractive, buildPost, buildShareChat, buildText, currentTimestamp, genSign };
|
|
481
|
+
|
|
482
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.js","names":[],"sources":["../src/env.ts","../src/errors.ts","../src/http.ts","../src/image-uploader.ts","../src/messages/image.ts","../src/messages/interactive.ts","../src/messages/post.ts","../src/messages/share-chat.ts","../src/messages/text.ts","../src/signer.ts","../src/token-manager.ts","../src/client.ts"],"sourcesContent":["/**\n * 安全读取 process.env。在不存在 process 的环境(如浏览器)里返回 undefined,不会崩溃。\n * SDK 本身不引入 dotenv,调用方可自行用 `node --env-file=.env` 或 `dotenv/config` 预加载。\n */\nexport function readEnv(key: string): string | undefined {\n if (typeof process === 'undefined' || !process.env) {\n return undefined;\n }\n // 上面已经保证 process.env 存在,无需再用可选链。\n const value = process.env[key];\n if (value === undefined || value === '') {\n return undefined;\n }\n return value;\n}\n","/**\n * 所有飞书机器人相关错误的基类。\n */\nexport class FeishuBotError extends Error {\n constructor(message: string) {\n super(message);\n this.name = 'FeishuBotError';\n // 保证原型链正确,便于 instanceof 检测\n Object.setPrototypeOf(this, new.target.prototype);\n }\n}\n\n/**\n * 配置相关错误:如未提供 webhook、secret、appId、appSecret 等。\n * 构造 FeishuBot 实例时不会抛;延迟到 send/upload 调用时才抛。\n */\nexport class FeishuConfigError extends FeishuBotError {\n constructor(message: string) {\n super(message);\n this.name = 'FeishuConfigError';\n }\n}\n\n/**\n * 调用飞书 OpenAPI 或 webhook 后,返回 code !== 0 或 HTTP 非 2xx 时抛出。\n */\nexport class FeishuApiError extends FeishuBotError {\n public readonly code: number;\n public readonly response: unknown;\n\n constructor(message: string, code: number, response: unknown) {\n super(message);\n this.name = 'FeishuApiError';\n this.code = code;\n this.response = response;\n }\n}\n","import { FeishuApiError } from './errors.js';\n\nexport interface RequestOptions {\n /** 自定义 fetch 实现,默认 globalThis.fetch */\n fetch?: typeof fetch;\n /** 请求超时,单位毫秒,默认 10000 */\n timeout?: number;\n /** 额外请求头 */\n headers?: Record<string, string>;\n}\n\nconst DEFAULT_TIMEOUT = 10_000;\n\ninterface RawResponse {\n status: number;\n statusText: string;\n ok: boolean;\n text: string;\n}\n\nfunction resolveFetch(customFetch?: typeof fetch): typeof fetch {\n const fn = customFetch ?? globalThis.fetch;\n if (typeof fn !== 'function') {\n throw new FeishuApiError(\n 'global fetch is not available. Please use Node.js >= 18 or provide a custom fetch.',\n -1,\n null,\n );\n }\n return fn;\n}\n\n/**\n * 通用请求执行器:处理 timeout + 错误归一化。\n * 为了让 timeout 覆盖整个 body 读取过程,在 clearTimeout 之前就完成 response.text()。\n * 返回结构化结果,由调用方自行决定是否解析 JSON。\n */\nasync function request(\n url: string,\n init: RequestInit,\n options: RequestOptions = {},\n): Promise<RawResponse> {\n const fetchImpl = resolveFetch(options.fetch);\n const timeout = options.timeout ?? DEFAULT_TIMEOUT;\n\n const controller = new AbortController();\n const timer = setTimeout(() => controller.abort(), timeout);\n\n try {\n const response = await fetchImpl(url, {\n ...init,\n signal: controller.signal,\n });\n // 关键:在 clearTimeout 之前读取 body,保证慢 body 也能触发 abort。\n const text = await response.text();\n return {\n status: response.status,\n statusText: response.statusText,\n ok: response.ok,\n text,\n };\n } catch (err) {\n if (err instanceof Error && err.name === 'AbortError') {\n throw new FeishuApiError(\n `Request timed out after ${timeout}ms: ${url}`,\n -1,\n null,\n );\n }\n if (err instanceof FeishuApiError) {\n throw err;\n }\n const message = err instanceof Error ? err.message : String(err);\n throw new FeishuApiError(`Network error: ${message}`, -1, null);\n } finally {\n clearTimeout(timer);\n }\n}\n\nfunction parseJsonBody<T>(raw: RawResponse): T {\n if (!raw.text) {\n throw new FeishuApiError(\n `Empty response body (HTTP ${raw.status})`,\n -1,\n null,\n );\n }\n try {\n return JSON.parse(raw.text) as T;\n } catch {\n throw new FeishuApiError(\n `Failed to parse JSON response (HTTP ${raw.status}): ${raw.text.slice(0, 200)}`,\n -1,\n raw.text,\n );\n }\n}\n\nfunction throwIfHttpError(raw: RawResponse): void {\n if (!raw.ok) {\n throw new FeishuApiError(\n `HTTP ${raw.status} ${raw.statusText}: ${raw.text.slice(0, 200)}`,\n raw.status,\n raw.text,\n );\n }\n}\n\n/**\n * POST JSON 请求,返回已解析的 JSON。HTTP 非 2xx 或解析失败时抛 FeishuApiError。\n * 注意:业务层 code !== 0 的判断由调用方处理(不同接口含义不同)。\n */\nexport async function postJson<T = unknown>(\n url: string,\n body: unknown,\n options: RequestOptions = {},\n): Promise<T> {\n const raw = await request(\n url,\n {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json; charset=utf-8',\n ...options.headers,\n },\n body: JSON.stringify(body),\n },\n options,\n );\n\n throwIfHttpError(raw);\n return parseJsonBody<T>(raw);\n}\n\n/**\n * POST 一个 FormData(multipart/form-data)。用于图片上传。\n * 注意:绝不要手动设置 Content-Type,让 fetch/undici 自动带 boundary。\n */\nexport async function postForm<T = unknown>(\n url: string,\n form: FormData,\n options: RequestOptions = {},\n): Promise<T> {\n const raw = await request(\n url,\n {\n method: 'POST',\n headers: {\n ...options.headers,\n },\n body: form,\n },\n options,\n );\n\n throwIfHttpError(raw);\n return parseJsonBody<T>(raw);\n}\n","import { readFile } from 'node:fs/promises';\nimport { basename } from 'node:path';\n\nimport { FeishuApiError } from './errors.js';\nimport { postForm } from './http.js';\nimport type { TokenManager } from './token-manager.js';\nimport type { FeishuApiResponse, UploadImageResult } from './types.js';\n\nconst DEFAULT_BASE_URL = 'https://open.feishu.cn';\nconst UPLOAD_PATH = '/open-apis/im/v1/images';\n\nexport interface ImageUploaderOptions {\n tokenManager: TokenManager;\n fetch?: typeof fetch;\n timeout?: number;\n baseUrl?: string;\n}\n\n/** 支持的图片源:文件路径字符串 / Buffer / Uint8Array */\nexport type ImageSource = string | Buffer | Uint8Array;\n\n/**\n * 图片上传器:调用 im/v1/images 接口,返回 image_key。\n * - string: 作为文件路径用 fs/promises.readFile 读成 Buffer\n * - Buffer/Uint8Array: 直接作为 Blob 数据\n * 用 globalThis 的 FormData + Blob(Node 18+ 内置),不依赖 form-data 包。\n */\nexport class ImageUploader {\n private readonly tokenManager: TokenManager;\n private readonly fetchImpl?: typeof fetch;\n private readonly timeout?: number;\n private readonly baseUrl: string;\n\n constructor(options: ImageUploaderOptions) {\n this.tokenManager = options.tokenManager;\n this.fetchImpl = options.fetch;\n this.timeout = options.timeout;\n this.baseUrl = options.baseUrl ?? DEFAULT_BASE_URL;\n }\n\n /**\n * 上传图片,返回 image_key。\n */\n async uploadImage(file: ImageSource): Promise<string> {\n const { bytes, filename } = await this.resolveSource(file);\n const token = await this.tokenManager.getToken();\n\n const form = new FormData();\n form.append('image_type', 'message');\n // Blob 构造器的 BlobPart 要求 Uint8Array 必须以 ArrayBuffer(而非 SharedArrayBuffer)为底。\n // 通过 bytes.slice() 得到一份拥有独立 ArrayBuffer 的新 Uint8Array。\n const blob = new Blob([bytes.slice()], {\n type: 'application/octet-stream',\n });\n form.append('image', blob, filename);\n\n const url = `${this.baseUrl}${UPLOAD_PATH}`;\n const response = await postForm<FeishuApiResponse<UploadImageResult>>(\n url,\n form,\n {\n fetch: this.fetchImpl,\n timeout: this.timeout,\n headers: {\n Authorization: `Bearer ${token}`,\n },\n },\n );\n\n if (response.code !== 0 || !response.data?.image_key) {\n throw new FeishuApiError(\n `Failed to upload image: ${response.msg ?? 'unknown error'}`,\n response.code ?? -1,\n response,\n );\n }\n\n return response.data.image_key;\n }\n\n private async resolveSource(\n file: ImageSource,\n ): Promise<{ bytes: Uint8Array; filename: string }> {\n if (typeof file === 'string') {\n const buf = await readFile(file);\n return { bytes: new Uint8Array(buf), filename: basename(file) };\n }\n if (file instanceof Uint8Array) {\n // Buffer extends Uint8Array\n return { bytes: file, filename: 'image' };\n }\n throw new FeishuApiError(\n 'Unsupported image source type. Expected string path, Buffer, or Uint8Array.',\n -1,\n null,\n );\n }\n}\n","import type { ImageMessage } from '../types.js';\n\n/**\n * 构造 image 消息。\n *\n * 注意:自定义机器人直发 image 消息只认 image_key(形如 `img_xxx`)。\n * 想要直接发送本地文件,请使用 FeishuBot.sendImage() 或 FeishuBot.uploadImage()。\n */\nexport function buildImage(imageKey: string): ImageMessage {\n return {\n msg_type: 'image',\n content: {\n image_key: imageKey,\n },\n };\n}\n","import type { InteractiveCard, InteractiveMessage } from '../types.js';\n\n/**\n * 构造卡片(interactive)消息。\n *\n * 直接透传 card 结构。支持 card schema 2.0 或旧版 header/elements 格式:\n *\n * buildInteractive({\n * schema: \"2.0\",\n * header: { title: { tag: \"plain_text\", content: \"标题\" } },\n * body: { elements: [...] },\n * });\n *\n * // 或旧版:\n * buildInteractive({\n * config: { wide_screen_mode: true },\n * header: { template: \"blue\", title: { tag: \"plain_text\", content: \"标题\" } },\n * elements: [...],\n * });\n */\nexport function buildInteractive(card: InteractiveCard): InteractiveMessage {\n return {\n msg_type: 'interactive',\n card,\n };\n}\n","import type { PostContent, PostMessage } from '../types.js';\n\n/**\n * 构造富文本(post)消息。\n *\n * 用户构造 PostContent(支持 zh_cn/en_us/ja_jp 三语言),每个语言下是 `content: PostTag[][]` 的二维数组:\n * 外层是段落(行),内层是行内的标签(text/a/at/img)。\n *\n * 示例:\n * buildPost({\n * zh_cn: {\n * title: \"标题\",\n * content: [\n * [{ tag: \"text\", text: \"第一段: \" }, { tag: \"a\", text: \"点这里\", href: \"https://...\" }],\n * [{ tag: \"img\", image_key: \"img_xxx\" }],\n * ],\n * },\n * });\n */\nexport function buildPost(post: PostContent): PostMessage {\n return {\n msg_type: 'post',\n content: { post },\n };\n}\n","import type { ShareChatMessage } from '../types.js';\n\n/**\n * 构造分享群名片(share_chat)消息。\n *\n * @param shareChatId 群 chat_id(形如 `oc_xxx`)\n */\nexport function buildShareChat(shareChatId: string): ShareChatMessage {\n return {\n msg_type: 'share_chat',\n content: {\n share_chat_id: shareChatId,\n },\n };\n}\n","import type { AtOptions, TextMessage } from '../types.js';\n\n/**\n * 构造 text 消息。\n *\n * @-提醒说明(来自飞书文档):\n * - @ 所有人:`<at user_id=\"all\">所有人</at>`(仅群里能用,必须机器人所在群支持)\n * - @ 指定用户(需已知 open_id):`<at user_id=\"ou_xxx\"></at>`\n *\n * 示例:\n * buildText(\"hello\", { atAll: true })\n * // => { msg_type: \"text\", content: { text: \"hello <at user_id=\\\"all\\\">所有人</at>\" } }\n */\nexport function buildText(text: string, opts: AtOptions = {}): TextMessage {\n const parts: string[] = [];\n if (text) {\n parts.push(text);\n }\n if (opts.atUserIds && opts.atUserIds.length > 0) {\n for (const id of opts.atUserIds) {\n parts.push(`<at user_id=\"${id}\"></at>`);\n }\n }\n if (opts.atAll) {\n parts.push('<at user_id=\"all\">所有人</at>');\n }\n return {\n msg_type: 'text',\n content: {\n text: parts.join(' '),\n },\n };\n}\n","import { createHmac } from 'node:crypto';\n\n/**\n * 生成飞书自定义机器人签名。\n *\n * 算法(来自飞书官方文档,反直觉之处:HMAC 的 key 是 stringToSign 本身,data 是空字符串):\n * stringToSign = `${timestamp}\\n${secret}`\n * sign = Base64(HmacSHA256(key = stringToSign, data = ''))\n *\n * @param timestamp Unix 秒时间戳(飞书要求 ±1 小时窗口)\n * @param secret 机器人「安全设置 → 签名校验」得到的 secret\n */\nexport function genSign(timestamp: number | string, secret: string): string {\n const stringToSign = `${timestamp}\\n${secret}`;\n return createHmac('sha256', stringToSign).update('').digest('base64');\n}\n\n/**\n * 获取当前 Unix 秒时间戳。\n */\nexport function currentTimestamp(): number {\n return Math.floor(Date.now() / 1000);\n}\n","import { FeishuApiError, FeishuConfigError } from './errors.js';\nimport { postJson } from './http.js';\nimport type { TenantAccessTokenResponse } from './types.js';\n\nconst DEFAULT_BASE_URL = 'https://open.feishu.cn';\nconst TENANT_TOKEN_PATH = '/open-apis/auth/v3/tenant_access_token/internal';\n\n/** 剩余有效时间小于 30 分钟就刷新 */\nconst REFRESH_THRESHOLD_MS = 30 * 60 * 1000;\n\nexport interface TokenManagerOptions {\n appId: string;\n appSecret: string;\n fetch?: typeof fetch;\n timeout?: number;\n baseUrl?: string;\n}\n\ninterface CachedToken {\n token: string;\n expiresAt: number;\n}\n\n/**\n * tenant_access_token 缓存与自动刷新。\n * 并发去重:多次 getToken() 在 in-flight 期间共享同一个 Promise,避免重复请求。\n */\nexport class TokenManager {\n private readonly appId: string;\n private readonly appSecret: string;\n private readonly fetchImpl?: typeof fetch;\n private readonly timeout?: number;\n private readonly baseUrl: string;\n\n private cached: CachedToken | null = null;\n private inflight: Promise<string> | null = null;\n\n constructor(options: TokenManagerOptions) {\n if (!options.appId || !options.appSecret) {\n throw new FeishuConfigError(\n 'appId and appSecret are required for TokenManager',\n );\n }\n this.appId = options.appId;\n this.appSecret = options.appSecret;\n this.fetchImpl = options.fetch;\n this.timeout = options.timeout;\n this.baseUrl = options.baseUrl ?? DEFAULT_BASE_URL;\n }\n\n /**\n * 获取有效 token。优先使用缓存;过期/即将过期时刷新。\n */\n async getToken(): Promise<string> {\n if (this.isCacheFresh()) {\n return this.cached!.token;\n }\n if (this.inflight) {\n return this.inflight;\n }\n this.inflight = this.fetchToken().finally(() => {\n this.inflight = null;\n });\n return this.inflight;\n }\n\n private isCacheFresh(): boolean {\n if (!this.cached) return false;\n return this.cached.expiresAt - Date.now() > REFRESH_THRESHOLD_MS;\n }\n\n private async fetchToken(): Promise<string> {\n const url = `${this.baseUrl}${TENANT_TOKEN_PATH}`;\n const body = {\n app_id: this.appId,\n app_secret: this.appSecret,\n };\n const response = await postJson<TenantAccessTokenResponse>(url, body, {\n fetch: this.fetchImpl,\n timeout: this.timeout,\n });\n\n if (response.code !== 0 || !response.tenant_access_token) {\n throw new FeishuApiError(\n `Failed to fetch tenant_access_token: ${response.msg ?? 'unknown error'}`,\n response.code ?? -1,\n response,\n );\n }\n\n const expireSeconds = response.expire ?? 7200;\n this.cached = {\n token: response.tenant_access_token,\n expiresAt: Date.now() + expireSeconds * 1000,\n };\n return this.cached.token;\n }\n}\n","import { readEnv } from './env.js';\nimport { FeishuApiError, FeishuConfigError } from './errors.js';\nimport { postJson } from './http.js';\nimport { ImageUploader, type ImageSource } from './image-uploader.js';\nimport { buildImage } from './messages/image.js';\nimport { buildInteractive } from './messages/interactive.js';\nimport { buildPost } from './messages/post.js';\nimport { buildShareChat } from './messages/share-chat.js';\nimport { buildText } from './messages/text.js';\nimport { currentTimestamp, genSign } from './signer.js';\nimport { TokenManager } from './token-manager.js';\nimport type {\n AtOptions,\n FeishuApiResponse,\n FeishuBotOptions,\n InteractiveCard,\n MessagePayload,\n PostContent,\n SignedPayload,\n} from './types.js';\n\nconst DEFAULT_BASE_URL = 'https://open.feishu.cn';\n\n/**\n * 飞书自定义机器人 SDK 主类。\n *\n * 构造期不会报错;缺失配置时延迟到 send/upload 调用时抛出 FeishuConfigError,\n * 便于「先 new 再注入配置」的使用模式。\n *\n * 使用示例:\n * const bot = new FeishuBot(); // 从 env 读配置\n * await bot.sendText(\"hello\", { atAll: true });\n * await bot.sendImage(\"./banner.png\"); // 自动上传得到 image_key 再发送\n */\nexport class FeishuBot {\n private readonly webhook?: string;\n private readonly secret?: string;\n private readonly appId?: string;\n private readonly appSecret?: string;\n private readonly fetchImpl?: typeof fetch;\n private readonly timeout?: number;\n private readonly baseUrl: string;\n\n private tokenManager: TokenManager | null = null;\n private imageUploader: ImageUploader | null = null;\n\n constructor(options: FeishuBotOptions = {}) {\n // 合并优先级:显式参数 > env 变量 > undefined\n this.webhook = options.webhook ?? readEnv('FEISHU_BOT_WEBHOOK');\n this.secret = options.secret ?? readEnv('FEISHU_BOT_SECRET');\n this.appId = options.appId ?? readEnv('FEISHU_APP_ID');\n this.appSecret = options.appSecret ?? readEnv('FEISHU_APP_SECRET');\n this.fetchImpl = options.fetch;\n this.timeout = options.timeout;\n this.baseUrl = options.baseUrl ?? DEFAULT_BASE_URL;\n }\n\n // ---------- 原子发送 ----------\n\n /**\n * 原子发送:接收已构造好的 payload,负责注入签名并 POST 到 webhook。\n * code !== 0 时抛 FeishuApiError。\n */\n async send<T = unknown>(\n payload: MessagePayload,\n ): Promise<FeishuApiResponse<T>> {\n const webhook = this.ensureWebhook();\n const finalPayload: SignedPayload = { ...payload };\n\n if (this.secret) {\n const timestamp = currentTimestamp();\n finalPayload.timestamp = String(timestamp);\n finalPayload.sign = genSign(timestamp, this.secret);\n }\n\n const response = await postJson<FeishuApiResponse<T>>(\n webhook,\n finalPayload,\n {\n fetch: this.fetchImpl,\n timeout: this.timeout,\n },\n );\n\n // 飞书 webhook 成功时 code=0;其它数值都视为业务错误。\n if (response.code !== 0) {\n throw new FeishuApiError(\n `Feishu webhook error: ${response.msg ?? 'unknown'} (code=${response.code})`,\n response.code,\n response,\n );\n }\n\n return response;\n }\n\n // ---------- 高层便捷方法 ----------\n\n sendText(text: string, opts?: AtOptions): Promise<FeishuApiResponse> {\n return this.send(buildText(text, opts));\n }\n\n sendPost(post: PostContent): Promise<FeishuApiResponse> {\n return this.send(buildPost(post));\n }\n\n sendShareChat(shareChatId: string): Promise<FeishuApiResponse> {\n return this.send(buildShareChat(shareChatId));\n }\n\n sendInteractive(card: InteractiveCard): Promise<FeishuApiResponse> {\n return this.send(buildInteractive(card));\n }\n\n /**\n * 发送图片。智能识别三种入参:\n * - string 且以 `img_` 开头 → 直接当 image_key 使用\n * - string 否则 → 视为本地文件路径,先上传再发送\n * - Buffer / Uint8Array → 直接上传再发送\n */\n async sendImage(input: ImageSource): Promise<FeishuApiResponse> {\n let imageKey: string;\n if (typeof input === 'string' && input.startsWith('img_')) {\n imageKey = input;\n } else {\n imageKey = await this.uploadImage(input);\n }\n return this.send(buildImage(imageKey));\n }\n\n /**\n * 暴露底层图片上传,便于调用方复用 image_key。\n * 需要 appId / appSecret 配置。\n */\n async uploadImage(file: ImageSource): Promise<string> {\n const uploader = this.getImageUploader();\n return uploader.uploadImage(file);\n }\n\n // ---------- 私有:懒初始化 + 校验 ----------\n\n private ensureWebhook(): string {\n if (!this.webhook) {\n throw new FeishuConfigError(\n 'webhook is required. Provide `webhook` in options or set FEISHU_BOT_WEBHOOK env.',\n );\n }\n return this.webhook;\n }\n\n private ensureAppCredentials(): { appId: string; appSecret: string } {\n if (!this.appId || !this.appSecret) {\n throw new FeishuConfigError(\n 'appId and appSecret are required for image upload. Provide them in options or set FEISHU_APP_ID / FEISHU_APP_SECRET env.',\n );\n }\n return { appId: this.appId, appSecret: this.appSecret };\n }\n\n private getTokenManager(): TokenManager {\n if (!this.tokenManager) {\n const { appId, appSecret } = this.ensureAppCredentials();\n this.tokenManager = new TokenManager({\n appId,\n appSecret,\n fetch: this.fetchImpl,\n timeout: this.timeout,\n baseUrl: this.baseUrl,\n });\n }\n return this.tokenManager;\n }\n\n private getImageUploader(): ImageUploader {\n if (!this.imageUploader) {\n this.imageUploader = new ImageUploader({\n tokenManager: this.getTokenManager(),\n fetch: this.fetchImpl,\n timeout: this.timeout,\n baseUrl: this.baseUrl,\n });\n }\n return this.imageUploader;\n }\n}\n"],"mappings":"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"}
|
package/package.json
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@minitool/feishu-bot",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "飞书自定义机器人 SDK — 支持 text/post/image/share_chat/interactive 五种消息类型,透明处理图片上传",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "./dist/index.cjs",
|
|
7
|
+
"module": "./dist/index.js",
|
|
8
|
+
"types": "./dist/index.d.ts",
|
|
9
|
+
"exports": {
|
|
10
|
+
".": {
|
|
11
|
+
"types": "./dist/index.d.ts",
|
|
12
|
+
"import": "./dist/index.js",
|
|
13
|
+
"require": "./dist/index.cjs"
|
|
14
|
+
}
|
|
15
|
+
},
|
|
16
|
+
"files": [
|
|
17
|
+
"dist",
|
|
18
|
+
"README.md",
|
|
19
|
+
"LICENSE",
|
|
20
|
+
"CHANGELOG.md"
|
|
21
|
+
],
|
|
22
|
+
"engines": {
|
|
23
|
+
"node": ">=18"
|
|
24
|
+
},
|
|
25
|
+
"publishConfig": {
|
|
26
|
+
"access": "public"
|
|
27
|
+
},
|
|
28
|
+
"keywords": [
|
|
29
|
+
"feishu",
|
|
30
|
+
"lark",
|
|
31
|
+
"bot",
|
|
32
|
+
"webhook",
|
|
33
|
+
"sdk",
|
|
34
|
+
"飞书",
|
|
35
|
+
"机器人"
|
|
36
|
+
],
|
|
37
|
+
"author": "hidumou",
|
|
38
|
+
"license": "MIT",
|
|
39
|
+
"repository": {
|
|
40
|
+
"type": "git",
|
|
41
|
+
"url": "https://github.com/hidumou/feishu-bot.git"
|
|
42
|
+
},
|
|
43
|
+
"bugs": {
|
|
44
|
+
"url": "https://github.com/hidumou/feishu-bot/issues"
|
|
45
|
+
},
|
|
46
|
+
"homepage": "https://github.com/hidumou/feishu-bot#readme",
|
|
47
|
+
"devDependencies": {
|
|
48
|
+
"@release-it/conventional-changelog": "^10.0.6",
|
|
49
|
+
"@types/node": "^25.5.2",
|
|
50
|
+
"release-it": "^19.2.4",
|
|
51
|
+
"typescript": "^6.0.2",
|
|
52
|
+
"vite": "^8.0.7",
|
|
53
|
+
"vite-plugin-dts": "^4.5.4",
|
|
54
|
+
"vitest": "^4.1.3"
|
|
55
|
+
},
|
|
56
|
+
"scripts": {
|
|
57
|
+
"build": "vite build",
|
|
58
|
+
"dev": "vite build --watch",
|
|
59
|
+
"test": "vitest run",
|
|
60
|
+
"test:watch": "vitest",
|
|
61
|
+
"typecheck": "tsc --noEmit",
|
|
62
|
+
"release": "release-it"
|
|
63
|
+
}
|
|
64
|
+
}
|