@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 +21 -0
- package/README.md +114 -16
- package/dist/index.cjs +98 -21
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.ts +85 -12
- package/dist/index.js +98 -21
- package/dist/index.js.map +1 -1
- package/package.json +5 -2
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
|
-
|
|
3
|
+
[](https://www.npmjs.com/package/@minitool/feishu-bot)
|
|
4
|
+
[](https://www.npmjs.com/package/@minitool/feishu-bot)
|
|
5
|
+
[](https://github.com/hidumou/feishu-bot/releases)
|
|
6
|
+
[](./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
|
-
- ✅
|
|
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
|
-
|
|
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. 本地文件路径 →
|
|
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_` 前缀→直发 /
|
|
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
|
-
*
|
|
136
|
-
*
|
|
137
|
-
*
|
|
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
|
|
170
|
-
const buf = await
|
|
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
|
|
173
|
+
filename
|
|
174
174
|
};
|
|
175
175
|
}
|
|
176
176
|
if (file instanceof Uint8Array) return {
|
|
177
177
|
bytes: file,
|
|
178
178
|
filename: "image"
|
|
179
179
|
};
|
|
180
|
-
|
|
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
|
-
|
|
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.
|
|
392
|
+
if (this.isFresh(this.cached)) return this.cached.token;
|
|
336
393
|
if (this.inflight) return this.inflight;
|
|
337
|
-
this.inflight = this.
|
|
394
|
+
this.inflight = this.refreshToken().finally(() => {
|
|
338
395
|
this.inflight = null;
|
|
339
396
|
});
|
|
340
397
|
return this.inflight;
|
|
341
398
|
}
|
|
342
|
-
|
|
343
|
-
if (!
|
|
344
|
-
return
|
|
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;
|
package/dist/index.cjs.map
CHANGED
|
@@ -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":"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"}
|
|
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
|
-
/**
|
|
203
|
-
|
|
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
|
-
*
|
|
208
|
-
*
|
|
209
|
-
*
|
|
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
|
|
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
|
-
*
|
|
135
|
-
*
|
|
136
|
-
*
|
|
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
|
|
169
|
-
const buf = await
|
|
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
|
|
172
|
+
filename
|
|
173
173
|
};
|
|
174
174
|
}
|
|
175
175
|
if (file instanceof Uint8Array) return {
|
|
176
176
|
bytes: file,
|
|
177
177
|
filename: "image"
|
|
178
178
|
};
|
|
179
|
-
|
|
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
|
-
|
|
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.
|
|
391
|
+
if (this.isFresh(this.cached)) return this.cached.token;
|
|
335
392
|
if (this.inflight) return this.inflight;
|
|
336
|
-
this.inflight = this.
|
|
393
|
+
this.inflight = this.refreshToken().finally(() => {
|
|
337
394
|
this.inflight = null;
|
|
338
395
|
});
|
|
339
396
|
return this.inflight;
|
|
340
397
|
}
|
|
341
|
-
|
|
342
|
-
if (!
|
|
343
|
-
return
|
|
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":";;;;;;;;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,MAAM,SAAS,KAAK;AAChC,UAAO;IAAE,OAAO,IAAI,WAAW,IAAI;IAAE,UAAU,SAAS,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,QAAO,WAAW,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.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.
|
|
4
|
-
"description": "飞书自定义机器人 SDK —
|
|
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
|
}
|