@minitool/feishu-bot 0.1.0 → 0.2.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 CHANGED
@@ -2,6 +2,27 @@
2
2
 
3
3
  本项目遵循 [Keep a Changelog](https://keepachangelog.com/zh-CN/1.1.0/) 与 [Semantic Versioning](https://semver.org/lang/zh-CN/)。
4
4
 
5
+ ## [0.2.0] - 2026-04-09
6
+
7
+ ### Added
8
+
9
+ - **同构(isomorphic)支持** 🎉 同一份产物 `dist/index.js` 可在 Node 18+、现代浏览器、Service Worker、Chrome MV3 扩展 background SW、Cloudflare Workers、Deno、Bun 等运行时直接运行
10
+ - `ImageSource` 新增 `Blob` / `File` 支持,浏览器与 SW 推荐用法:`bot.sendImage(await fetch(url).then(r => r.blob()))`
11
+ - `TokenStorage` 适配器接口与 `CachedToken` 公开类型:可注入 `chrome.storage.session`、Redis、KV 等外部存储,让 `tenant_access_token` 在跨进程/跨重启(如 MV3 SW 被杀)时复用,避免冷启动消耗频次
12
+ - `package.json` exports map 新增 `browser` / `worker` 条件,并增加顶层 `browser` 字段
13
+ - README 新增「在浏览器 / Service Worker / 浏览器扩展 (MV3) 中使用」专章,含完整 MV3 manifest 与 `chrome.storage.session` 适配器示例
14
+ - 测试覆盖 +7:`sendImage(Blob)` 字节级 round-trip、`sendImage(File)` 文件名保留、`tokenStorage` 透传到 `TokenManager`、storage 命中跳过网络、stale 时回退网络、storage.get 抛错降级、storage.set 失败不抛
15
+
16
+ ### Changed
17
+
18
+ - 签名实现从 `node:crypto` 切换到 WebCrypto (`crypto.subtle`),彻底移除 `node:crypto` 依赖;Node 18+ / 浏览器 / SW 都走同一条路径
19
+ - `image-uploader` 中 `node:fs/promises` / `node:path` 的引用通过 `new Function('return import(...)')` 隐藏,避免被浏览器/扩展打包器静态分析为不可解析依赖;文件路径分支仍然只在 Node 可用,浏览器/SW 中传 string 路径会抛 `FeishuConfigError`
20
+ - 构建 `target` 从 `node18` 改为 `es2022`
21
+
22
+ ### Breaking
23
+
24
+ - `genSign(timestamp, secret)` 现在返回 `Promise<string>`(之前是同步 `string`)。原因:WebCrypto API 仅提供异步接口。使用 `FeishuBot` 高层方法(`sendText` / `sendImage` 等)的用户**不受影响**;直接 import 并调用 `genSign` 的用户需要加 `await`
25
+
5
26
  ## [0.1.0] - 2026-04-09
6
27
 
7
28
  ### Added
package/README.md CHANGED
@@ -1,12 +1,18 @@
1
1
  # @minitool/feishu-bot
2
2
 
3
- > 轻量、零运行时依赖、TypeScript 优先的飞书自定义机器人 SDK。
3
+ [![npm version](https://img.shields.io/npm/v/@minitool/feishu-bot.svg?logo=npm)](https://www.npmjs.com/package/@minitool/feishu-bot)
4
+ [![npm downloads](https://img.shields.io/npm/dm/@minitool/feishu-bot.svg)](https://www.npmjs.com/package/@minitool/feishu-bot)
5
+ [![GitHub release](https://img.shields.io/github/v/release/hidumou/feishu-bot.svg?logo=github)](https://github.com/hidumou/feishu-bot/releases)
6
+ [![License](https://img.shields.io/npm/l/@minitool/feishu-bot.svg)](./LICENSE)
7
+
8
+ > 轻量、零运行时依赖、TypeScript 优先、**真正同构**的飞书自定义机器人 SDK。
4
9
 
5
10
  - ✅ 支持全部 5 种消息类型:`text` / `post` / `image` / `share_chat` / `interactive`
6
- - ✅ 透明处理图片上传:`sendImage('./local.png')` 自动走 `im/v1/images` 接口取 `image_key` 再发送
7
- - ✅ 自动注入签名(HMAC-SHA256)
8
- - ✅ `tenant_access_token` 自动缓存与刷新
9
- - ✅ 仅依赖 Node 18+ 内置 `fetch` / `FormData` / `Blob` / `node:crypto` / `node:fs/promises`,零运行时依赖
11
+ - ✅ 透明处理图片上传:`sendImage(blob)` / `sendImage('./local.png')` 自动走 `im/v1/images` 接口取 `image_key` 再发送
12
+ - ✅ 自动注入签名(HMAC-SHA256,基于 WebCrypto
13
+ - ✅ `tenant_access_token` 自动缓存与刷新,可选注入 `TokenStorage` 适配器(适合 MV3 SW 跨重启复用)
14
+ - ✅ **同构**:同一个 bundle 在 Node 18+ / 浏览器 / Service Worker / 浏览器扩展 SW (MV3) / Cloudflare Workers / Deno / Bun 都能跑
15
+ - ✅ 零运行时依赖,仅使用各运行时内置的 `fetch` / `FormData` / `Blob` / `crypto.subtle`
10
16
  - ✅ 构造期不抛错,便于「先 new 再注入配置」
11
17
 
12
18
  ## 安装
@@ -17,7 +23,8 @@ pnpm add @minitool/feishu-bot
17
23
  npm install @minitool/feishu-bot
18
24
  ```
19
25
 
20
- 要求 Node.js ≥ 18。
26
+ **运行时要求**:Node.js ≥ 18 / Chrome ≥ 89 / Firefox ≥ 90 / Safari ≥ 15 / Cloudflare Workers / Deno / Bun
27
+ 凡是支持 `fetch` + `WebCrypto (crypto.subtle)` + `FormData` + `Blob` 的运行时都能用。
21
28
 
22
29
  ## 快速开始
23
30
 
@@ -49,6 +56,7 @@ await bot.sendText('Hello 飞书!');
49
56
  | `fetch` | — | 可选 | 注入自定义 fetch,测试用 |
50
57
  | `timeout` | — | 可选 | 请求超时,单位毫秒,默认 `10000` |
51
58
  | `baseUrl` | — | 可选 | 飞书开放平台基础 URL,默认 `https://open.feishu.cn` |
59
+ | `tokenStorage` | — | 可选 | `TokenStorage` 适配器;用于让 `tenant_access_token` 在跨进程/跨重启时复用,详见下方「浏览器扩展 SW」小节 |
52
60
 
53
61
  > SDK 本身不引入 `dotenv`。如果你想用 `.env` 文件,可以通过 `node --env-file=.env app.js`(Node 20.6+)或在项目 devDep 里装 `dotenv` 自行预加载。
54
62
 
@@ -93,20 +101,26 @@ await bot.sendPost({
93
101
  // 1. 已有 image_key(以 `img_` 开头)→ 直发
94
102
  await bot.sendImage('img_v2_041b28e3-xxx');
95
103
 
96
- // 2. 本地文件路径 → 自动上传再发(需要 appId/appSecret)
104
+ // 2. 本地文件路径 → 自动上传再发(仅 Node,需要 appId/appSecret)
97
105
  await bot.sendImage('./screenshot.png');
98
106
 
99
- // 3. Buffer / Uint8Array → 自动上传再发
107
+ // 3. Buffer / Uint8Array → 自动上传再发(同构)
100
108
  import { readFile } from 'node:fs/promises';
101
109
  const buf = await readFile('./screenshot.png');
102
110
  await bot.sendImage(buf);
103
111
 
112
+ // 4. Blob / File → 自动上传再发(浏览器 / SW / 扩展首选)
113
+ const resp = await fetch('https://example.com/banner.png');
114
+ await bot.sendImage(await resp.blob());
115
+
104
116
  // 也可以只拿 image_key,稍后自己复用
105
117
  const imageKey = await bot.uploadImage('./screenshot.png');
106
118
  await bot.sendImage(imageKey);
107
119
  ```
108
120
 
109
121
  > ⚠️ 图片上传需要自建应用的 App ID / App Secret,因为飞书 `im/v1/images` 接口要求 `tenant_access_token` 授权。
122
+ >
123
+ > ℹ️ 在浏览器 / SW / 扩展中,**只能用 `Blob` / `File` / `Uint8Array`**——传字符串路径会抛 `FeishuConfigError`。
110
124
 
111
125
  ### share_chat 分享群名片
112
126
 
@@ -181,7 +195,7 @@ sign = Base64(HmacSHA256(key = stringToSign, data = ''))
181
195
  | `send(payload)` | 原子发送,接受已构造好的 `MessagePayload` |
182
196
  | `sendText(text, { atUserIds?, atAll? })` | 文本消息 |
183
197
  | `sendPost(post)` | 富文本 |
184
- | `sendImage(input)` | 图片:`string`(`img_` 前缀→直发 / 其它→路径上传)、`Buffer`、`Uint8Array` |
198
+ | `sendImage(input)` | 图片:`string`(`img_` 前缀→直发 / 其它→路径上传,仅 Node)、`Buffer`、`Uint8Array`、`Blob`、`File` |
185
199
  | `sendShareChat(shareChatId)` | 分享群名片 |
186
200
  | `sendInteractive(card)` | 卡片 |
187
201
  | `uploadImage(file)` | 单独上传图片,返回 `image_key` |
@@ -199,6 +213,97 @@ const payload = buildText('hi', { atAll: true });
199
213
  // => { msg_type: 'text', content: { text: 'hi <at user_id="all">所有人</at>' } }
200
214
  ```
201
215
 
216
+ ## 在浏览器 / Service Worker / 浏览器扩展 (MV3) 中使用
217
+
218
+ 本 SDK 是真正同构的 —— 同一个 `dist/index.js` 可以直接在以下环境运行:Node 18+、现代浏览器主线程、Web/Service Worker、Chrome MV3 扩展 background SW、Cloudflare Workers、Deno、Bun。
219
+
220
+ ### 关键差异
221
+
222
+ | 能力 | Node | 浏览器主线程 | MV3 SW |
223
+ |---|:---:|:---:|:---:|
224
+ | 文本 / 富文本 / 卡片 / 群名片 | ✅ | ⚠️ CORS¹ | ✅ |
225
+ | 图片上传:`Blob` / `File` / `Uint8Array` | ✅ | ⚠️ CORS¹ | ✅ |
226
+ | 图片上传:本地文件路径 string | ✅ | ❌ | ❌ |
227
+ | `tenant_access_token` 跨重启复用 | 进程内即可 | localStorage 等 | ✅ 推荐 `chrome.storage.session` |
228
+
229
+ ¹ 浏览器主线程直连 `open.feishu.cn` 会被 CORS 拦截。**MV3 SW 不受 CORS 约束**,只要 `manifest.json` 里声明了 `host_permissions` 即可。
230
+
231
+ ### Chrome MV3 扩展示例
232
+
233
+ `manifest.json`:
234
+
235
+ ```json
236
+ {
237
+ "manifest_version": 3,
238
+ "name": "My Extension",
239
+ "version": "1.0.0",
240
+ "background": {
241
+ "service_worker": "background.js",
242
+ "type": "module"
243
+ },
244
+ "host_permissions": ["https://open.feishu.cn/*"],
245
+ "permissions": ["storage"]
246
+ }
247
+ ```
248
+
249
+ `background.ts`(用 Vite + `@crxjs/vite-plugin` 或 webpack 打包成 `background.js`):
250
+
251
+ ```ts
252
+ import { FeishuBot, type TokenStorage } from '@minitool/feishu-bot';
253
+
254
+ // MV3 SW 空闲 ~30s 就会被杀,内存里的 token 会丢。
255
+ // 注入 chrome.storage.session 适配器,让 token 在 SW 重启间存活。
256
+ const tokenStorage: TokenStorage = {
257
+ async get() {
258
+ const { feishuToken } = await chrome.storage.session.get('feishuToken');
259
+ return feishuToken ?? null;
260
+ },
261
+ async set(value) {
262
+ await chrome.storage.session.set({ feishuToken: value });
263
+ },
264
+ };
265
+
266
+ const bot = new FeishuBot({
267
+ webhook: 'https://open.feishu.cn/open-apis/bot/v2/hook/xxxxxxxx',
268
+ secret: 'your-secret', // 可选
269
+ appId: 'cli_xxx', // 仅图片上传需要
270
+ appSecret: 'xxx',
271
+ tokenStorage, // ← 关键
272
+ });
273
+
274
+ // 文本
275
+ await bot.sendText('hello from extension');
276
+
277
+ // 图片:从网络拉一个 Blob 直接发
278
+ const resp = await fetch('https://example.com/banner.png');
279
+ await bot.sendImage(await resp.blob());
280
+
281
+ // 或从 OffscreenCanvas
282
+ const blob = await offscreenCanvas.convertToBlob();
283
+ await bot.sendImage(blob);
284
+ ```
285
+
286
+ ### `TokenStorage` 接口
287
+
288
+ ```ts
289
+ interface CachedToken {
290
+ token: string;
291
+ /** Unix 毫秒时间戳 */
292
+ expiresAt: number;
293
+ }
294
+
295
+ interface TokenStorage {
296
+ /** 没有缓存或读失败时返回 null */
297
+ get(): Promise<CachedToken | null>;
298
+ /** 写入新的 token;写失败不应抛 */
299
+ set(value: CachedToken): Promise<void>;
300
+ }
301
+ ```
302
+
303
+ `TokenManager` 内部按以下顺序查找:**内存缓存 → `TokenStorage` → 网络**。`storage` 抛任何异常都会被吞掉并降级到下一层,永不阻塞主流程。
304
+
305
+ > 同样的适配器接口也可以用于 Cloudflare Workers KV、Redis、文件系统、Deno KV 等任何外部存储。
306
+
202
307
  ## 频控与限制
203
308
 
204
309
  飞书官方规则(每个机器人独立计数):
@@ -209,13 +314,6 @@ const payload = buildText('hi', { atAll: true });
209
314
 
210
315
  SDK 不做内置限流;请在调用方按需排队或节流。
211
316
 
212
- ## Roadmap
213
-
214
- 下面是计划在 v0.2 加入的特性(当前版本已评估但延后):
215
-
216
- - **更细粒度的错误类型**:在 `FeishuApiError` 之上拆分 `FeishuNetworkError` / `FeishuTimeoutError` / `FeishuHttpError` 子类(或在当前类上加 `kind` 字段),便于调用方区分超时、网络抖动、HTTP 状态码错误与业务 code。
217
- - **图片上传自定义元数据**:`uploadImage` / `sendImage` 支持 `{ filename, contentType }` 选项,用于 Buffer/Uint8Array 入参时指定文件名与 MIME。
218
-
219
317
  ## 许可
220
318
 
221
319
  MIT © hidumou
package/dist/index.cjs CHANGED
@@ -1,7 +1,4 @@
1
1
  Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
2
- let node_fs_promises = require("node:fs/promises");
3
- let node_path = require("node:path");
4
- let node_crypto = require("node:crypto");
5
2
  //#region src/env.ts
6
3
  /**
7
4
  * 安全读取 process.env。在不存在 process 的环境(如浏览器)里返回 undefined,不会崩溃。
@@ -132,9 +129,11 @@ var DEFAULT_BASE_URL$2 = "https://open.feishu.cn";
132
129
  var UPLOAD_PATH = "/open-apis/im/v1/images";
133
130
  /**
134
131
  * 图片上传器:调用 im/v1/images 接口,返回 image_key。
135
- * - string: 作为文件路径用 fs/promises.readFile 读成 Buffer
136
- * - Buffer/Uint8Array: 直接作为 Blob 数据
137
- * globalThis FormData + Blob(Node 18+ 内置),不依赖 form-data 包。
132
+ *
133
+ * 同构设计:
134
+ * - Blob / Uint8Array 分支在 Node 18+ / 浏览器 / Service Worker 都能跑
135
+ * - string 路径分支仅在 Node 可用,通过 new Function 隐藏 node:fs/promises 的
136
+ * 静态引用,让浏览器/扩展打包器(Vite/Webpack/esbuild)不会因为找不到模块而报错
138
137
  */
139
138
  var ImageUploader = class {
140
139
  tokenManager;
@@ -166,20 +165,44 @@ var ImageUploader = class {
166
165
  return response.data.image_key;
167
166
  }
168
167
  async resolveSource(file) {
169
- if (typeof file === "string") {
170
- const buf = await (0, node_fs_promises.readFile)(file);
168
+ if (typeof Blob !== "undefined" && file instanceof Blob) {
169
+ const buf = await file.arrayBuffer();
170
+ const filename = file.name ?? "image";
171
171
  return {
172
172
  bytes: new Uint8Array(buf),
173
- filename: (0, node_path.basename)(file)
173
+ filename
174
174
  };
175
175
  }
176
176
  if (file instanceof Uint8Array) return {
177
177
  bytes: file,
178
178
  filename: "image"
179
179
  };
180
- throw new FeishuApiError("Unsupported image source type. Expected string path, Buffer, or Uint8Array.", -1, null);
180
+ if (typeof file === "string") {
181
+ if (typeof process === "undefined" || !process.versions?.node) throw new FeishuConfigError("String file path is only supported in Node.js. In browsers or Service Workers, pass a Blob, File, or Uint8Array instead.");
182
+ return loadFromFilePath(file);
183
+ }
184
+ throw new FeishuApiError("Unsupported image source type. Expected string path, Uint8Array, or Blob.", -1, null);
181
185
  }
182
186
  };
187
+ /**
188
+ * 从文件路径读取字节(仅 Node)。
189
+ *
190
+ * 关键技巧:用 `new Function` 包裹 dynamic import 字符串,让 Vite / Webpack / esbuild
191
+ * 等打包器无法静态分析这两个 node:* import,从而不会在浏览器/扩展产物里报「找不到模块」。
192
+ *
193
+ * 这条代码路径在浏览器/SW 中永远不可达(resolveSource 已经在 typeof process 处抛错了),
194
+ * 所以静态引用即使被打入 bundle 也不会被执行。
195
+ */
196
+ async function loadFromFilePath(filePath) {
197
+ const importFs = new Function("return import(\"node:fs/promises\")");
198
+ const importPath = new Function("return import(\"node:path\")");
199
+ const [fs, pathMod] = await Promise.all([importFs(), importPath()]);
200
+ const buf = await fs.readFile(filePath);
201
+ return {
202
+ bytes: new Uint8Array(buf),
203
+ filename: pathMod.basename(filePath)
204
+ };
205
+ }
183
206
  //#endregion
184
207
  //#region src/messages/image.ts
185
208
  /**
@@ -284,17 +307,34 @@ function buildText(text, opts = {}) {
284
307
  //#endregion
285
308
  //#region src/signer.ts
286
309
  /**
287
- * 生成飞书自定义机器人签名。
310
+ * 生成飞书自定义机器人签名(同构实现,使用 WebCrypto)。
288
311
  *
289
312
  * 算法(来自飞书官方文档,反直觉之处:HMAC 的 key 是 stringToSign 本身,data 是空字符串):
290
313
  * stringToSign = `${timestamp}\n${secret}`
291
314
  * sign = Base64(HmacSHA256(key = stringToSign, data = ''))
292
315
  *
316
+ * 仅依赖 globalThis.crypto.subtle,因此在以下环境均可运行:
317
+ * - Node 18+(原生 WebCrypto)
318
+ * - 浏览器主线程
319
+ * - Service Worker / 浏览器扩展 Service Worker
320
+ * - Cloudflare Workers / Deno / Bun
321
+ *
322
+ * ⚠️ 破坏性变更(v0.1 → v0.2):返回 Promise,而非同步字符串。
323
+ *
293
324
  * @param timestamp Unix 秒时间戳(飞书要求 ±1 小时窗口)
294
325
  * @param secret 机器人「安全设置 → 签名校验」得到的 secret
295
326
  */
296
- function genSign(timestamp, secret) {
297
- return (0, node_crypto.createHmac)("sha256", `${timestamp}\n${secret}`).update("").digest("base64");
327
+ async function genSign(timestamp, secret) {
328
+ const subtle = globalThis.crypto?.subtle;
329
+ if (!subtle) throw new Error("WebCrypto (globalThis.crypto.subtle) is not available. Use Node.js >= 18, a modern browser, or a Service Worker context.");
330
+ const stringToSign = `${timestamp}\n${secret}`;
331
+ const keyData = new TextEncoder().encode(stringToSign);
332
+ const cryptoKey = await subtle.importKey("raw", keyData, {
333
+ name: "HMAC",
334
+ hash: "SHA-256"
335
+ }, false, ["sign"]);
336
+ const signature = await subtle.sign("HMAC", cryptoKey, new Uint8Array(0));
337
+ return bytesToBase64(new Uint8Array(signature));
298
338
  }
299
339
  /**
300
340
  * 获取当前 Unix 秒时间戳。
@@ -302,6 +342,15 @@ function genSign(timestamp, secret) {
302
342
  function currentTimestamp() {
303
343
  return Math.floor(Date.now() / 1e3);
304
344
  }
345
+ /**
346
+ * Uint8Array → base64。
347
+ * 不依赖 Node Buffer,浏览器/SW/Node 18+ 都有 btoa。
348
+ */
349
+ function bytesToBase64(bytes) {
350
+ let bin = "";
351
+ for (let i = 0; i < bytes.length; i++) bin += String.fromCharCode(bytes[i]);
352
+ return btoa(bin);
353
+ }
305
354
  //#endregion
306
355
  //#region src/token-manager.ts
307
356
  var DEFAULT_BASE_URL$1 = "https://open.feishu.cn";
@@ -310,6 +359,12 @@ var TENANT_TOKEN_PATH = "/open-apis/auth/v3/tenant_access_token/internal";
310
359
  var REFRESH_THRESHOLD_MS = 1800 * 1e3;
311
360
  /**
312
361
  * tenant_access_token 缓存与自动刷新。
362
+ *
363
+ * 三层缓存查找顺序:
364
+ * 1. 内存(最快)
365
+ * 2. 注入的 TokenStorage(跨进程/跨 SW 重启)
366
+ * 3. 网络获取
367
+ *
313
368
  * 并发去重:多次 getToken() 在 in-flight 期间共享同一个 Promise,避免重复请求。
314
369
  */
315
370
  var TokenManager = class {
@@ -318,6 +373,7 @@ var TokenManager = class {
318
373
  fetchImpl;
319
374
  timeout;
320
375
  baseUrl;
376
+ storage;
321
377
  cached = null;
322
378
  inflight = null;
323
379
  constructor(options) {
@@ -327,21 +383,36 @@ var TokenManager = class {
327
383
  this.fetchImpl = options.fetch;
328
384
  this.timeout = options.timeout;
329
385
  this.baseUrl = options.baseUrl ?? DEFAULT_BASE_URL$1;
386
+ this.storage = options.storage;
330
387
  }
331
388
  /**
332
- * 获取有效 token。优先使用缓存;过期/即将过期时刷新。
389
+ * 获取有效 token。优先内存缓存;过期/即将过期时尝试 storage,最后回退到网络。
333
390
  */
334
391
  async getToken() {
335
- if (this.isCacheFresh()) return this.cached.token;
392
+ if (this.isFresh(this.cached)) return this.cached.token;
336
393
  if (this.inflight) return this.inflight;
337
- this.inflight = this.fetchToken().finally(() => {
394
+ this.inflight = this.refreshToken().finally(() => {
338
395
  this.inflight = null;
339
396
  });
340
397
  return this.inflight;
341
398
  }
342
- isCacheFresh() {
343
- if (!this.cached) return false;
344
- return this.cached.expiresAt - Date.now() > REFRESH_THRESHOLD_MS;
399
+ isFresh(entry) {
400
+ if (!entry) return false;
401
+ return entry.expiresAt - Date.now() > REFRESH_THRESHOLD_MS;
402
+ }
403
+ /**
404
+ * 刷新流程:先尝试 storage(若注入),不可用则走网络。
405
+ * storage 异常一律视为「miss」,回退到网络,避免单点故障阻塞主流程。
406
+ */
407
+ async refreshToken() {
408
+ if (this.storage) try {
409
+ const stored = await this.storage.get();
410
+ if (this.isFresh(stored)) {
411
+ this.cached = stored;
412
+ return stored.token;
413
+ }
414
+ } catch {}
415
+ return this.fetchToken();
345
416
  }
346
417
  async fetchToken() {
347
418
  const response = await postJson(`${this.baseUrl}${TENANT_TOKEN_PATH}`, {
@@ -357,6 +428,9 @@ var TokenManager = class {
357
428
  token: response.tenant_access_token,
358
429
  expiresAt: Date.now() + expireSeconds * 1e3
359
430
  };
431
+ if (this.storage) try {
432
+ await this.storage.set(this.cached);
433
+ } catch {}
360
434
  return this.cached.token;
361
435
  }
362
436
  };
@@ -382,6 +456,7 @@ var FeishuBot = class {
382
456
  fetchImpl;
383
457
  timeout;
384
458
  baseUrl;
459
+ tokenStorage;
385
460
  tokenManager = null;
386
461
  imageUploader = null;
387
462
  constructor(options = {}) {
@@ -392,6 +467,7 @@ var FeishuBot = class {
392
467
  this.fetchImpl = options.fetch;
393
468
  this.timeout = options.timeout;
394
469
  this.baseUrl = options.baseUrl ?? DEFAULT_BASE_URL;
470
+ this.tokenStorage = options.tokenStorage;
395
471
  }
396
472
  /**
397
473
  * 原子发送:接收已构造好的 payload,负责注入签名并 POST 到 webhook。
@@ -403,7 +479,7 @@ var FeishuBot = class {
403
479
  if (this.secret) {
404
480
  const timestamp = currentTimestamp();
405
481
  finalPayload.timestamp = String(timestamp);
406
- finalPayload.sign = genSign(timestamp, this.secret);
482
+ finalPayload.sign = await genSign(timestamp, this.secret);
407
483
  }
408
484
  const response = await postJson(webhook, finalPayload, {
409
485
  fetch: this.fetchImpl,
@@ -462,7 +538,8 @@ var FeishuBot = class {
462
538
  appSecret,
463
539
  fetch: this.fetchImpl,
464
540
  timeout: this.timeout,
465
- baseUrl: this.baseUrl
541
+ baseUrl: this.baseUrl,
542
+ storage: this.tokenStorage
466
543
  });
467
544
  }
468
545
  return this.tokenManager;
@@ -1 +1 @@
1
- {"version":3,"file":"index.cjs","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":";;;;;;;;;AAIA,SAAgB,QAAQ,KAAiC;AACvD,KAAI,OAAO,YAAY,eAAe,CAAC,QAAQ,IAC7C;CAGF,MAAM,QAAQ,QAAQ,IAAI;AAC1B,KAAI,UAAU,KAAA,KAAa,UAAU,GACnC;AAEF,QAAO;;;;;;;ACVT,IAAa,iBAAb,cAAoC,MAAM;CACxC,YAAY,SAAiB;AAC3B,QAAM,QAAQ;AACd,OAAK,OAAO;AAEZ,SAAO,eAAe,MAAM,IAAI,OAAO,UAAU;;;;;;;AAQrD,IAAa,oBAAb,cAAuC,eAAe;CACpD,YAAY,SAAiB;AAC3B,QAAM,QAAQ;AACd,OAAK,OAAO;;;;;;AAOhB,IAAa,iBAAb,cAAoC,eAAe;CACjD;CACA;CAEA,YAAY,SAAiB,MAAc,UAAmB;AAC5D,QAAM,QAAQ;AACd,OAAK,OAAO;AACZ,OAAK,OAAO;AACZ,OAAK,WAAW;;;;;ACvBpB,IAAM,kBAAkB;AASxB,SAAS,aAAa,aAA0C;CAC9D,MAAM,KAAK,eAAe,WAAW;AACrC,KAAI,OAAO,OAAO,WAChB,OAAM,IAAI,eACR,sFACA,IACA,KACD;AAEH,QAAO;;;;;;;AAQT,eAAe,QACb,KACA,MACA,UAA0B,EAAE,EACN;CACtB,MAAM,YAAY,aAAa,QAAQ,MAAM;CAC7C,MAAM,UAAU,QAAQ,WAAW;CAEnC,MAAM,aAAa,IAAI,iBAAiB;CACxC,MAAM,QAAQ,iBAAiB,WAAW,OAAO,EAAE,QAAQ;AAE3D,KAAI;EACF,MAAM,WAAW,MAAM,UAAU,KAAK;GACpC,GAAG;GACH,QAAQ,WAAW;GACpB,CAAC;EAEF,MAAM,OAAO,MAAM,SAAS,MAAM;AAClC,SAAO;GACL,QAAQ,SAAS;GACjB,YAAY,SAAS;GACrB,IAAI,SAAS;GACb;GACD;UACM,KAAK;AACZ,MAAI,eAAe,SAAS,IAAI,SAAS,aACvC,OAAM,IAAI,eACR,2BAA2B,QAAQ,MAAM,OACzC,IACA,KACD;AAEH,MAAI,eAAe,eACjB,OAAM;AAGR,QAAM,IAAI,eAAe,kBADT,eAAe,QAAQ,IAAI,UAAU,OAAO,IAAI,IACV,IAAI,KAAK;WACvD;AACR,eAAa,MAAM;;;AAIvB,SAAS,cAAiB,KAAqB;AAC7C,KAAI,CAAC,IAAI,KACP,OAAM,IAAI,eACR,6BAA6B,IAAI,OAAO,IACxC,IACA,KACD;AAEH,KAAI;AACF,SAAO,KAAK,MAAM,IAAI,KAAK;SACrB;AACN,QAAM,IAAI,eACR,uCAAuC,IAAI,OAAO,KAAK,IAAI,KAAK,MAAM,GAAG,IAAI,IAC7E,IACA,IAAI,KACL;;;AAIL,SAAS,iBAAiB,KAAwB;AAChD,KAAI,CAAC,IAAI,GACP,OAAM,IAAI,eACR,QAAQ,IAAI,OAAO,GAAG,IAAI,WAAW,IAAI,IAAI,KAAK,MAAM,GAAG,IAAI,IAC/D,IAAI,QACJ,IAAI,KACL;;;;;;AAQL,eAAsB,SACpB,KACA,MACA,UAA0B,EAAE,EAChB;CACZ,MAAM,MAAM,MAAM,QAChB,KACA;EACE,QAAQ;EACR,SAAS;GACP,gBAAgB;GAChB,GAAG,QAAQ;GACZ;EACD,MAAM,KAAK,UAAU,KAAK;EAC3B,EACD,QACD;AAED,kBAAiB,IAAI;AACrB,QAAO,cAAiB,IAAI;;;;;;AAO9B,eAAsB,SACpB,KACA,MACA,UAA0B,EAAE,EAChB;CACZ,MAAM,MAAM,MAAM,QAChB,KACA;EACE,QAAQ;EACR,SAAS,EACP,GAAG,QAAQ,SACZ;EACD,MAAM;EACP,EACD,QACD;AAED,kBAAiB,IAAI;AACrB,QAAO,cAAiB,IAAI;;;;ACpJ9B,IAAM,qBAAmB;AACzB,IAAM,cAAc;;;;;;;AAkBpB,IAAa,gBAAb,MAA2B;CACzB;CACA;CACA;CACA;CAEA,YAAY,SAA+B;AACzC,OAAK,eAAe,QAAQ;AAC5B,OAAK,YAAY,QAAQ;AACzB,OAAK,UAAU,QAAQ;AACvB,OAAK,UAAU,QAAQ,WAAW;;;;;CAMpC,MAAM,YAAY,MAAoC;EACpD,MAAM,EAAE,OAAO,aAAa,MAAM,KAAK,cAAc,KAAK;EAC1D,MAAM,QAAQ,MAAM,KAAK,aAAa,UAAU;EAEhD,MAAM,OAAO,IAAI,UAAU;AAC3B,OAAK,OAAO,cAAc,UAAU;EAGpC,MAAM,OAAO,IAAI,KAAK,CAAC,MAAM,OAAO,CAAC,EAAE,EACrC,MAAM,4BACP,CAAC;AACF,OAAK,OAAO,SAAS,MAAM,SAAS;EAGpC,MAAM,WAAW,MAAM,SADX,GAAG,KAAK,UAAU,eAG5B,MACA;GACE,OAAO,KAAK;GACZ,SAAS,KAAK;GACd,SAAS,EACP,eAAe,UAAU,SAC1B;GACF,CACF;AAED,MAAI,SAAS,SAAS,KAAK,CAAC,SAAS,MAAM,UACzC,OAAM,IAAI,eACR,2BAA2B,SAAS,OAAO,mBAC3C,SAAS,QAAQ,IACjB,SACD;AAGH,SAAO,SAAS,KAAK;;CAGvB,MAAc,cACZ,MACkD;AAClD,MAAI,OAAO,SAAS,UAAU;GAC5B,MAAM,MAAM,OAAA,GAAA,iBAAA,UAAe,KAAK;AAChC,UAAO;IAAE,OAAO,IAAI,WAAW,IAAI;IAAE,WAAA,GAAA,UAAA,UAAmB,KAAK;IAAE;;AAEjE,MAAI,gBAAgB,WAElB,QAAO;GAAE,OAAO;GAAM,UAAU;GAAS;AAE3C,QAAM,IAAI,eACR,+EACA,IACA,KACD;;;;;;;;;;;ACvFL,SAAgB,WAAW,UAAgC;AACzD,QAAO;EACL,UAAU;EACV,SAAS,EACP,WAAW,UACZ;EACF;;;;;;;;;;;;;;;;;;;;;;ACMH,SAAgB,iBAAiB,MAA2C;AAC1E,QAAO;EACL,UAAU;EACV;EACD;;;;;;;;;;;;;;;;;;;;;ACLH,SAAgB,UAAU,MAAgC;AACxD,QAAO;EACL,UAAU;EACV,SAAS,EAAE,MAAM;EAClB;;;;;;;;;AChBH,SAAgB,eAAe,aAAuC;AACpE,QAAO;EACL,UAAU;EACV,SAAS,EACP,eAAe,aAChB;EACF;;;;;;;;;;;;;;;ACAH,SAAgB,UAAU,MAAc,OAAkB,EAAE,EAAe;CACzE,MAAM,QAAkB,EAAE;AAC1B,KAAI,KACF,OAAM,KAAK,KAAK;AAElB,KAAI,KAAK,aAAa,KAAK,UAAU,SAAS,EAC5C,MAAK,MAAM,MAAM,KAAK,UACpB,OAAM,KAAK,gBAAgB,GAAG,SAAS;AAG3C,KAAI,KAAK,MACP,OAAM,KAAK,+BAA6B;AAE1C,QAAO;EACL,UAAU;EACV,SAAS,EACP,MAAM,MAAM,KAAK,IAAI,EACtB;EACF;;;;;;;;;;;;;;ACnBH,SAAgB,QAAQ,WAA4B,QAAwB;AAE1E,SAAA,GAAA,YAAA,YAAkB,UADG,GAAG,UAAU,IAAI,SACG,CAAC,OAAO,GAAG,CAAC,OAAO,SAAS;;;;;AAMvE,SAAgB,mBAA2B;AACzC,QAAO,KAAK,MAAM,KAAK,KAAK,GAAG,IAAK;;;;ACjBtC,IAAM,qBAAmB;AACzB,IAAM,oBAAoB;;AAG1B,IAAM,uBAAuB,OAAU;;;;;AAmBvC,IAAa,eAAb,MAA0B;CACxB;CACA;CACA;CACA;CACA;CAEA,SAAqC;CACrC,WAA2C;CAE3C,YAAY,SAA8B;AACxC,MAAI,CAAC,QAAQ,SAAS,CAAC,QAAQ,UAC7B,OAAM,IAAI,kBACR,oDACD;AAEH,OAAK,QAAQ,QAAQ;AACrB,OAAK,YAAY,QAAQ;AACzB,OAAK,YAAY,QAAQ;AACzB,OAAK,UAAU,QAAQ;AACvB,OAAK,UAAU,QAAQ,WAAW;;;;;CAMpC,MAAM,WAA4B;AAChC,MAAI,KAAK,cAAc,CACrB,QAAO,KAAK,OAAQ;AAEtB,MAAI,KAAK,SACP,QAAO,KAAK;AAEd,OAAK,WAAW,KAAK,YAAY,CAAC,cAAc;AAC9C,QAAK,WAAW;IAChB;AACF,SAAO,KAAK;;CAGd,eAAgC;AAC9B,MAAI,CAAC,KAAK,OAAQ,QAAO;AACzB,SAAO,KAAK,OAAO,YAAY,KAAK,KAAK,GAAG;;CAG9C,MAAc,aAA8B;EAM1C,MAAM,WAAW,MAAM,SALX,GAAG,KAAK,UAAU,qBACjB;GACX,QAAQ,KAAK;GACb,YAAY,KAAK;GAClB,EACqE;GACpE,OAAO,KAAK;GACZ,SAAS,KAAK;GACf,CAAC;AAEF,MAAI,SAAS,SAAS,KAAK,CAAC,SAAS,oBACnC,OAAM,IAAI,eACR,wCAAwC,SAAS,OAAO,mBACxD,SAAS,QAAQ,IACjB,SACD;EAGH,MAAM,gBAAgB,SAAS,UAAU;AACzC,OAAK,SAAS;GACZ,OAAO,SAAS;GAChB,WAAW,KAAK,KAAK,GAAG,gBAAgB;GACzC;AACD,SAAO,KAAK,OAAO;;;;;AC1EvB,IAAM,mBAAmB;;;;;;;;;;;;AAazB,IAAa,YAAb,MAAuB;CACrB;CACA;CACA;CACA;CACA;CACA;CACA;CAEA,eAA4C;CAC5C,gBAA8C;CAE9C,YAAY,UAA4B,EAAE,EAAE;AAE1C,OAAK,UAAU,QAAQ,WAAW,QAAQ,qBAAqB;AAC/D,OAAK,SAAS,QAAQ,UAAU,QAAQ,oBAAoB;AAC5D,OAAK,QAAQ,QAAQ,SAAS,QAAQ,gBAAgB;AACtD,OAAK,YAAY,QAAQ,aAAa,QAAQ,oBAAoB;AAClE,OAAK,YAAY,QAAQ;AACzB,OAAK,UAAU,QAAQ;AACvB,OAAK,UAAU,QAAQ,WAAW;;;;;;CASpC,MAAM,KACJ,SAC+B;EAC/B,MAAM,UAAU,KAAK,eAAe;EACpC,MAAM,eAA8B,EAAE,GAAG,SAAS;AAElD,MAAI,KAAK,QAAQ;GACf,MAAM,YAAY,kBAAkB;AACpC,gBAAa,YAAY,OAAO,UAAU;AAC1C,gBAAa,OAAO,QAAQ,WAAW,KAAK,OAAO;;EAGrD,MAAM,WAAW,MAAM,SACrB,SACA,cACA;GACE,OAAO,KAAK;GACZ,SAAS,KAAK;GACf,CACF;AAGD,MAAI,SAAS,SAAS,EACpB,OAAM,IAAI,eACR,yBAAyB,SAAS,OAAO,UAAU,SAAS,SAAS,KAAK,IAC1E,SAAS,MACT,SACD;AAGH,SAAO;;CAKT,SAAS,MAAc,MAA8C;AACnE,SAAO,KAAK,KAAK,UAAU,MAAM,KAAK,CAAC;;CAGzC,SAAS,MAA+C;AACtD,SAAO,KAAK,KAAK,UAAU,KAAK,CAAC;;CAGnC,cAAc,aAAiD;AAC7D,SAAO,KAAK,KAAK,eAAe,YAAY,CAAC;;CAG/C,gBAAgB,MAAmD;AACjE,SAAO,KAAK,KAAK,iBAAiB,KAAK,CAAC;;;;;;;;CAS1C,MAAM,UAAU,OAAgD;EAC9D,IAAI;AACJ,MAAI,OAAO,UAAU,YAAY,MAAM,WAAW,OAAO,CACvD,YAAW;MAEX,YAAW,MAAM,KAAK,YAAY,MAAM;AAE1C,SAAO,KAAK,KAAK,WAAW,SAAS,CAAC;;;;;;CAOxC,MAAM,YAAY,MAAoC;AAEpD,SADiB,KAAK,kBAAkB,CACxB,YAAY,KAAK;;CAKnC,gBAAgC;AAC9B,MAAI,CAAC,KAAK,QACR,OAAM,IAAI,kBACR,mFACD;AAEH,SAAO,KAAK;;CAGd,uBAAqE;AACnE,MAAI,CAAC,KAAK,SAAS,CAAC,KAAK,UACvB,OAAM,IAAI,kBACR,2HACD;AAEH,SAAO;GAAE,OAAO,KAAK;GAAO,WAAW,KAAK;GAAW;;CAGzD,kBAAwC;AACtC,MAAI,CAAC,KAAK,cAAc;GACtB,MAAM,EAAE,OAAO,cAAc,KAAK,sBAAsB;AACxD,QAAK,eAAe,IAAI,aAAa;IACnC;IACA;IACA,OAAO,KAAK;IACZ,SAAS,KAAK;IACd,SAAS,KAAK;IACf,CAAC;;AAEJ,SAAO,KAAK;;CAGd,mBAA0C;AACxC,MAAI,CAAC,KAAK,cACR,MAAK,gBAAgB,IAAI,cAAc;GACrC,cAAc,KAAK,iBAAiB;GACpC,OAAO,KAAK;GACZ,SAAS,KAAK;GACd,SAAS,KAAK;GACf,CAAC;AAEJ,SAAO,KAAK"}
1
+ {"version":3,"file":"index.cjs","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 { FeishuApiError, FeishuConfigError } 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/**\n * 支持的图片源:\n * - string : 文件路径(仅 Node 环境,浏览器/SW 会抛错)\n * - Uint8Array / Buffer : 原始字节\n * - Blob / File : 浏览器和 SW 推荐的方式(fetch().blob()、canvas.convertToBlob() 等)\n */\nexport type ImageSource = string | Uint8Array | Blob;\n\n/**\n * 图片上传器:调用 im/v1/images 接口,返回 image_key。\n *\n * 同构设计:\n * - Blob / Uint8Array 分支在 Node 18+ / 浏览器 / Service Worker 都能跑\n * - string 路径分支仅在 Node 可用,通过 new Function 隐藏 node:fs/promises 的\n * 静态引用,让浏览器/扩展打包器(Vite/Webpack/esbuild)不会因为找不到模块而报错\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 // Blob / File:浏览器和 SW 的主要路径\n if (typeof Blob !== 'undefined' && file instanceof Blob) {\n const buf = await file.arrayBuffer();\n // File extends Blob,有 .name;普通 Blob 没有 .name,用鸭子类型读\n const filename = (file as { name?: string }).name ?? 'image';\n return { bytes: new Uint8Array(buf), filename };\n }\n // Uint8Array / Buffer:Node 和浏览器都能用\n if (file instanceof Uint8Array) {\n return { bytes: file, filename: 'image' };\n }\n // string:文件路径,仅 Node\n if (typeof file === 'string') {\n if (typeof process === 'undefined' || !process.versions?.node) {\n throw new FeishuConfigError(\n 'String file path is only supported in Node.js. ' +\n 'In browsers or Service Workers, pass a Blob, File, or Uint8Array instead.',\n );\n }\n return loadFromFilePath(file);\n }\n throw new FeishuApiError(\n 'Unsupported image source type. Expected string path, Uint8Array, or Blob.',\n -1,\n null,\n );\n }\n}\n\n/**\n * 从文件路径读取字节(仅 Node)。\n *\n * 关键技巧:用 `new Function` 包裹 dynamic import 字符串,让 Vite / Webpack / esbuild\n * 等打包器无法静态分析这两个 node:* import,从而不会在浏览器/扩展产物里报「找不到模块」。\n *\n * 这条代码路径在浏览器/SW 中永远不可达(resolveSource 已经在 typeof process 处抛错了),\n * 所以静态引用即使被打入 bundle 也不会被执行。\n */\nasync function loadFromFilePath(\n filePath: string,\n): Promise<{ bytes: Uint8Array; filename: string }> {\n type FsModule = typeof import('node:fs/promises');\n type PathModule = typeof import('node:path');\n const importFs = new Function(\n 'return import(\"node:fs/promises\")',\n ) as () => Promise<FsModule>;\n const importPath = new Function(\n 'return import(\"node:path\")',\n ) as () => Promise<PathModule>;\n\n const [fs, pathMod] = await Promise.all([importFs(), importPath()]);\n const buf = await fs.readFile(filePath);\n return {\n bytes: new Uint8Array(buf),\n filename: pathMod.basename(filePath),\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","/**\n * 生成飞书自定义机器人签名(同构实现,使用 WebCrypto)。\n *\n * 算法(来自飞书官方文档,反直觉之处:HMAC 的 key 是 stringToSign 本身,data 是空字符串):\n * stringToSign = `${timestamp}\\n${secret}`\n * sign = Base64(HmacSHA256(key = stringToSign, data = ''))\n *\n * 仅依赖 globalThis.crypto.subtle,因此在以下环境均可运行:\n * - Node 18+(原生 WebCrypto)\n * - 浏览器主线程\n * - Service Worker / 浏览器扩展 Service Worker\n * - Cloudflare Workers / Deno / Bun\n *\n * ⚠️ 破坏性变更(v0.1 → v0.2):返回 Promise,而非同步字符串。\n *\n * @param timestamp Unix 秒时间戳(飞书要求 ±1 小时窗口)\n * @param secret 机器人「安全设置 → 签名校验」得到的 secret\n */\nexport async function genSign(\n timestamp: number | string,\n secret: string,\n): Promise<string> {\n const subtle = globalThis.crypto?.subtle;\n if (!subtle) {\n throw new Error(\n 'WebCrypto (globalThis.crypto.subtle) is not available. ' +\n 'Use Node.js >= 18, a modern browser, or a Service Worker context.',\n );\n }\n\n const stringToSign = `${timestamp}\\n${secret}`;\n const keyData = new TextEncoder().encode(stringToSign);\n\n const cryptoKey = await subtle.importKey(\n 'raw',\n keyData,\n { name: 'HMAC', hash: 'SHA-256' },\n false,\n ['sign'],\n );\n const signature = await subtle.sign('HMAC', cryptoKey, new Uint8Array(0));\n\n return bytesToBase64(new Uint8Array(signature));\n}\n\n/**\n * 获取当前 Unix 秒时间戳。\n */\nexport function currentTimestamp(): number {\n return Math.floor(Date.now() / 1000);\n}\n\n/**\n * Uint8Array → base64。\n * 不依赖 Node Buffer,浏览器/SW/Node 18+ 都有 btoa。\n */\nfunction bytesToBase64(bytes: Uint8Array): string {\n let bin = '';\n for (let i = 0; i < bytes.length; i++) {\n bin += String.fromCharCode(bytes[i]);\n }\n return btoa(bin);\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\n/**\n * 缓存的 token 结构。是 TokenStorage 适配器读写的数据形状。\n * 公开导出,便于 SW / 浏览器扩展实现自己的存储适配器。\n */\nexport interface CachedToken {\n /** tenant_access_token 字符串 */\n token: string;\n /** Unix 毫秒时间戳;过期时间 = 获取时刻 + expire 秒 * 1000 */\n expiresAt: number;\n}\n\n/**\n * 跨进程/跨重启的 token 持久化适配器。\n *\n * 默认 TokenManager 只在内存里缓存 token,进程退出或 SW 被杀就丢失。\n * 注入 TokenStorage 后可以让 token 在 chrome.storage.session、Redis、\n * 文件等外部介质里活下来,避免每次冷启动都消耗一次 OpenAPI 频次。\n *\n * 实现要求:\n * - get(): 没有缓存或读失败时返回 null(内部会兜底回退到网络刷新)\n * - set(value): 写失败不应抛出(TokenManager 会吞掉异常,避免影响主流程)\n *\n * 典型实现示例(Chrome MV3 扩展 SW):\n * const storage: TokenStorage = {\n * async get() {\n * const { feishuToken } = await chrome.storage.session.get('feishuToken');\n * return feishuToken ?? null;\n * },\n * async set(value) {\n * await chrome.storage.session.set({ feishuToken: value });\n * },\n * };\n */\nexport interface TokenStorage {\n /** 读取缓存的 token;不存在或读失败返回 null */\n get(): Promise<CachedToken | null>;\n /** 写入新的 token */\n set(value: CachedToken): Promise<void>;\n}\n\nexport interface TokenManagerOptions {\n appId: string;\n appSecret: string;\n fetch?: typeof fetch;\n timeout?: number;\n baseUrl?: string;\n /** 可选的持久化适配器;不传则只在内存里缓存 */\n storage?: TokenStorage;\n}\n\n/**\n * tenant_access_token 缓存与自动刷新。\n *\n * 三层缓存查找顺序:\n * 1. 内存(最快)\n * 2. 注入的 TokenStorage(跨进程/跨 SW 重启)\n * 3. 网络获取\n *\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 private readonly storage?: TokenStorage;\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 this.storage = options.storage;\n }\n\n /**\n * 获取有效 token。优先内存缓存;过期/即将过期时尝试 storage,最后回退到网络。\n */\n async getToken(): Promise<string> {\n if (this.isFresh(this.cached)) {\n return this.cached!.token;\n }\n if (this.inflight) {\n return this.inflight;\n }\n this.inflight = this.refreshToken().finally(() => {\n this.inflight = null;\n });\n return this.inflight;\n }\n\n private isFresh(entry: CachedToken | null): boolean {\n if (!entry) return false;\n return entry.expiresAt - Date.now() > REFRESH_THRESHOLD_MS;\n }\n\n /**\n * 刷新流程:先尝试 storage(若注入),不可用则走网络。\n * storage 异常一律视为「miss」,回退到网络,避免单点故障阻塞主流程。\n */\n private async refreshToken(): Promise<string> {\n if (this.storage) {\n try {\n const stored = await this.storage.get();\n if (this.isFresh(stored)) {\n this.cached = stored;\n return stored!.token;\n }\n } catch {\n // 读失败不抛,继续走网络\n }\n }\n return this.fetchToken();\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\n if (this.storage) {\n try {\n await this.storage.set(this.cached);\n } catch {\n // 写失败不抛,下一次冷启动会重新拉取\n }\n }\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, type TokenStorage } 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 private readonly tokenStorage?: TokenStorage;\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 this.tokenStorage = options.tokenStorage;\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 = await 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 storage: this.tokenStorage,\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/dist/index.d.ts CHANGED
@@ -73,6 +73,17 @@ export declare function buildShareChat(shareChatId: string): ShareChatMessage;
73
73
  */
74
74
  export declare function buildText(text: string, opts?: AtOptions): TextMessage;
75
75
 
76
+ /**
77
+ * 缓存的 token 结构。是 TokenStorage 适配器读写的数据形状。
78
+ * 公开导出,便于 SW / 浏览器扩展实现自己的存储适配器。
79
+ */
80
+ export declare interface CachedToken {
81
+ /** tenant_access_token 字符串 */
82
+ token: string;
83
+ /** Unix 毫秒时间戳;过期时间 = 获取时刻 + expire 秒 * 1000 */
84
+ expiresAt: number;
85
+ }
86
+
76
87
  /**
77
88
  * 获取当前 Unix 秒时间戳。
78
89
  */
@@ -113,6 +124,7 @@ export declare class FeishuBot {
113
124
  private readonly fetchImpl?;
114
125
  private readonly timeout?;
115
126
  private readonly baseUrl;
127
+ private readonly tokenStorage?;
116
128
  private tokenManager;
117
129
  private imageUploader;
118
130
  constructor(options?: FeishuBotOptions);
@@ -150,9 +162,6 @@ export declare class FeishuBotError extends Error {
150
162
  constructor(message: string);
151
163
  }
152
164
 
153
- /**
154
- * 飞书自定义机器人 SDK 的类型定义。
155
- */
156
165
  /** SDK 构造配置 */
157
166
  export declare interface FeishuBotOptions {
158
167
  /** 机器人 webhook URL。默认读 `process.env.FEISHU_BOT_WEBHOOK` */
@@ -169,6 +178,12 @@ export declare interface FeishuBotOptions {
169
178
  timeout?: number;
170
179
  /** 飞书开放平台基础 URL,默认 https://open.feishu.cn */
171
180
  baseUrl?: string;
181
+ /**
182
+ * 可选的 tenant_access_token 持久化适配器。
183
+ * 注入后 token 会写入外部存储,避免每次冷启动(如 MV3 SW 被杀)都重新拉取。
184
+ * 典型用法见 TokenStorage 文档。
185
+ */
186
+ tokenStorage?: TokenStorage;
172
187
  }
173
188
 
174
189
  /**
@@ -180,16 +195,24 @@ export declare class FeishuConfigError extends FeishuBotError {
180
195
  }
181
196
 
182
197
  /**
183
- * 生成飞书自定义机器人签名。
198
+ * 生成飞书自定义机器人签名(同构实现,使用 WebCrypto)。
184
199
  *
185
200
  * 算法(来自飞书官方文档,反直觉之处:HMAC 的 key 是 stringToSign 本身,data 是空字符串):
186
201
  * stringToSign = `${timestamp}\n${secret}`
187
202
  * sign = Base64(HmacSHA256(key = stringToSign, data = ''))
188
203
  *
204
+ * 仅依赖 globalThis.crypto.subtle,因此在以下环境均可运行:
205
+ * - Node 18+(原生 WebCrypto)
206
+ * - 浏览器主线程
207
+ * - Service Worker / 浏览器扩展 Service Worker
208
+ * - Cloudflare Workers / Deno / Bun
209
+ *
210
+ * ⚠️ 破坏性变更(v0.1 → v0.2):返回 Promise,而非同步字符串。
211
+ *
189
212
  * @param timestamp Unix 秒时间戳(飞书要求 ±1 小时窗口)
190
213
  * @param secret 机器人「安全设置 → 签名校验」得到的 secret
191
214
  */
192
- export declare function genSign(timestamp: number | string, secret: string): string;
215
+ export declare function genSign(timestamp: number | string, secret: string): Promise<string>;
193
216
 
194
217
  /** 图片消息 */
195
218
  export declare interface ImageMessage {
@@ -199,14 +222,21 @@ export declare interface ImageMessage {
199
222
  };
200
223
  }
201
224
 
202
- /** 支持的图片源:文件路径字符串 / Buffer / Uint8Array */
203
- export declare type ImageSource = string | Buffer | Uint8Array;
225
+ /**
226
+ * 支持的图片源:
227
+ * - string : 文件路径(仅 Node 环境,浏览器/SW 会抛错)
228
+ * - Uint8Array / Buffer : 原始字节
229
+ * - Blob / File : 浏览器和 SW 推荐的方式(fetch().blob()、canvas.convertToBlob() 等)
230
+ */
231
+ export declare type ImageSource = string | Uint8Array | Blob;
204
232
 
205
233
  /**
206
234
  * 图片上传器:调用 im/v1/images 接口,返回 image_key。
207
- * - string: 作为文件路径用 fs/promises.readFile 读成 Buffer
208
- * - Buffer/Uint8Array: 直接作为 Blob 数据
209
- * globalThis FormData + Blob(Node 18+ 内置),不依赖 form-data 包。
235
+ *
236
+ * 同构设计:
237
+ * - Blob / Uint8Array 分支在 Node 18+ / 浏览器 / Service Worker 都能跑
238
+ * - string 路径分支仅在 Node 可用,通过 new Function 隐藏 node:fs/promises 的
239
+ * 静态引用,让浏览器/扩展打包器(Vite/Webpack/esbuild)不会因为找不到模块而报错
210
240
  */
211
241
  export declare class ImageUploader {
212
242
  private readonly tokenManager;
@@ -310,6 +340,12 @@ export declare interface TextMessage {
310
340
 
311
341
  /**
312
342
  * tenant_access_token 缓存与自动刷新。
343
+ *
344
+ * 三层缓存查找顺序:
345
+ * 1. 内存(最快)
346
+ * 2. 注入的 TokenStorage(跨进程/跨 SW 重启)
347
+ * 3. 网络获取
348
+ *
313
349
  * 并发去重:多次 getToken() 在 in-flight 期间共享同一个 Promise,避免重复请求。
314
350
  */
315
351
  export declare class TokenManager {
@@ -318,14 +354,20 @@ export declare class TokenManager {
318
354
  private readonly fetchImpl?;
319
355
  private readonly timeout?;
320
356
  private readonly baseUrl;
357
+ private readonly storage?;
321
358
  private cached;
322
359
  private inflight;
323
360
  constructor(options: TokenManagerOptions);
324
361
  /**
325
- * 获取有效 token。优先使用缓存;过期/即将过期时刷新。
362
+ * 获取有效 token。优先内存缓存;过期/即将过期时尝试 storage,最后回退到网络。
326
363
  */
327
364
  getToken(): Promise<string>;
328
- private isCacheFresh;
365
+ private isFresh;
366
+ /**
367
+ * 刷新流程:先尝试 storage(若注入),不可用则走网络。
368
+ * storage 异常一律视为「miss」,回退到网络,避免单点故障阻塞主流程。
369
+ */
370
+ private refreshToken;
329
371
  private fetchToken;
330
372
  }
331
373
 
@@ -335,6 +377,37 @@ declare interface TokenManagerOptions {
335
377
  fetch?: typeof fetch;
336
378
  timeout?: number;
337
379
  baseUrl?: string;
380
+ /** 可选的持久化适配器;不传则只在内存里缓存 */
381
+ storage?: TokenStorage;
382
+ }
383
+
384
+ /**
385
+ * 跨进程/跨重启的 token 持久化适配器。
386
+ *
387
+ * 默认 TokenManager 只在内存里缓存 token,进程退出或 SW 被杀就丢失。
388
+ * 注入 TokenStorage 后可以让 token 在 chrome.storage.session、Redis、
389
+ * 文件等外部介质里活下来,避免每次冷启动都消耗一次 OpenAPI 频次。
390
+ *
391
+ * 实现要求:
392
+ * - get(): 没有缓存或读失败时返回 null(内部会兜底回退到网络刷新)
393
+ * - set(value): 写失败不应抛出(TokenManager 会吞掉异常,避免影响主流程)
394
+ *
395
+ * 典型实现示例(Chrome MV3 扩展 SW):
396
+ * const storage: TokenStorage = {
397
+ * async get() {
398
+ * const { feishuToken } = await chrome.storage.session.get('feishuToken');
399
+ * return feishuToken ?? null;
400
+ * },
401
+ * async set(value) {
402
+ * await chrome.storage.session.set({ feishuToken: value });
403
+ * },
404
+ * };
405
+ */
406
+ export declare interface TokenStorage {
407
+ /** 读取缓存的 token;不存在或读失败返回 null */
408
+ get(): Promise<CachedToken | null>;
409
+ /** 写入新的 token */
410
+ set(value: CachedToken): Promise<void>;
338
411
  }
339
412
 
340
413
  /** 上传图片返回数据 */
package/dist/index.js CHANGED
@@ -1,6 +1,3 @@
1
- import { readFile } from "node:fs/promises";
2
- import { basename } from "node:path";
3
- import { createHmac } from "node:crypto";
4
1
  //#region src/env.ts
5
2
  /**
6
3
  * 安全读取 process.env。在不存在 process 的环境(如浏览器)里返回 undefined,不会崩溃。
@@ -131,9 +128,11 @@ var DEFAULT_BASE_URL$2 = "https://open.feishu.cn";
131
128
  var UPLOAD_PATH = "/open-apis/im/v1/images";
132
129
  /**
133
130
  * 图片上传器:调用 im/v1/images 接口,返回 image_key。
134
- * - string: 作为文件路径用 fs/promises.readFile 读成 Buffer
135
- * - Buffer/Uint8Array: 直接作为 Blob 数据
136
- * globalThis FormData + Blob(Node 18+ 内置),不依赖 form-data 包。
131
+ *
132
+ * 同构设计:
133
+ * - Blob / Uint8Array 分支在 Node 18+ / 浏览器 / Service Worker 都能跑
134
+ * - string 路径分支仅在 Node 可用,通过 new Function 隐藏 node:fs/promises 的
135
+ * 静态引用,让浏览器/扩展打包器(Vite/Webpack/esbuild)不会因为找不到模块而报错
137
136
  */
138
137
  var ImageUploader = class {
139
138
  tokenManager;
@@ -165,20 +164,44 @@ var ImageUploader = class {
165
164
  return response.data.image_key;
166
165
  }
167
166
  async resolveSource(file) {
168
- if (typeof file === "string") {
169
- const buf = await readFile(file);
167
+ if (typeof Blob !== "undefined" && file instanceof Blob) {
168
+ const buf = await file.arrayBuffer();
169
+ const filename = file.name ?? "image";
170
170
  return {
171
171
  bytes: new Uint8Array(buf),
172
- filename: basename(file)
172
+ filename
173
173
  };
174
174
  }
175
175
  if (file instanceof Uint8Array) return {
176
176
  bytes: file,
177
177
  filename: "image"
178
178
  };
179
- throw new FeishuApiError("Unsupported image source type. Expected string path, Buffer, or Uint8Array.", -1, null);
179
+ if (typeof file === "string") {
180
+ if (typeof process === "undefined" || !process.versions?.node) throw new FeishuConfigError("String file path is only supported in Node.js. In browsers or Service Workers, pass a Blob, File, or Uint8Array instead.");
181
+ return loadFromFilePath(file);
182
+ }
183
+ throw new FeishuApiError("Unsupported image source type. Expected string path, Uint8Array, or Blob.", -1, null);
180
184
  }
181
185
  };
186
+ /**
187
+ * 从文件路径读取字节(仅 Node)。
188
+ *
189
+ * 关键技巧:用 `new Function` 包裹 dynamic import 字符串,让 Vite / Webpack / esbuild
190
+ * 等打包器无法静态分析这两个 node:* import,从而不会在浏览器/扩展产物里报「找不到模块」。
191
+ *
192
+ * 这条代码路径在浏览器/SW 中永远不可达(resolveSource 已经在 typeof process 处抛错了),
193
+ * 所以静态引用即使被打入 bundle 也不会被执行。
194
+ */
195
+ async function loadFromFilePath(filePath) {
196
+ const importFs = new Function("return import(\"node:fs/promises\")");
197
+ const importPath = new Function("return import(\"node:path\")");
198
+ const [fs, pathMod] = await Promise.all([importFs(), importPath()]);
199
+ const buf = await fs.readFile(filePath);
200
+ return {
201
+ bytes: new Uint8Array(buf),
202
+ filename: pathMod.basename(filePath)
203
+ };
204
+ }
182
205
  //#endregion
183
206
  //#region src/messages/image.ts
184
207
  /**
@@ -283,17 +306,34 @@ function buildText(text, opts = {}) {
283
306
  //#endregion
284
307
  //#region src/signer.ts
285
308
  /**
286
- * 生成飞书自定义机器人签名。
309
+ * 生成飞书自定义机器人签名(同构实现,使用 WebCrypto)。
287
310
  *
288
311
  * 算法(来自飞书官方文档,反直觉之处:HMAC 的 key 是 stringToSign 本身,data 是空字符串):
289
312
  * stringToSign = `${timestamp}\n${secret}`
290
313
  * sign = Base64(HmacSHA256(key = stringToSign, data = ''))
291
314
  *
315
+ * 仅依赖 globalThis.crypto.subtle,因此在以下环境均可运行:
316
+ * - Node 18+(原生 WebCrypto)
317
+ * - 浏览器主线程
318
+ * - Service Worker / 浏览器扩展 Service Worker
319
+ * - Cloudflare Workers / Deno / Bun
320
+ *
321
+ * ⚠️ 破坏性变更(v0.1 → v0.2):返回 Promise,而非同步字符串。
322
+ *
292
323
  * @param timestamp Unix 秒时间戳(飞书要求 ±1 小时窗口)
293
324
  * @param secret 机器人「安全设置 → 签名校验」得到的 secret
294
325
  */
295
- function genSign(timestamp, secret) {
296
- return createHmac("sha256", `${timestamp}\n${secret}`).update("").digest("base64");
326
+ async function genSign(timestamp, secret) {
327
+ const subtle = globalThis.crypto?.subtle;
328
+ if (!subtle) throw new Error("WebCrypto (globalThis.crypto.subtle) is not available. Use Node.js >= 18, a modern browser, or a Service Worker context.");
329
+ const stringToSign = `${timestamp}\n${secret}`;
330
+ const keyData = new TextEncoder().encode(stringToSign);
331
+ const cryptoKey = await subtle.importKey("raw", keyData, {
332
+ name: "HMAC",
333
+ hash: "SHA-256"
334
+ }, false, ["sign"]);
335
+ const signature = await subtle.sign("HMAC", cryptoKey, new Uint8Array(0));
336
+ return bytesToBase64(new Uint8Array(signature));
297
337
  }
298
338
  /**
299
339
  * 获取当前 Unix 秒时间戳。
@@ -301,6 +341,15 @@ function genSign(timestamp, secret) {
301
341
  function currentTimestamp() {
302
342
  return Math.floor(Date.now() / 1e3);
303
343
  }
344
+ /**
345
+ * Uint8Array → base64。
346
+ * 不依赖 Node Buffer,浏览器/SW/Node 18+ 都有 btoa。
347
+ */
348
+ function bytesToBase64(bytes) {
349
+ let bin = "";
350
+ for (let i = 0; i < bytes.length; i++) bin += String.fromCharCode(bytes[i]);
351
+ return btoa(bin);
352
+ }
304
353
  //#endregion
305
354
  //#region src/token-manager.ts
306
355
  var DEFAULT_BASE_URL$1 = "https://open.feishu.cn";
@@ -309,6 +358,12 @@ var TENANT_TOKEN_PATH = "/open-apis/auth/v3/tenant_access_token/internal";
309
358
  var REFRESH_THRESHOLD_MS = 1800 * 1e3;
310
359
  /**
311
360
  * tenant_access_token 缓存与自动刷新。
361
+ *
362
+ * 三层缓存查找顺序:
363
+ * 1. 内存(最快)
364
+ * 2. 注入的 TokenStorage(跨进程/跨 SW 重启)
365
+ * 3. 网络获取
366
+ *
312
367
  * 并发去重:多次 getToken() 在 in-flight 期间共享同一个 Promise,避免重复请求。
313
368
  */
314
369
  var TokenManager = class {
@@ -317,6 +372,7 @@ var TokenManager = class {
317
372
  fetchImpl;
318
373
  timeout;
319
374
  baseUrl;
375
+ storage;
320
376
  cached = null;
321
377
  inflight = null;
322
378
  constructor(options) {
@@ -326,21 +382,36 @@ var TokenManager = class {
326
382
  this.fetchImpl = options.fetch;
327
383
  this.timeout = options.timeout;
328
384
  this.baseUrl = options.baseUrl ?? DEFAULT_BASE_URL$1;
385
+ this.storage = options.storage;
329
386
  }
330
387
  /**
331
- * 获取有效 token。优先使用缓存;过期/即将过期时刷新。
388
+ * 获取有效 token。优先内存缓存;过期/即将过期时尝试 storage,最后回退到网络。
332
389
  */
333
390
  async getToken() {
334
- if (this.isCacheFresh()) return this.cached.token;
391
+ if (this.isFresh(this.cached)) return this.cached.token;
335
392
  if (this.inflight) return this.inflight;
336
- this.inflight = this.fetchToken().finally(() => {
393
+ this.inflight = this.refreshToken().finally(() => {
337
394
  this.inflight = null;
338
395
  });
339
396
  return this.inflight;
340
397
  }
341
- isCacheFresh() {
342
- if (!this.cached) return false;
343
- return this.cached.expiresAt - Date.now() > REFRESH_THRESHOLD_MS;
398
+ isFresh(entry) {
399
+ if (!entry) return false;
400
+ return entry.expiresAt - Date.now() > REFRESH_THRESHOLD_MS;
401
+ }
402
+ /**
403
+ * 刷新流程:先尝试 storage(若注入),不可用则走网络。
404
+ * storage 异常一律视为「miss」,回退到网络,避免单点故障阻塞主流程。
405
+ */
406
+ async refreshToken() {
407
+ if (this.storage) try {
408
+ const stored = await this.storage.get();
409
+ if (this.isFresh(stored)) {
410
+ this.cached = stored;
411
+ return stored.token;
412
+ }
413
+ } catch {}
414
+ return this.fetchToken();
344
415
  }
345
416
  async fetchToken() {
346
417
  const response = await postJson(`${this.baseUrl}${TENANT_TOKEN_PATH}`, {
@@ -356,6 +427,9 @@ var TokenManager = class {
356
427
  token: response.tenant_access_token,
357
428
  expiresAt: Date.now() + expireSeconds * 1e3
358
429
  };
430
+ if (this.storage) try {
431
+ await this.storage.set(this.cached);
432
+ } catch {}
359
433
  return this.cached.token;
360
434
  }
361
435
  };
@@ -381,6 +455,7 @@ var FeishuBot = class {
381
455
  fetchImpl;
382
456
  timeout;
383
457
  baseUrl;
458
+ tokenStorage;
384
459
  tokenManager = null;
385
460
  imageUploader = null;
386
461
  constructor(options = {}) {
@@ -391,6 +466,7 @@ var FeishuBot = class {
391
466
  this.fetchImpl = options.fetch;
392
467
  this.timeout = options.timeout;
393
468
  this.baseUrl = options.baseUrl ?? DEFAULT_BASE_URL;
469
+ this.tokenStorage = options.tokenStorage;
394
470
  }
395
471
  /**
396
472
  * 原子发送:接收已构造好的 payload,负责注入签名并 POST 到 webhook。
@@ -402,7 +478,7 @@ var FeishuBot = class {
402
478
  if (this.secret) {
403
479
  const timestamp = currentTimestamp();
404
480
  finalPayload.timestamp = String(timestamp);
405
- finalPayload.sign = genSign(timestamp, this.secret);
481
+ finalPayload.sign = await genSign(timestamp, this.secret);
406
482
  }
407
483
  const response = await postJson(webhook, finalPayload, {
408
484
  fetch: this.fetchImpl,
@@ -461,7 +537,8 @@ var FeishuBot = class {
461
537
  appSecret,
462
538
  fetch: this.fetchImpl,
463
539
  timeout: this.timeout,
464
- baseUrl: this.baseUrl
540
+ baseUrl: this.baseUrl,
541
+ storage: this.tokenStorage
465
542
  });
466
543
  }
467
544
  return this.tokenManager;
package/dist/index.js.map CHANGED
@@ -1 +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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"}
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 { FeishuApiError, FeishuConfigError } 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/**\n * 支持的图片源:\n * - string : 文件路径(仅 Node 环境,浏览器/SW 会抛错)\n * - Uint8Array / Buffer : 原始字节\n * - Blob / File : 浏览器和 SW 推荐的方式(fetch().blob()、canvas.convertToBlob() 等)\n */\nexport type ImageSource = string | Uint8Array | Blob;\n\n/**\n * 图片上传器:调用 im/v1/images 接口,返回 image_key。\n *\n * 同构设计:\n * - Blob / Uint8Array 分支在 Node 18+ / 浏览器 / Service Worker 都能跑\n * - string 路径分支仅在 Node 可用,通过 new Function 隐藏 node:fs/promises 的\n * 静态引用,让浏览器/扩展打包器(Vite/Webpack/esbuild)不会因为找不到模块而报错\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 // Blob / File:浏览器和 SW 的主要路径\n if (typeof Blob !== 'undefined' && file instanceof Blob) {\n const buf = await file.arrayBuffer();\n // File extends Blob,有 .name;普通 Blob 没有 .name,用鸭子类型读\n const filename = (file as { name?: string }).name ?? 'image';\n return { bytes: new Uint8Array(buf), filename };\n }\n // Uint8Array / Buffer:Node 和浏览器都能用\n if (file instanceof Uint8Array) {\n return { bytes: file, filename: 'image' };\n }\n // string:文件路径,仅 Node\n if (typeof file === 'string') {\n if (typeof process === 'undefined' || !process.versions?.node) {\n throw new FeishuConfigError(\n 'String file path is only supported in Node.js. ' +\n 'In browsers or Service Workers, pass a Blob, File, or Uint8Array instead.',\n );\n }\n return loadFromFilePath(file);\n }\n throw new FeishuApiError(\n 'Unsupported image source type. Expected string path, Uint8Array, or Blob.',\n -1,\n null,\n );\n }\n}\n\n/**\n * 从文件路径读取字节(仅 Node)。\n *\n * 关键技巧:用 `new Function` 包裹 dynamic import 字符串,让 Vite / Webpack / esbuild\n * 等打包器无法静态分析这两个 node:* import,从而不会在浏览器/扩展产物里报「找不到模块」。\n *\n * 这条代码路径在浏览器/SW 中永远不可达(resolveSource 已经在 typeof process 处抛错了),\n * 所以静态引用即使被打入 bundle 也不会被执行。\n */\nasync function loadFromFilePath(\n filePath: string,\n): Promise<{ bytes: Uint8Array; filename: string }> {\n type FsModule = typeof import('node:fs/promises');\n type PathModule = typeof import('node:path');\n const importFs = new Function(\n 'return import(\"node:fs/promises\")',\n ) as () => Promise<FsModule>;\n const importPath = new Function(\n 'return import(\"node:path\")',\n ) as () => Promise<PathModule>;\n\n const [fs, pathMod] = await Promise.all([importFs(), importPath()]);\n const buf = await fs.readFile(filePath);\n return {\n bytes: new Uint8Array(buf),\n filename: pathMod.basename(filePath),\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","/**\n * 生成飞书自定义机器人签名(同构实现,使用 WebCrypto)。\n *\n * 算法(来自飞书官方文档,反直觉之处:HMAC 的 key 是 stringToSign 本身,data 是空字符串):\n * stringToSign = `${timestamp}\\n${secret}`\n * sign = Base64(HmacSHA256(key = stringToSign, data = ''))\n *\n * 仅依赖 globalThis.crypto.subtle,因此在以下环境均可运行:\n * - Node 18+(原生 WebCrypto)\n * - 浏览器主线程\n * - Service Worker / 浏览器扩展 Service Worker\n * - Cloudflare Workers / Deno / Bun\n *\n * ⚠️ 破坏性变更(v0.1 → v0.2):返回 Promise,而非同步字符串。\n *\n * @param timestamp Unix 秒时间戳(飞书要求 ±1 小时窗口)\n * @param secret 机器人「安全设置 → 签名校验」得到的 secret\n */\nexport async function genSign(\n timestamp: number | string,\n secret: string,\n): Promise<string> {\n const subtle = globalThis.crypto?.subtle;\n if (!subtle) {\n throw new Error(\n 'WebCrypto (globalThis.crypto.subtle) is not available. ' +\n 'Use Node.js >= 18, a modern browser, or a Service Worker context.',\n );\n }\n\n const stringToSign = `${timestamp}\\n${secret}`;\n const keyData = new TextEncoder().encode(stringToSign);\n\n const cryptoKey = await subtle.importKey(\n 'raw',\n keyData,\n { name: 'HMAC', hash: 'SHA-256' },\n false,\n ['sign'],\n );\n const signature = await subtle.sign('HMAC', cryptoKey, new Uint8Array(0));\n\n return bytesToBase64(new Uint8Array(signature));\n}\n\n/**\n * 获取当前 Unix 秒时间戳。\n */\nexport function currentTimestamp(): number {\n return Math.floor(Date.now() / 1000);\n}\n\n/**\n * Uint8Array → base64。\n * 不依赖 Node Buffer,浏览器/SW/Node 18+ 都有 btoa。\n */\nfunction bytesToBase64(bytes: Uint8Array): string {\n let bin = '';\n for (let i = 0; i < bytes.length; i++) {\n bin += String.fromCharCode(bytes[i]);\n }\n return btoa(bin);\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\n/**\n * 缓存的 token 结构。是 TokenStorage 适配器读写的数据形状。\n * 公开导出,便于 SW / 浏览器扩展实现自己的存储适配器。\n */\nexport interface CachedToken {\n /** tenant_access_token 字符串 */\n token: string;\n /** Unix 毫秒时间戳;过期时间 = 获取时刻 + expire 秒 * 1000 */\n expiresAt: number;\n}\n\n/**\n * 跨进程/跨重启的 token 持久化适配器。\n *\n * 默认 TokenManager 只在内存里缓存 token,进程退出或 SW 被杀就丢失。\n * 注入 TokenStorage 后可以让 token 在 chrome.storage.session、Redis、\n * 文件等外部介质里活下来,避免每次冷启动都消耗一次 OpenAPI 频次。\n *\n * 实现要求:\n * - get(): 没有缓存或读失败时返回 null(内部会兜底回退到网络刷新)\n * - set(value): 写失败不应抛出(TokenManager 会吞掉异常,避免影响主流程)\n *\n * 典型实现示例(Chrome MV3 扩展 SW):\n * const storage: TokenStorage = {\n * async get() {\n * const { feishuToken } = await chrome.storage.session.get('feishuToken');\n * return feishuToken ?? null;\n * },\n * async set(value) {\n * await chrome.storage.session.set({ feishuToken: value });\n * },\n * };\n */\nexport interface TokenStorage {\n /** 读取缓存的 token;不存在或读失败返回 null */\n get(): Promise<CachedToken | null>;\n /** 写入新的 token */\n set(value: CachedToken): Promise<void>;\n}\n\nexport interface TokenManagerOptions {\n appId: string;\n appSecret: string;\n fetch?: typeof fetch;\n timeout?: number;\n baseUrl?: string;\n /** 可选的持久化适配器;不传则只在内存里缓存 */\n storage?: TokenStorage;\n}\n\n/**\n * tenant_access_token 缓存与自动刷新。\n *\n * 三层缓存查找顺序:\n * 1. 内存(最快)\n * 2. 注入的 TokenStorage(跨进程/跨 SW 重启)\n * 3. 网络获取\n *\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 private readonly storage?: TokenStorage;\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 this.storage = options.storage;\n }\n\n /**\n * 获取有效 token。优先内存缓存;过期/即将过期时尝试 storage,最后回退到网络。\n */\n async getToken(): Promise<string> {\n if (this.isFresh(this.cached)) {\n return this.cached!.token;\n }\n if (this.inflight) {\n return this.inflight;\n }\n this.inflight = this.refreshToken().finally(() => {\n this.inflight = null;\n });\n return this.inflight;\n }\n\n private isFresh(entry: CachedToken | null): boolean {\n if (!entry) return false;\n return entry.expiresAt - Date.now() > REFRESH_THRESHOLD_MS;\n }\n\n /**\n * 刷新流程:先尝试 storage(若注入),不可用则走网络。\n * storage 异常一律视为「miss」,回退到网络,避免单点故障阻塞主流程。\n */\n private async refreshToken(): Promise<string> {\n if (this.storage) {\n try {\n const stored = await this.storage.get();\n if (this.isFresh(stored)) {\n this.cached = stored;\n return stored!.token;\n }\n } catch {\n // 读失败不抛,继续走网络\n }\n }\n return this.fetchToken();\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\n if (this.storage) {\n try {\n await this.storage.set(this.cached);\n } catch {\n // 写失败不抛,下一次冷启动会重新拉取\n }\n }\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, type TokenStorage } 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 private readonly tokenStorage?: TokenStorage;\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 this.tokenStorage = options.tokenStorage;\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 = await 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 storage: this.tokenStorage,\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 CHANGED
@@ -1,14 +1,17 @@
1
1
  {
2
2
  "name": "@minitool/feishu-bot",
3
- "version": "0.1.0",
4
- "description": "飞书自定义机器人 SDK — 支持 text/post/image/share_chat/interactive 五种消息类型,透明处理图片上传",
3
+ "version": "0.2.0",
4
+ "description": "飞书自定义机器人 SDK — 同构(Node / 浏览器 / Service Worker / 浏览器扩展),支持 text/post/image/share_chat/interactive 五种消息类型,透明处理图片上传",
5
5
  "type": "module",
6
6
  "main": "./dist/index.cjs",
7
7
  "module": "./dist/index.js",
8
+ "browser": "./dist/index.js",
8
9
  "types": "./dist/index.d.ts",
9
10
  "exports": {
10
11
  ".": {
11
12
  "types": "./dist/index.d.ts",
13
+ "worker": "./dist/index.js",
14
+ "browser": "./dist/index.js",
12
15
  "import": "./dist/index.js",
13
16
  "require": "./dist/index.cjs"
14
17
  }