@quecue/notify-core 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.en.md +8 -0
- package/CHANGELOG.md +8 -0
- package/CONTRIBUTING.md +7 -3
- package/PUBLISHING.md +5 -3
- package/README.en.md +80 -7
- package/README.md +80 -7
- package/SECURITY.en.md +8 -3
- package/SECURITY.md +9 -4
- package/dist/feishu-sdk.d.ts +5 -0
- package/dist/feishu-sdk.js +44 -0
- package/dist/feishu.d.ts +100 -0
- package/dist/feishu.js +145 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/package.json +4 -1
package/CHANGELOG.en.md
CHANGED
|
@@ -2,6 +2,14 @@
|
|
|
2
2
|
|
|
3
3
|
English | [简体中文](CHANGELOG.md)
|
|
4
4
|
|
|
5
|
+
## 0.2.0
|
|
6
|
+
|
|
7
|
+
- Add a separate Feishu self-built app `createFeishuClient`: explicitly start WebSocket reception of `im.message.receive_v1`, with HTTP sending/replies and no card-action callbacks. Existing Webhook `makePlan`/`deliver` and `NotifyConfig` remain unchanged.
|
|
8
|
+
- Synchronous creation is offline and the SDK is lazy-loaded. Startup waits for actual readiness, defaults to a 10000 ms timeout and is idempotent. Support connection states, stopping with pending-start cancellation/reconnection disabled, and restart. Stop does not wait for or cancel callbacks/HTTP requests in progress; sending does not require startup.
|
|
9
|
+
- Add safe `FeishuError` errors and offline/custom `adapterFactory` injection. Message handlers are awaited; failure is safely reported before returning a failure ACK. Isolate `onError` failures and disable SDK logs by default. Handlers must finish within 3 seconds; hosts own deduplication, authorization, self-reply prevention and durable queues, with no exactly-once guarantee across disconnects/redelivery.
|
|
10
|
+
- Add the official MIT-licensed runtime dependency `@larksuiteoapi/node-sdk`, pinned to `1.76.0`, for app tokens, the binary protocol, framing, heartbeats and reconnection. SDK HTTP separately enforces 10 seconds/1 MiB/no redirects; WebSocket frames are outside `createTransport`'s JSON-response guarantee.
|
|
11
|
+
- Synchronize Chinese/English receive/reply examples, security boundaries, contribution and release guidance, including npm registry access when installing the SDK.
|
|
12
|
+
|
|
5
13
|
## 0.1.0
|
|
6
14
|
|
|
7
15
|
First standalone release; the npm registry is authoritative for actual publication status and available versions.
|
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,14 @@
|
|
|
2
2
|
|
|
3
3
|
简体中文 | [English](CHANGELOG.en.md)
|
|
4
4
|
|
|
5
|
+
## 0.2.0
|
|
6
|
+
|
|
7
|
+
- 新增独立的飞书自建应用 `createFeishuClient`:显式启动 WebSocket 接收 `im.message.receive_v1`,通过 HTTP 发送/回复,不支持卡片动作回调;现有 Webhook `makePlan`/`deliver` 和 `NotifyConfig` 不变。
|
|
8
|
+
- 同步创建不联网,SDK 懒加载;启动等待实际 ready,默认超时 10000 毫秒,重复启动幂等。支持连接状态、停止取消待启动/禁用重连和重启;停止不等待或取消已执行的回调/HTTP 请求,发送无需先启动。
|
|
9
|
+
- 新增 `FeishuError` 安全错误和离线/自定义 `adapterFactory`;消息回调被 await,失败安全报告后返回失败 ACK,`onError` 失败被隔离,默认不输出 SDK 日志。回调须在 3 秒内结束;宿主负责去重、授权、防自回复和持久队列,不保证断线/重推恰好一次。
|
|
10
|
+
- 新增官方 MIT 许可运行时依赖 `@larksuiteoapi/node-sdk`,固定为 `1.76.0`,负责应用 token、二进制协议、分帧心跳和重连;SDK HTTP 独立限制 10 秒/1 MiB/禁止重定向,WebSocket 帧不适用 `createTransport` 的 JSON 响应保证。
|
|
11
|
+
- 同步中英文接收/回复用法、安全边界、贡献与发布说明,明确安装 SDK 会访问 npm registry。
|
|
12
|
+
|
|
5
13
|
## 0.1.0
|
|
6
14
|
|
|
7
15
|
首次独立发行;实际发布状态和可用版本以 npm registry 为准。
|
package/CONTRIBUTING.md
CHANGED
|
@@ -6,16 +6,20 @@
|
|
|
6
6
|
|
|
7
7
|
- Node.js 22 或更高版本、npm。
|
|
8
8
|
- 在本仓库执行 `npm ci --ignore-scripts`、`npm test`。
|
|
9
|
-
- 测试默认禁止真实网络,使用虚假凭据和注入的 HTTP
|
|
9
|
+
- 测试默认禁止真实网络,使用虚假凭据和注入的 HTTP/等待函数;飞书客户端通过 `adapterFactory` 注入离线 SDK 适配器,不应要求贡献者提供真实 Bot Token、Webhook、App ID 或 App Secret。依赖安装会访问 npm registry,与运行时测试离线是不同边界。
|
|
10
10
|
- 修改 API 时同步 `.d.ts` 构建、中文/英文 README、错误边界和变更记录。不要手工编辑 `dist`。
|
|
11
11
|
|
|
12
|
-
建议为限流、超时、格式回退、Unicode
|
|
12
|
+
建议为限流、超时、格式回退、Unicode 分段、部分投递、自定义回调失败和错误脱敏补测试。保持默认导入及客户端创建无网络或配置副作用,SDK 懒加载。传输与授权的边界应写清楚,不要使用测试通过来替代真实平台兼容承诺。
|
|
13
|
+
|
|
14
|
+
飞书客户端修改还应覆盖:实际 WS ready 才完成启动、默认 10000 毫秒启动超时/致命失败、重复启动幂等、同步停止取消待启动并禁用重连、停止后重启,以及 `idle`/`connecting`/`connected`/`reconnecting`/`failed`/`stopped` 状态。验证 SDK 原格式事件与 JSON 字符串 `content`、无需启动的 HTTP 发送/回复、`FeishuError` 的 operation/数值 code 和错误脱敏。消息回调须被 await,失败返回失败 ACK;`onError` 抛错/拒绝须隔离且不输出原日志。`stop()` 不等待或取消已执行的回调/HTTP 请求。
|
|
15
|
+
|
|
16
|
+
回调需在 3 秒内结束;去重、授权、防机器人自回复和耗时任务持久队列属于宿主,不承诺断线/重推恰好一次。现有 Webhook `makePlan`/`deliver` 与 `NotifyConfig` 保持不变,不扩展为卡片动作回调接收器。SDK HTTP 的 10 秒/1 MiB/禁重定向限制应单独验证,不要把 `createTransport` 的 JSON 响应保证套用到 WebSocket 帧。
|
|
13
17
|
|
|
14
18
|
## 提交范围
|
|
15
19
|
|
|
16
20
|
不要提交 `.env`、Token、Webhook、真实接收人、消息原文、日志、node_modules 或包含这些内容的安装包。使用明显的假值,例如 `123:TEST_ONLY`;issue 和截图也需脱敏。
|
|
17
21
|
|
|
18
|
-
MIT 许可证及从 [dev-notify](https://github.com/QueCue/dev-notify)
|
|
22
|
+
MIT 许可证及从 [dev-notify](https://github.com/QueCue/dev-notify) 提取的既有版权声明应保留。飞书客户端使用固定版本 `1.76.0` 的官方 MIT 许可运行时依赖 `@larksuiteoapi/node-sdk`,由其负责应用 token、二进制协议、分帧心跳和重连,默认关闭 SDK 日志;不要再将本库描述为无运行时依赖。新增/更新依赖、复制外部代码或改变发布方式前,请说明来源、许可证、目的及测试范围。
|
|
19
23
|
|
|
20
24
|
## 版本包
|
|
21
25
|
|
package/PUBLISHING.md
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
## 包和发布边界
|
|
6
6
|
|
|
7
7
|
- 包名 `@quecue/notify-core`,MIT 许可证,公开 npm 包。保留从 [dev-notify](https://github.com/QueCue/dev-notify) 提取代码的版权声明。
|
|
8
|
-
-
|
|
8
|
+
- 飞书客户端新增官方 MIT 许可运行时依赖 `@larksuiteoapi/node-sdk`,固定为 `1.76.0`;本库不再是无运行时依赖。直接版本由 package.json 固定,开发工具及依赖树由 package-lock 固定;发行前核对 SDK 和传递依赖的来源、许可证及锁文件。npm 包仅含 dist 运行时/类型、公开说明、package.json 和许可证,不含测试、CI 脚本或应用凭据。
|
|
9
9
|
- GitHub Actions 使用托管 Ubuntu runner、Node.js 24 和 npm 11.21.0;只有正式发布任务拥有 `id-token: write`。不需要 `NPM_TOKEN`、`NODE_AUTH_TOKEN` secret 或本机 npm 登录。
|
|
10
10
|
- GitHub 仓库公开、工作流存在和测试通过,都不能证明 npm 账户侧信任已配置,也不等于包已经发布。
|
|
11
11
|
|
|
@@ -35,6 +35,8 @@
|
|
|
35
35
|
5. 保持 `verify_only=true` 先运行验证:安装锁定依赖、编译、测试、生成 tarball、校验精确文件白名单/元数据/SHA512,再隔离安装同一个 tarball,核对 ESM 导出及 TypeScript 类型。下载 `npm-package` artifact 检查。
|
|
36
36
|
6. 确认 npm 信任已建立且版本可发行后,对**同一提交**再次运行,设置 `verify_only=false`。正式运行会重新验证,并只上传本次验证任务保存的同一 tarball;不是直接上传之前某次运行或本机目录中的包。
|
|
37
37
|
|
|
38
|
+
隔离安装本地 tarball **仍会联网 npm registry 下载 SDK 及其传递依赖**,不是完全离线检查;`npm ci --ignore-scripts` 也需要可用的 registry/缓存。安装忽略脚本不等于禁止网络,隔离目录不等于网络沙箱。库导入/客户端创建不联网、SDK 懒加载的承诺仅指运行时,不覆盖 npm 安装;冒烟验证应保持使用虚假凭据和离线适配器,不执行真实 `start()` 或发送。检查新客户端的 ESM 导出和类型、启动/停止/错误边界,以及 Webhook/`NotifyConfig` 未变;SDK HTTP 的 10 秒/1 MiB/禁止重定向保护不代表 WebSocket 帧也有 `createTransport` 的 JSON 响应保证。
|
|
39
|
+
|
|
38
40
|
工作流仅通过 `workflow_dispatch` 触发。正式版本映射 `latest`;`alpha.N`、`beta.N`、`rc.N` 映射对应预发布标签。不会让预发布额外覆盖 `latest`。手动表单通过环境变量传给脚本,不直接拼接到 shell。
|
|
39
41
|
|
|
40
42
|
## 发布与失败核验
|
|
@@ -47,11 +49,11 @@
|
|
|
47
49
|
- 测试或仅验证任务成功不表示发布成功。以正式发布任务结果和 registry 状态为准:
|
|
48
50
|
|
|
49
51
|
```sh
|
|
50
|
-
npm view @quecue/notify-core@0.
|
|
52
|
+
npm view @quecue/notify-core@0.2.0 version dist.integrity --json --registry=https://registry.npmjs.org/
|
|
51
53
|
npm view @quecue/notify-core dist-tags --json --registry=https://registry.npmjs.org/
|
|
52
54
|
```
|
|
53
55
|
|
|
54
|
-
应用应声明正常的 npm 运行时依赖,如 `"@quecue/notify-core": "0.
|
|
56
|
+
应用应声明正常的 npm 运行时依赖,如 `"@quecue/notify-core": "0.2.0"`,在版本可用后更新锁文件并回归;无需保存 vendor tarball、复制通讯运行时或检出兄弟仓库。
|
|
55
57
|
|
|
56
58
|
## 官方参考
|
|
57
59
|
|
package/README.en.md
CHANGED
|
@@ -7,17 +7,17 @@ A small Node.js library for Telegram and Feishu: message planning, a Markdown re
|
|
|
7
7
|
Originally extracted from [dev-notify](https://github.com/QueCue/dev-notify) and maintained independently at [QueCue/notify-core](https://github.com/QueCue/notify-core).
|
|
8
8
|
|
|
9
9
|
- ESM with TypeScript declarations; Node.js 22 or later.
|
|
10
|
-
-
|
|
11
|
-
- Telegram
|
|
12
|
-
-
|
|
10
|
+
- Webhook/Telegram transport uses built-in `fetch` and `crypto`; the Feishu client adds the official MIT-licensed `@larksuiteoapi/node-sdk`, pinned to `1.76.0` as a runtime dependency.
|
|
11
|
+
- Telegram sending and selected query/button APIs; Feishu custom-bot Webhook sending plus self-built app WebSocket message reception and HTTP sending/replies; **no card-action callbacks**.
|
|
12
|
+
- No credential discovery from environment variables, application configuration or keyrings, and no automatic polling or long connections. Importing and creating a Feishu client are offline; the SDK is lazy-loaded, and reception requires an explicit `start()`.
|
|
13
13
|
|
|
14
14
|
## Status and installation
|
|
15
15
|
|
|
16
|
-
MIT-licensed source; npm package name: `@quecue/notify-core`. The registry is authoritative for available versions:
|
|
16
|
+
MIT-licensed source; npm package name: `@quecue/notify-core`. The Feishu self-built app client below is available starting with `0.2.0`; see the [changelog](CHANGELOG.en.md). The registry is authoritative for available versions:
|
|
17
17
|
|
|
18
18
|
```sh
|
|
19
19
|
npm view @quecue/notify-core version --registry=https://registry.npmjs.org/
|
|
20
|
-
npm install @quecue/notify-core@0.
|
|
20
|
+
npm install @quecue/notify-core@0.2.0
|
|
21
21
|
```
|
|
22
22
|
|
|
23
23
|
Applications can declare it as a normal runtime dependency. npm installs it automatically, without a source checkout, vendor archive or embedded copy. Import the ESM API and declarations through `@quecue/notify-core`.
|
|
@@ -34,7 +34,7 @@ npm pack --pack-destination releases
|
|
|
34
34
|
Install the generated file in your own Node.js application, replacing the path:
|
|
35
35
|
|
|
36
36
|
```sh
|
|
37
|
-
npm install /path/to/quecue-notify-core-0.
|
|
37
|
+
npm install /path/to/quecue-notify-core-0.2.0.tgz
|
|
38
38
|
```
|
|
39
39
|
|
|
40
40
|
No other project needs to be checked out. Use the declared ESM entry; CommonJS `require()` and browser compatibility are not guaranteed.
|
|
@@ -99,6 +99,7 @@ console.log(result.ok); // true; the injected transport performs no real request
|
|
|
99
99
|
| `splitPlainText(text, limit = 4000)` | UTF-16-unit budget without splitting surrogate pairs; not grapheme-cluster or atomic-delivery protection |
|
|
100
100
|
| `createTransport(options?)` | Defaults to a 10-second timeout, 1 MiB response limit, no redirects, strict UTF-8 and JSON-object checks; supports an injected fetcher |
|
|
101
101
|
| `createTelegramApi(token, options?)` | Selected API methods, without a polling loop, offset persistence or authorization |
|
|
102
|
+
| `createFeishuClient({appId,appSecret}, options)` | Synchronously creates a self-built app client; explicit `start()` receives messages, while `sendMessage`/`replyMessage` use HTTP |
|
|
102
103
|
|
|
103
104
|
Markdown supports readable headings, quotations, lists, emphasis, code and tables. Link addresses remain visible. Telegram parse rejection can fall back to plain text; Feishu can retry using compatible card components. Metadata that cannot fit safely is rejected instead of silently truncated.
|
|
104
105
|
|
|
@@ -122,10 +123,82 @@ const api = createTelegramApi(process.env.TELEGRAM_BOT_TOKEN ?? '', {
|
|
|
122
123
|
|
|
123
124
|
Allowed methods: `sendMessage`, `editMessageReplyMarkup`, `answerCallbackQuery`, `getMe`, `getUpdates`, `getWebhookInfo`. The library never deletes a Webhook. `getUpdates` and Webhooks are mutually exclusive; only one consumer may receive a Bot's updates. The application owns offsets, deduplication, backlogs and process coordination.
|
|
124
125
|
|
|
126
|
+
### Feishu self-built apps: WebSocket reception and HTTP replies
|
|
127
|
+
|
|
128
|
+
This client is separate from existing Webhook `makePlan`/`deliver`; `NotifyConfig` is unchanged. **Only self-built apps support long connections; custom-bot Webhook URLs and secrets cannot receive events.** WebSocket receives only `im.message.receive_v1` message events; sends/replies use HTTP APIs. Card-action callbacks are unsupported, and the host does not need a public callback endpoint.
|
|
129
|
+
|
|
130
|
+
Configure the app in the Feishu developer console first:
|
|
131
|
+
|
|
132
|
+
1. Create a self-built app and enable its bot capability.
|
|
133
|
+
2. Select long-connection mode under events and callbacks, and subscribe to `im.message.receive_v1`.
|
|
134
|
+
3. Request `im:message:send_as_bot` for sending, and as needed `im:message.p2p_msg:readonly` for private messages and `im:message.group_at_msg:readonly` for group mentions. The platform is authoritative for permissions, approvals and visibility.
|
|
135
|
+
4. Publish an app version and ensure the user account is within its availability scope. The runtime needs outbound access to Feishu services.
|
|
136
|
+
|
|
137
|
+
Minimal receive-and-reply example (host-selected user only; production also needs deduplication and full authorization):
|
|
138
|
+
|
|
139
|
+
```js
|
|
140
|
+
import { createFeishuClient } from '@quecue/notify-core';
|
|
141
|
+
|
|
142
|
+
// The host still reads credentials and the allowlist; never hard-code real values.
|
|
143
|
+
const allowedOpenId = process.env.FEISHU_ALLOWED_OPEN_ID ?? '';
|
|
144
|
+
const client = createFeishuClient({
|
|
145
|
+
appId: process.env.FEISHU_APP_ID ?? '',
|
|
146
|
+
appSecret: process.env.FEISHU_APP_SECRET ?? '',
|
|
147
|
+
}, {
|
|
148
|
+
onMessage: async (event) => {
|
|
149
|
+
// Avoid bot self-replies and check the sender before executing any business action.
|
|
150
|
+
if (event.sender.sender_type !== 'user' || !allowedOpenId ||
|
|
151
|
+
event.sender.sender_id?.open_id !== allowedOpenId) return;
|
|
152
|
+
if (event.message.message_type !== 'text') return;
|
|
153
|
+
const content = JSON.parse(event.message.content);
|
|
154
|
+
if (typeof content.text !== 'string') return;
|
|
155
|
+
await client.replyMessage(event.message.message_id, {
|
|
156
|
+
msgType: 'text',
|
|
157
|
+
content: JSON.stringify({ text: 'Received your text message' }),
|
|
158
|
+
});
|
|
159
|
+
},
|
|
160
|
+
onError: (error) => console.error('Feishu operation failed', error.operation),
|
|
161
|
+
startTimeoutMs: 10_000,
|
|
162
|
+
}); // Synchronous, offline creation.
|
|
163
|
+
|
|
164
|
+
process.once('SIGINT', () => client.stop());
|
|
165
|
+
await client.start(); // Explicit network access; resolves only when WebSocket is ready.
|
|
166
|
+
```
|
|
167
|
+
|
|
168
|
+
`onMessage` receives the SDK-shaped `FeishuMessageEvent`, not a wrapper with an extra `event` layer: `event.event_id?`, `event.sender.sender_id.open_id?`, `event.sender.sender_type`, and `event.message` fields `message_id`, `chat_id`, `chat_type`, `message_type`, `content`. `content` is a JSON string suitable for `JSON.parse`; do not assume non-text messages have a `text` field.
|
|
169
|
+
|
|
170
|
+
Client contract:
|
|
171
|
+
|
|
172
|
+
- `createFeishuClient({appId,appSecret}, {onMessage, onError?, startTimeoutMs?, adapterFactory?})` returns synchronously. `adapterFactory` supports offline tests or a custom SDK adapter; custom implementations own their network and redaction boundaries.
|
|
173
|
+
- `await client.start()` waits for actual WS readiness, with a default `10000` ms timeout. Timeout or fatal errors reject the Promise; repeated `start()` is idempotent. `client.state` is `idle`, `connecting`, `connected`, `reconnecting`, `failed` or `stopped`.
|
|
174
|
+
- `client.stop()` synchronously closes the connection and disables reconnection, canceling a pending `start()` by rejecting its Promise; the client can be started again. It **does not wait for or cancel** callbacks or HTTP requests already in progress.
|
|
175
|
+
- `onMessage` is awaited and must finish within **3 seconds**. Failure is safely reported to `onError`, then a safe error is thrown so the SDK returns a failure ACK. Throws/rejections from `onError` are isolated without logging the original error. The 10-second HTTP timeout is not the callback time budget.
|
|
176
|
+
- The host owns `event_id`/`message_id` deduplication, sender/chat authorization, bot self-reply prevention and a durable queue for slow work. Disconnects/redelivery provide no exactly-once guarantee; callback retries can repeat business effects. The example does not implement durable deduplication. Multiple long-connected clients for one app receive in cluster mode, not broadcast mode.
|
|
177
|
+
|
|
178
|
+
Sending does not require `start()` and makes an HTTP request directly:
|
|
179
|
+
|
|
180
|
+
```js
|
|
181
|
+
const sent = await client.sendMessage({
|
|
182
|
+
receiveId: 'oc_EXAMPLE', // Replace with a host-validated recipient.
|
|
183
|
+
receiveIdType: 'chat_id', // Optional; defaults to chat_id.
|
|
184
|
+
msgType: 'text',
|
|
185
|
+
content: JSON.stringify({ text: 'Proactive message' }),
|
|
186
|
+
// uuid: 'host-provided-request-id',
|
|
187
|
+
});
|
|
188
|
+
// sent: { messageId, chatId? }
|
|
189
|
+
```
|
|
190
|
+
|
|
191
|
+
`client.replyMessage(messageId, {msgType, content, replyInThread?, uuid?})` also returns `{messageId,chatId?}`; `content` is a JSON string for the selected message type. Neither method uses message planning or automatic splitting.
|
|
192
|
+
|
|
193
|
+
The official MIT-licensed `@larksuiteoapi/node-sdk@1.76.0` handles app tokens, the binary protocol, framing, heartbeats and reconnection; SDK logging is disabled by default. SDK HTTP is independently configured with a 10-second timeout, 1 MiB limit and no redirects. **WebSocket frames are not covered by `createTransport`'s 1 MiB JSON-response guarantee**, nor do they use its `request`/`fetcher` injection.
|
|
194
|
+
|
|
195
|
+
Reference: [official SDK WebSocket event reception](https://github.com/larksuite/node-sdk#receive-events-by-websocket-connection).
|
|
196
|
+
|
|
125
197
|
## Errors and delivery semantics
|
|
126
198
|
|
|
127
|
-
- Built-in transport-generated safe errors omit original URLs, response bodies and underlying network exception text. `NotifyError` is the base class for `TransportError`, `HttpError` and `
|
|
199
|
+
- Built-in transport-generated safe errors omit original URLs, response bodies and underlying network exception text. `NotifyError` is the base class for `TransportError`, `HttpError`, `TelegramApiError` and `FeishuError`. Custom translators, transports and callback-owned output are outside this guarantee and must be sanitized by the caller.
|
|
128
200
|
- `HttpError.status` describes an already-parsed HTTP failure. Invalid JSON or UTF-8 may first produce `TransportError`; not every HTTP 401 is guaranteed to become the same error type.
|
|
201
|
+
- `FeishuError extends NotifyError`: `operation` is `configuration`, `connection`, `stopped`, `send`, `reply` or `handler`; optional `code` is numeric. Generated errors omit SDK text. Handler failures are wrapped as safe errors rather than forwarding the original callback exception to the SDK or `onError`.
|
|
129
202
|
- `TelegramApiError` has `code`, `retryAfterSeconds` and `fatal`. `fatal` is true only for 401/404/409 and does not terminate the host process.
|
|
130
203
|
- `createTelegramApi` retries a 429 at most once within its default 60-second wait threshold. Set `maxRateLimitWaitSeconds: 0` to disable this internal wait. Other network failures are not automatically resent.
|
|
131
204
|
- `deliver` has separate handling for valid Telegram 429 waits up to 60 seconds, format fallback for the current part, and 250 ms spacing between successful Feishu parts. Combined fallback paths do not imply a two-request maximum for the whole operation.
|
package/README.md
CHANGED
|
@@ -7,17 +7,17 @@
|
|
|
7
7
|
代码最初提取自 [dev-notify](https://github.com/QueCue/dev-notify),在 [QueCue/notify-core](https://github.com/QueCue/notify-core) 独立维护。
|
|
8
8
|
|
|
9
9
|
- ESM,提供 TypeScript 类型,要求 Node.js 22 或更新版本。
|
|
10
|
-
-
|
|
11
|
-
- Telegram:发送消息,以及有限的查询/按钮 API;飞书:自定义机器人 Webhook
|
|
12
|
-
-
|
|
10
|
+
- Webhook/Telegram 传输使用 Node 内置 `fetch` 和 `crypto`;飞书客户端新增官方 MIT 许可 SDK `@larksuiteoapi/node-sdk`,运行时依赖固定为 `1.76.0`。
|
|
11
|
+
- Telegram:发送消息,以及有限的查询/按钮 API;飞书:自定义机器人 Webhook 发送,以及自建应用 WebSocket 收消息、HTTP API 发送/回复;**不支持卡片动作回调**。
|
|
12
|
+
- 不读取环境变量或配置文件来取得凭据,不访问系统凭据库,不自动启动轮询或长连接。导入和创建飞书客户端均不联网,SDK 懒加载;显式 `start()` 才启动接收。
|
|
13
13
|
|
|
14
14
|
## 当前状态与安装
|
|
15
15
|
|
|
16
|
-
源码使用 MIT 许可证,npm 包名为 `@quecue/notify-core
|
|
16
|
+
源码使用 MIT 许可证,npm 包名为 `@quecue/notify-core`。从 `0.2.0` 起提供下述飞书自建应用客户端,版本变化见[变更记录](CHANGELOG.md)。实际可用版本以 registry 为准:
|
|
17
17
|
|
|
18
18
|
```sh
|
|
19
19
|
npm view @quecue/notify-core version --registry=https://registry.npmjs.org/
|
|
20
|
-
npm install @quecue/notify-core@0.
|
|
20
|
+
npm install @quecue/notify-core@0.2.0
|
|
21
21
|
```
|
|
22
22
|
|
|
23
23
|
应用可将它声明为普通运行时依赖,npm 会自动安装,不需要用户检出本仓库、复制 vendor 包或手工嵌入通讯源码。ESM/类型入口均通过 `@quecue/notify-core` 导入。
|
|
@@ -34,7 +34,7 @@ npm pack --pack-destination releases
|
|
|
34
34
|
在你的 Node.js 项目中安装生成的文件(将路径换为实际位置):
|
|
35
35
|
|
|
36
36
|
```sh
|
|
37
|
-
npm install /path/to/quecue-notify-core-0.
|
|
37
|
+
npm install /path/to/quecue-notify-core-0.2.0.tgz
|
|
38
38
|
```
|
|
39
39
|
|
|
40
40
|
`npm ci` 需要完整源码检出,包括锁文件;不需要同时检出其他项目。运行时只支持本包声明的 ESM 入口,不保证 `require()` 或浏览器环境兼容性。
|
|
@@ -99,6 +99,7 @@ console.log(result.ok); // true;使用注入传输,没有真实发送。
|
|
|
99
99
|
| `splitPlainText(text, limit = 4000)` | 按 UTF-16 单元预算分段,不切断代理对;保持正文,不保证不拆开组合字形或整条消息原子投递 |
|
|
100
100
|
| `createTransport(options?)` | 10 秒超时、1 MiB 响应上限、禁止重定向、严格 UTF-8/JSON 对象检查;返回可注入 `fetcher` 的请求函数 |
|
|
101
101
|
| `createTelegramApi(token, options?)` | 固定 Telegram 方法集合,不启动轮询、不持久化 offset、不提供身份授权 |
|
|
102
|
+
| `createFeishuClient({appId,appSecret}, options)` | 同步创建自建应用客户端;显式 `start()` 接收消息,`sendMessage`/`replyMessage` 通过 HTTP 发送 |
|
|
102
103
|
|
|
103
104
|
Markdown 支持标题、引用、列表、内联强调、代码和表格的可读渲染,链接地址保留在可见文字中。Telegram 解析失败可回退纯文本;飞书卡片组件被拒绝时可尝试兼容组件。过长标题/footer 等不能安全分段时明确失败,不悄悄截断。
|
|
104
105
|
|
|
@@ -122,10 +123,82 @@ const api = createTelegramApi(process.env.TELEGRAM_BOT_TOKEN ?? '', {
|
|
|
122
123
|
|
|
123
124
|
允许的方法仅为 `sendMessage`、`editMessageReplyMarkup`、`answerCallbackQuery`、`getMe`、`getUpdates` 和 `getWebhookInfo`。不会删除现有 Webhook。`getUpdates` 与 Webhook 互斥,同一个 Bot 只能有一个更新消费者;offset、去重、积压消息、业务授权和进程互斥由应用管理。
|
|
124
125
|
|
|
126
|
+
### 飞书自建应用:长连接接收与 HTTP 回复
|
|
127
|
+
|
|
128
|
+
此入口独立于现有 Webhook `makePlan`/`deliver`,`NotifyConfig` 不变。**只有自建应用支持长连接;自定义机器人 Webhook 地址和 `secret` 不能用于接收。** WebSocket 仅接收 `im.message.receive_v1` 消息事件,发送/回复走 HTTP API,不支持卡片动作回调,也不需要宿主提供公网回调地址。
|
|
129
|
+
|
|
130
|
+
先在飞书开放平台完成配置:
|
|
131
|
+
|
|
132
|
+
1. 创建自建应用并启用机器人能力。
|
|
133
|
+
2. 在“事件与回调”选择长连接模式,订阅 `im.message.receive_v1`。
|
|
134
|
+
3. 申请发送权限 `im:message:send_as_bot`,按需申请私聊接收权限 `im:message.p2p_msg:readonly`、群聊 @机器人接收权限 `im:message.group_at_msg:readonly`;具体权限、审批及可见范围以平台为准。
|
|
135
|
+
4. 发布应用版本,并确保使用账号在应用可用范围内;运行环境需能访问飞书公网服务。
|
|
136
|
+
|
|
137
|
+
最小接收并回复示例(只允许宿主指定的用户,生产环境还需去重和完整授权):
|
|
138
|
+
|
|
139
|
+
```js
|
|
140
|
+
import { createFeishuClient } from '@quecue/notify-core';
|
|
141
|
+
|
|
142
|
+
// 凭据及授权名单仍由宿主读取;不要把真实值写进源码。
|
|
143
|
+
const allowedOpenId = process.env.FEISHU_ALLOWED_OPEN_ID ?? '';
|
|
144
|
+
const client = createFeishuClient({
|
|
145
|
+
appId: process.env.FEISHU_APP_ID ?? '',
|
|
146
|
+
appSecret: process.env.FEISHU_APP_SECRET ?? '',
|
|
147
|
+
}, {
|
|
148
|
+
onMessage: async (event) => {
|
|
149
|
+
// 防止机器人自回复,并验证发送者;不要对所有入站消息自动执行业务。
|
|
150
|
+
if (event.sender.sender_type !== 'user' || !allowedOpenId ||
|
|
151
|
+
event.sender.sender_id?.open_id !== allowedOpenId) return;
|
|
152
|
+
if (event.message.message_type !== 'text') return;
|
|
153
|
+
const content = JSON.parse(event.message.content);
|
|
154
|
+
if (typeof content.text !== 'string') return;
|
|
155
|
+
await client.replyMessage(event.message.message_id, {
|
|
156
|
+
msgType: 'text',
|
|
157
|
+
content: JSON.stringify({ text: '收到你的文本消息' }),
|
|
158
|
+
});
|
|
159
|
+
},
|
|
160
|
+
onError: (error) => console.error('飞书操作失败', error.operation),
|
|
161
|
+
startTimeoutMs: 10_000,
|
|
162
|
+
}); // 同步返回,不联网。
|
|
163
|
+
|
|
164
|
+
process.once('SIGINT', () => client.stop());
|
|
165
|
+
await client.start(); // 主动联网;实际 WebSocket ready 后才完成。
|
|
166
|
+
```
|
|
167
|
+
|
|
168
|
+
`onMessage` 收到 SDK 原格式 `FeishuMessageEvent`,不是包含额外 `event` 层的包装:`event.event_id?`、`event.sender.sender_id.open_id?`、`event.sender.sender_type`,以及 `event.message` 的 `message_id`、`chat_id`、`chat_type`、`message_type`、`content`。`content` 是 JSON 字符串,可用 `JSON.parse`;非文本消息不要假设存在 `text` 字段。
|
|
169
|
+
|
|
170
|
+
客户端约定:
|
|
171
|
+
|
|
172
|
+
- `createFeishuClient({appId,appSecret}, {onMessage, onError?, startTimeoutMs?, adapterFactory?})` 同步返回客户端。`adapterFactory` 用于离线测试或自定义 SDK 适配器;自定义实现须自行维护网络和脱敏边界。
|
|
173
|
+
- `await client.start()` 等待实际 WS ready,默认超时 `10000` 毫秒;超时或致命错误使 Promise 拒绝,重复 `start()` 幂等。`client.state` 为 `idle`/`connecting`/`connected`/`reconnecting`/`failed`/`stopped`。
|
|
174
|
+
- `client.stop()` 同步关闭长连接并禁用重连,取消正在等待的 `start()`(其 Promise 拒绝);之后可再次 `start()`。它**不等待或取消**已执行中的消息回调或 HTTP 请求。
|
|
175
|
+
- `onMessage` 会被 await,必须在 **3 秒内结束**。失败会先安全报告给 `onError`,再抛安全错误,让 SDK 返回失败 ACK。`onError` 自身抛错或拒绝会被库隔离,不输出原日志。HTTP 请求的 10 秒超时不是消息回调的时间预算。
|
|
176
|
+
- 宿主负责按 `event_id`/`message_id` 去重、发送者/会话授权、防机器人自回复,并将耗时任务放入持久队列。断线与重推不保证恰好一次,重试回调可能重复业务副作用;上例未实现持久去重。相同应用的多个长连客户端按集群模式接收,不是广播。
|
|
177
|
+
|
|
178
|
+
主动发送不要求先 `start()`,会直接通过 HTTP 联网:
|
|
179
|
+
|
|
180
|
+
```js
|
|
181
|
+
const sent = await client.sendMessage({
|
|
182
|
+
receiveId: 'oc_EXAMPLE', // 换成宿主验证过的接收人。
|
|
183
|
+
receiveIdType: 'chat_id', // 可省略,默认 chat_id。
|
|
184
|
+
msgType: 'text',
|
|
185
|
+
content: JSON.stringify({ text: '主动发送' }),
|
|
186
|
+
// uuid: '由宿主提供的请求标识',
|
|
187
|
+
});
|
|
188
|
+
// sent: { messageId, chatId? }
|
|
189
|
+
```
|
|
190
|
+
|
|
191
|
+
`client.replyMessage(messageId, {msgType, content, replyInThread?, uuid?})` 同样返回 `{messageId,chatId?}`,`content` 使用相应消息类型的 JSON 字符串;这两个方法不经过消息规划或自动分段。
|
|
192
|
+
|
|
193
|
+
官方 MIT 许可 SDK `@larksuiteoapi/node-sdk@1.76.0` 负责应用 token、二进制协议、分帧、心跳和重连;默认不输出 SDK 日志。SDK HTTP 独立配置为 10 秒超时、1 MiB 上限、禁止重定向;**WebSocket 帧不适用 `createTransport` 的 1 MiB JSON 响应保证**,也不通过该传输的 `request`/`fetcher` 注入。
|
|
194
|
+
|
|
195
|
+
参考:[官方 SDK 长连接接收事件](https://github.com/larksuite/node-sdk#receive-events-by-websocket-connection)。
|
|
196
|
+
|
|
125
197
|
## 错误、重试与投递语义
|
|
126
198
|
|
|
127
|
-
- 内置传输不把底层 URL、响应原文或网络异常原文放进它生成的安全错误。`NotifyError` 是公共基类,`TransportError`、`HttpError` 和 `
|
|
199
|
+
- 内置传输不把底层 URL、响应原文或网络异常原文放进它生成的安全错误。`NotifyError` 是公共基类,`TransportError`、`HttpError`、`TelegramApiError` 和 `FeishuError` 可按类型处理。自定义翻译/传输及业务回调自身的输出不在此保证内,调用方须自行脱敏。
|
|
128
200
|
- `HttpError.status` 是已解析响应的 HTTP 状态;无效 JSON/UTF-8 等可能先表现为 `TransportError`。不要假设所有 HTTP 401 都必然映射为某一种错误。
|
|
201
|
+
- `FeishuError extends NotifyError`:`operation` 区分 `configuration`/`connection`/`stopped`/`send`/`reply`/`handler`,`code?` 为数值;生成的错误不包含 SDK 原文。消息处理失败会被包装为安全错误,不会把业务回调原异常交给 SDK 或 `onError`。
|
|
129
202
|
- `TelegramApiError` 提供 `code`、`retryAfterSeconds`、`fatal`;`fatal` 只对 401/404/409 为真,是供宿主参考的标记,不会自动退出进程。
|
|
130
203
|
- `createTelegramApi` 默认对一次 429 进行至多一次等待重试,等待阈值为 60 秒;`maxRateLimitWaitSeconds: 0` 关闭内层等待。其余网络失败不自动重发。
|
|
131
204
|
- `deliver` 有自己的策略:Telegram 明确 429 且有效 `retry_after` 在 60 秒内时有限重试;格式兼容回退只处理当前分段;飞书成功分段间等待 250 毫秒。上述回退叠加时不能理解为整个调用只会发生两次 HTTP 请求。
|
package/SECURITY.en.md
CHANGED
|
@@ -6,17 +6,20 @@ This is a communication library, not an authorization system or security agent.
|
|
|
6
6
|
|
|
7
7
|
## Data and network
|
|
8
8
|
|
|
9
|
-
- Importing
|
|
9
|
+
- Importing and creating a Feishu client are offline and do not read environment credentials, application configuration or system keyrings. Explicit sends/API calls transmit content; Feishu `start()` initiates network reception, while `sendMessage`/`replyMessage` use HTTP without requiring startup. Service-side retention is outside this library's control.
|
|
10
10
|
- The Telegram factory fixes its official HTTPS base and limits methods. Generic JSON transport and Feishu Webhook configuration are **not URL access controls**. Hosts must validate schemes, destination domains, recipients and links; never pass untrusted external URLs directly.
|
|
11
11
|
- Default transport preserves runtime TLS verification, rejects redirects, defaults to 10 seconds and 1 MiB, and checks UTF-8 and JSON objects. Limits are configurable; weakened settings or substituted transports require their own review.
|
|
12
|
+
- The Feishu client adds the official MIT-licensed runtime dependency `@larksuiteoapi/node-sdk`, pinned to `1.76.0` and lazy-loaded. It manages app tokens, the binary protocol, framing, heartbeats and reconnection. SDK HTTP is independently configured with a 10-second timeout, 1 MiB limit and no redirects. **SDK WebSocket frames are outside `createTransport`'s 1 MiB JSON-response guarantee**; do not extend that guarantee to all SDK traffic.
|
|
12
13
|
- Proxy configuration belongs to the host. Default fetch behavior depends on Node startup and network settings; unconditional proxy bypass is not promised.
|
|
13
14
|
- Built-in safe error summaries omit original URLs, response bodies and underlying network exception text. This is not general content redaction: titles, bodies, footers, buttons, custom translators, injected transports and callbacks can contain sensitive data.
|
|
14
15
|
- `NotifyConfig` is a type contract, not account validation or exhaustive runtime schema validation. Hosts and services must handle missing, invalid or revoked credentials.
|
|
15
16
|
|
|
16
17
|
## Receiving and authorization
|
|
17
18
|
|
|
18
|
-
- `getUpdates` is a request API, not a background receiver, Webhook server, offset store, deduplicator or sender authorization system.
|
|
19
|
-
- A Bot must have one update consumer. The library does not delete Webhooks, take over other consumers or automatically execute backlogged actions.
|
|
19
|
+
- Telegram `getUpdates` is a request API, not a background receiver, Webhook server, offset store, deduplicator or sender authorization system.
|
|
20
|
+
- A Telegram Bot must have one update consumer. The library does not delete Webhooks, take over other consumers or automatically execute backlogged actions.
|
|
21
|
+
- Only Feishu self-built apps can explicitly `start()` a long connection for `im.message.receive_v1`. Webhook URLs/secrets cannot receive events; card-action callbacks are unsupported. Hosts must enable the bot, configure long-connection event subscriptions, request send/receive permissions, publish a version and restrict app availability (see the [README setup](README.en.md#feishu-self-built-apps-websocket-reception-and-http-replies)).
|
|
22
|
+
- Incoming messages remain untrusted business input. Hosts own `event_id`/`message_id` deduplication, user/chat authorization, bot self-reply prevention and a durable queue for slow work. Reception is not permission to execute commands. Disconnects/redelivery provide no exactly-once guarantee; multiple clients for one app receive in cluster mode, not broadcast mode.
|
|
20
23
|
- `reply.users` selects recipients shown buttons; it grants no permission to submit callbacks. Hosts must verify user/chat/request identities, lengths, expiry and routing.
|
|
21
24
|
|
|
22
25
|
## Retries and state
|
|
@@ -24,6 +27,8 @@ This is a communication library, not an authorization system or security agent.
|
|
|
24
27
|
- A timeout may follow server acceptance; delivery may be unknown. Exactly-once or atomic multipart delivery is not promised.
|
|
25
28
|
- Explicit rate-limit or format rejection can trigger bounded retries/fallbacks, not repeated application-side effects.
|
|
26
29
|
- `onSent` runs after reported delivery; a callback failure cannot undo the message. Do not blindly resend a whole plan because `ok` is false.
|
|
30
|
+
- Feishu `onMessage` is awaited and must finish within 3 seconds. Failure is safely reported to `onError`, then a safe error is thrown so the SDK returns a failure ACK, potentially causing redelivery. `FeishuError extends NotifyError`; `operation` is `configuration`, `connection`, `stopped`, `send`, `reply` or `handler`, and optional `code` is numeric, without SDK text. Throws/rejections from `onError` are isolated; SDK logs and original callback exceptions are not output by default. Hosts must still sanitize their own logs.
|
|
31
|
+
- Feishu startup waits up to 10000 ms by default for actual WS readiness; timeout/fatal errors reject, and repeated startup is idempotent. Synchronous `stop()` closes the connection, disables reconnection and rejects pending startup; restarting is supported. It does not wait for or cancel callbacks/HTTP requests in progress and cannot undo business actions.
|
|
27
32
|
- The library does not persist queues, tokens, content, offsets or logs. Application storage, logs, backups, permissions and updates require separate assessment.
|
|
28
33
|
|
|
29
34
|
## Reporting vulnerabilities
|
package/SECURITY.md
CHANGED
|
@@ -6,17 +6,20 @@
|
|
|
6
6
|
|
|
7
7
|
## 数据与网络
|
|
8
8
|
|
|
9
|
-
-
|
|
9
|
+
- 导入库及创建飞书客户端不会联网,也不会读取应用配置、环境凭据或系统凭据库。显式投递/API 调用才发送内容;飞书 `start()` 主动联网接收,`sendMessage`/`replyMessage` 无需先启动即可通过 HTTP 发送。外部平台的数据保留规则不由本库控制。
|
|
10
10
|
- Telegram API 工厂固定官方 HTTPS 基址并限制方法;通用 JSON 传输和飞书 Webhook 配置并不构成 URL 访问控制。宿主必须验证协议、目的地域名、接收人以及跳转链接,不能将外部不可信 URL 直接交给传输。
|
|
11
11
|
- 默认传输保留运行时 TLS 验证,禁止重定向,默认超时 10 秒、响应最多 1 MiB,严格检查 UTF-8 和 JSON 对象。限制可配置;降低保护或替换传输后的安全性由宿主承担。
|
|
12
|
+
- 飞书客户端新增固定版本 `1.76.0` 的官方 MIT 许可运行时依赖 `@larksuiteoapi/node-sdk`,懒加载并由其管理应用 token、二进制协议、分帧、心跳和重连。SDK HTTP 独立配置 10 秒超时、1 MiB 上限及禁止重定向;**SDK WebSocket 帧不受 `createTransport` 的 1 MiB JSON 响应保证约束**,不能将该保证推广到所有 SDK 流量。
|
|
12
13
|
- 代理由宿主提供。默认 fetch 的行为受 Node 启动和网络配置影响;库不保证完全不使用代理,也不提供自动代理发现或凭据隔离。
|
|
13
14
|
- 内置错误对 URL、响应原文、底层异常采用安全摘要。不要把这理解为对任何用户输入的通用脱敏:标题、正文、footer、按钮内容、自定义翻译、注入传输和业务回调仍可能包含敏感信息。
|
|
14
15
|
- `NotifyConfig` 是类型约定,不会查询凭据是否有效,也不实现全面运行时 schema 验证。缺少、错误或已撤销的凭据由应用和服务端处理。
|
|
15
16
|
|
|
16
17
|
## 接收与授权
|
|
17
18
|
|
|
18
|
-
- `getUpdates` 只是请求接口,没有内置后台循环、Webhook 接收器、offset
|
|
19
|
-
- 只能由一个消费者读取同一 Bot 的更新;本库不会删除现有 Webhook、抢占其他消费者或自动恢复积压命令。
|
|
19
|
+
- Telegram `getUpdates` 只是请求接口,没有内置后台循环、Webhook 接收器、offset 保存、重复更新过滤或发送者授权。
|
|
20
|
+
- 只能由一个消费者读取同一 Telegram Bot 的更新;本库不会删除现有 Webhook、抢占其他消费者或自动恢复积压命令。
|
|
21
|
+
- 飞书仅自建应用可通过显式 `start()` 长连接接收 `im.message.receive_v1`;Webhook 地址/secret 不可用于接收,不支持卡片动作回调。宿主需启用机器人、配置长连接事件订阅、申请发送/接收权限、发布版本并限制可用范围(配置步骤见 [README](README.md#飞书自建应用长连接接收与-http-回复))。
|
|
22
|
+
- 入站消息仍是不可信业务输入。宿主负责 `event_id`/`message_id` 去重、用户和会话授权、防机器人自回复及耗时任务的持久队列;不要假设收到消息就有权执行命令。断线与重推不保证恰好一次,同一应用的多个长连客户端是集群分发而非广播。
|
|
20
23
|
- `reply.users` 只控制展示给哪些接收人按钮,不授予回调提交权。应用必须自行核验用户、聊天、请求身份、长度、时效和路由。
|
|
21
24
|
|
|
22
25
|
## 重试与状态
|
|
@@ -24,7 +27,9 @@
|
|
|
24
27
|
- 网络超时可能发生在服务端已接受消息之后;投递可能未知,不保证恰好一次或长消息原子发送。
|
|
25
28
|
- 明确的限流和格式兼容拒绝可能触发有界重试/回退;它们不允许宿主重复执行有副作用的业务操作。
|
|
26
29
|
- `onSent` 在成功发送后调用,其失败不能撤销已投递消息。不要因为 `ok:false` 就不加区分地重发整个计划。
|
|
27
|
-
-
|
|
30
|
+
- 飞书 `onMessage` 被 await,须在 3 秒内结束;失败先安全报告 `onError`,再抛安全错误使 SDK 返回失败 ACK,可能触发重推。`FeishuError extends NotifyError`,`operation` 区分 `configuration`/`connection`/`stopped`/`send`/`reply`/`handler`,`code?` 为数值,不携带 SDK 原文。`onError` 自身抛错/拒绝会被隔离,默认不输出 SDK 日志或回调原异常;宿主日志仍须自行脱敏。
|
|
31
|
+
- 飞书启动默认等待实际 WS ready 最多 10000 毫秒;超时/致命错误拒绝,重复启动幂等。同步 `stop()` 关闭长连、禁用重连并拒绝正在等待的启动,之后可重启;不等待或取消已执行中的回调/HTTP 请求,不能作为撤销业务操作的保证。
|
|
32
|
+
- 库不持久保存队列、Token、消息正文、offset 或日志。应用自己的存储、日志、备份、权限和更新机制需单独评估。
|
|
28
33
|
|
|
29
34
|
## 报告安全问题
|
|
30
35
|
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import type { FeishuAdapter, FeishuCredentials } from './feishu.js';
|
|
2
|
+
type Sdk = typeof import('@larksuiteoapi/node-sdk');
|
|
3
|
+
/** 内部适配层;SDK 懒加载,导入 notify-core 或创建客户端都不会建立连接。 */
|
|
4
|
+
export declare function createFeishuSdkAdapter(credentials: FeishuCredentials, loadSdk?: () => Promise<Sdk>): Promise<FeishuAdapter>;
|
|
5
|
+
export {};
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/** 内部适配层;SDK 懒加载,导入 notify-core 或创建客户端都不会建立连接。 */
|
|
2
|
+
export async function createFeishuSdkAdapter(credentials, loadSdk = () => import('@larksuiteoapi/node-sdk')) {
|
|
3
|
+
const sdk = await loadSdk();
|
|
4
|
+
// 不使用 SDK 默认日志,避免连接 URL、凭据、事件正文或底层异常进入控制台。
|
|
5
|
+
const quiet = () => { };
|
|
6
|
+
const logger = { error: quiet, warn: quiet, info: quiet, debug: quiet, trace: quiet };
|
|
7
|
+
const httpInstance = sdk.defaultHttpInstance.create({ timeout: 10000, maxRedirects: 0,
|
|
8
|
+
maxContentLength: 1024 * 1024, maxBodyLength: 1024 * 1024, responseType: 'json',
|
|
9
|
+
transitional: { silentJSONParsing: false } });
|
|
10
|
+
// SDK 的长连接发现请求自带 timeout,仍统一收紧到本库的 HTTP 边界。
|
|
11
|
+
httpInstance.interceptors.request.use(request => { request.timeout = 10000; return request; });
|
|
12
|
+
httpInstance.interceptors.response.use(response => response.data);
|
|
13
|
+
const config = { ...credentials, appType: sdk.AppType.SelfBuild, domain: sdk.Domain.Feishu,
|
|
14
|
+
logger, httpInstance: httpInstance };
|
|
15
|
+
const client = new sdk.Client(config);
|
|
16
|
+
let connection;
|
|
17
|
+
return {
|
|
18
|
+
start(handlers) {
|
|
19
|
+
connection = new sdk.WSClient({ ...config, autoReconnect: true, handshakeTimeoutMs: 10000,
|
|
20
|
+
onReady: handlers.onReady, onError: handlers.onError,
|
|
21
|
+
onReconnecting: handlers.onReconnecting, onReconnected: handlers.onReconnected });
|
|
22
|
+
const eventDispatcher = new sdk.EventDispatcher({ logger }).register({
|
|
23
|
+
'im.message.receive_v1': handlers.onMessage,
|
|
24
|
+
});
|
|
25
|
+
return connection.start({ eventDispatcher });
|
|
26
|
+
},
|
|
27
|
+
close() {
|
|
28
|
+
const active = connection;
|
|
29
|
+
connection = undefined;
|
|
30
|
+
active?.close({ force: true });
|
|
31
|
+
},
|
|
32
|
+
sendMessage(message) {
|
|
33
|
+
return client.im.v1.message.create({ params: { receive_id_type: message.receiveIdType ?? 'chat_id' },
|
|
34
|
+
data: { receive_id: message.receiveId, msg_type: message.msgType, content: message.content,
|
|
35
|
+
...(message.uuid === undefined ? {} : { uuid: message.uuid }) } });
|
|
36
|
+
},
|
|
37
|
+
replyMessage(messageId, message) {
|
|
38
|
+
return client.im.v1.message.reply({ path: { message_id: messageId },
|
|
39
|
+
data: { msg_type: message.msgType, content: message.content,
|
|
40
|
+
...(message.replyInThread === undefined ? {} : { reply_in_thread: message.replyInThread }),
|
|
41
|
+
...(message.uuid === undefined ? {} : { uuid: message.uuid }) } });
|
|
42
|
+
},
|
|
43
|
+
};
|
|
44
|
+
}
|
package/dist/feishu.d.ts
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import { NotifyError } from './messages.js';
|
|
2
|
+
export interface FeishuCredentials {
|
|
3
|
+
appId: string;
|
|
4
|
+
appSecret: string;
|
|
5
|
+
}
|
|
6
|
+
export interface FeishuUserId {
|
|
7
|
+
open_id?: string;
|
|
8
|
+
user_id?: string;
|
|
9
|
+
union_id?: string;
|
|
10
|
+
}
|
|
11
|
+
/** 飞书 SDK 分发的消息事件;content 保持平台原始 JSON 字符串。 */
|
|
12
|
+
export interface FeishuMessageEvent {
|
|
13
|
+
event_id?: string;
|
|
14
|
+
event_type?: string;
|
|
15
|
+
create_time?: string;
|
|
16
|
+
tenant_key?: string;
|
|
17
|
+
sender: {
|
|
18
|
+
sender_id?: FeishuUserId;
|
|
19
|
+
sender_type: string;
|
|
20
|
+
tenant_key?: string;
|
|
21
|
+
};
|
|
22
|
+
message: {
|
|
23
|
+
message_id: string;
|
|
24
|
+
chat_id: string;
|
|
25
|
+
chat_type: string;
|
|
26
|
+
message_type: string;
|
|
27
|
+
content: string;
|
|
28
|
+
create_time: string;
|
|
29
|
+
update_time?: string;
|
|
30
|
+
root_id?: string;
|
|
31
|
+
parent_id?: string;
|
|
32
|
+
thread_id?: string;
|
|
33
|
+
mentions?: {
|
|
34
|
+
key: string;
|
|
35
|
+
id: FeishuUserId;
|
|
36
|
+
name: string;
|
|
37
|
+
tenant_key?: string;
|
|
38
|
+
}[];
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
export type FeishuReceiveIdType = 'chat_id' | 'open_id' | 'user_id' | 'union_id' | 'email';
|
|
42
|
+
export interface FeishuOutgoingMessage {
|
|
43
|
+
msgType: string;
|
|
44
|
+
content: string;
|
|
45
|
+
uuid?: string;
|
|
46
|
+
}
|
|
47
|
+
export interface FeishuSendMessage extends FeishuOutgoingMessage {
|
|
48
|
+
receiveId: string;
|
|
49
|
+
receiveIdType?: FeishuReceiveIdType;
|
|
50
|
+
}
|
|
51
|
+
export interface FeishuReplyMessage extends FeishuOutgoingMessage {
|
|
52
|
+
replyInThread?: boolean;
|
|
53
|
+
}
|
|
54
|
+
export interface FeishuMessageResult {
|
|
55
|
+
messageId: string;
|
|
56
|
+
chatId?: string;
|
|
57
|
+
}
|
|
58
|
+
export type FeishuState = 'idle' | 'connecting' | 'connected' | 'reconnecting' | 'failed' | 'stopped';
|
|
59
|
+
export type FeishuOperation = 'configuration' | 'connection' | 'stopped' | 'send' | 'reply' | 'handler';
|
|
60
|
+
export declare class FeishuError extends NotifyError {
|
|
61
|
+
readonly operation: FeishuOperation;
|
|
62
|
+
readonly code?: number | undefined;
|
|
63
|
+
constructor(operation: FeishuOperation, code?: number | undefined);
|
|
64
|
+
}
|
|
65
|
+
export interface FeishuConnectionHandlers {
|
|
66
|
+
onReady(): void;
|
|
67
|
+
onError(): void;
|
|
68
|
+
onReconnecting(): void;
|
|
69
|
+
onReconnected(): void;
|
|
70
|
+
onMessage(event: FeishuMessageEvent): Promise<void>;
|
|
71
|
+
}
|
|
72
|
+
export interface FeishuApiResponse {
|
|
73
|
+
code?: number;
|
|
74
|
+
data?: {
|
|
75
|
+
message_id?: string;
|
|
76
|
+
chat_id?: string;
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
/** 工厂及 start 之前的适配器必须无网络副作用;close 必须终止重连。 */
|
|
80
|
+
export interface FeishuAdapter {
|
|
81
|
+
start(handlers: FeishuConnectionHandlers): void | Promise<void>;
|
|
82
|
+
close(): void;
|
|
83
|
+
sendMessage(message: FeishuSendMessage): Promise<FeishuApiResponse>;
|
|
84
|
+
replyMessage(messageId: string, message: FeishuReplyMessage): Promise<FeishuApiResponse>;
|
|
85
|
+
}
|
|
86
|
+
export interface FeishuClientOptions {
|
|
87
|
+
onMessage?: (event: FeishuMessageEvent) => void | Promise<void>;
|
|
88
|
+
onError?: (error: FeishuError) => void | Promise<void>;
|
|
89
|
+
startTimeoutMs?: number;
|
|
90
|
+
adapterFactory?: (credentials: FeishuCredentials) => FeishuAdapter | Promise<FeishuAdapter>;
|
|
91
|
+
}
|
|
92
|
+
export interface FeishuClient {
|
|
93
|
+
readonly state: FeishuState;
|
|
94
|
+
start(): Promise<void>;
|
|
95
|
+
/** 关闭长连接,但不取消已经执行的业务回调或 HTTP 发送。 */
|
|
96
|
+
stop(): void;
|
|
97
|
+
sendMessage(message: FeishuSendMessage): Promise<FeishuMessageResult>;
|
|
98
|
+
replyMessage(messageId: string, message: FeishuReplyMessage): Promise<FeishuMessageResult>;
|
|
99
|
+
}
|
|
100
|
+
export declare function createFeishuClient(credentials: FeishuCredentials, options?: FeishuClientOptions): FeishuClient;
|
package/dist/feishu.js
ADDED
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
import { NotifyError } from './messages.js';
|
|
2
|
+
import { createFeishuSdkAdapter } from './feishu-sdk.js';
|
|
3
|
+
const errors = {
|
|
4
|
+
configuration: '飞书应用客户端配置无效。',
|
|
5
|
+
connection: '飞书长连接建立失败、超时或已终止。',
|
|
6
|
+
stopped: '飞书长连接启动已取消。',
|
|
7
|
+
send: '飞书消息发送失败,结果可能未知;不会自动重发。',
|
|
8
|
+
reply: '飞书消息回复失败,结果可能未知;不会自动重发。',
|
|
9
|
+
handler: '飞书消息处理失败。',
|
|
10
|
+
};
|
|
11
|
+
export class FeishuError extends NotifyError {
|
|
12
|
+
operation;
|
|
13
|
+
code;
|
|
14
|
+
constructor(operation, code) {
|
|
15
|
+
super(errors[operation]);
|
|
16
|
+
this.operation = operation;
|
|
17
|
+
this.code = code;
|
|
18
|
+
this.name = 'FeishuError';
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
const receiveIdTypes = new Set(['chat_id', 'open_id', 'user_id', 'union_id', 'email']);
|
|
22
|
+
const nonempty = (value) => typeof value === 'string' && value.trim().length > 0;
|
|
23
|
+
export function createFeishuClient(credentials, options = {}) {
|
|
24
|
+
const startTimeoutMs = options.startTimeoutMs ?? 10000;
|
|
25
|
+
if (!credentials || typeof credentials.appId !== 'string' || !/^cli_[0-9a-fA-F]{16}$/.test(credentials.appId) ||
|
|
26
|
+
!nonempty(credentials.appSecret) || !Number.isSafeInteger(startTimeoutMs) || startTimeoutMs < 1 || startTimeoutMs > 2147483647 ||
|
|
27
|
+
(options.onMessage !== undefined && typeof options.onMessage !== 'function') ||
|
|
28
|
+
(options.onError !== undefined && typeof options.onError !== 'function') ||
|
|
29
|
+
(options.adapterFactory !== undefined && typeof options.adapterFactory !== 'function'))
|
|
30
|
+
throw new FeishuError('configuration');
|
|
31
|
+
const config = { appId: credentials.appId, appSecret: credentials.appSecret };
|
|
32
|
+
const { onMessage, onError, adapterFactory = createFeishuSdkAdapter } = options;
|
|
33
|
+
let state = 'idle', session;
|
|
34
|
+
let adapter, initializing;
|
|
35
|
+
function report(error) {
|
|
36
|
+
// 错误观察回调不能破坏连接状态机或产生未处理的 Promise 拒绝。
|
|
37
|
+
void Promise.resolve().then(() => onError?.(error)).catch(() => { });
|
|
38
|
+
}
|
|
39
|
+
function getAdapter() {
|
|
40
|
+
if (!initializing)
|
|
41
|
+
initializing = Promise.resolve().then(() => adapterFactory({ ...config }))
|
|
42
|
+
.then(value => { adapter = value; return value; })
|
|
43
|
+
.catch(() => { initializing = undefined; throw new FeishuError('configuration'); });
|
|
44
|
+
return initializing;
|
|
45
|
+
}
|
|
46
|
+
function closeAdapter() {
|
|
47
|
+
try {
|
|
48
|
+
adapter?.close();
|
|
49
|
+
}
|
|
50
|
+
catch {
|
|
51
|
+
report(new FeishuError('connection'));
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
function fail(active, error) {
|
|
55
|
+
if (session !== active)
|
|
56
|
+
return;
|
|
57
|
+
session = undefined;
|
|
58
|
+
clearTimeout(active.timer);
|
|
59
|
+
state = 'failed';
|
|
60
|
+
closeAdapter();
|
|
61
|
+
active.reject(error);
|
|
62
|
+
report(error);
|
|
63
|
+
}
|
|
64
|
+
function start() {
|
|
65
|
+
if (session)
|
|
66
|
+
return session.promise;
|
|
67
|
+
if (!onMessage)
|
|
68
|
+
return Promise.reject(new FeishuError('configuration'));
|
|
69
|
+
let resolve, reject;
|
|
70
|
+
const active = { promise: new Promise((ok, no) => { resolve = ok; reject = no; }),
|
|
71
|
+
resolve: () => resolve(), reject: error => reject(error) };
|
|
72
|
+
session = active;
|
|
73
|
+
state = 'connecting';
|
|
74
|
+
active.timer = setTimeout(() => fail(active, new FeishuError('connection')), startTimeoutMs);
|
|
75
|
+
const ready = () => {
|
|
76
|
+
if (session !== active)
|
|
77
|
+
return;
|
|
78
|
+
clearTimeout(active.timer);
|
|
79
|
+
state = 'connected';
|
|
80
|
+
active.resolve();
|
|
81
|
+
};
|
|
82
|
+
void getAdapter().then(value => {
|
|
83
|
+
if (session !== active)
|
|
84
|
+
return;
|
|
85
|
+
return value.start({
|
|
86
|
+
onReady: ready,
|
|
87
|
+
onError: () => fail(active, new FeishuError('connection')),
|
|
88
|
+
onReconnecting: () => { if (session === active)
|
|
89
|
+
state = 'reconnecting'; },
|
|
90
|
+
onReconnected: ready,
|
|
91
|
+
onMessage: async (event) => {
|
|
92
|
+
if (session !== active)
|
|
93
|
+
return;
|
|
94
|
+
try {
|
|
95
|
+
await onMessage(event);
|
|
96
|
+
}
|
|
97
|
+
catch {
|
|
98
|
+
const error = new FeishuError('handler');
|
|
99
|
+
report(error);
|
|
100
|
+
// 交给 SDK 发送失败 ACK,而不是把失败处理伪装为已成功消费。
|
|
101
|
+
throw error;
|
|
102
|
+
}
|
|
103
|
+
},
|
|
104
|
+
});
|
|
105
|
+
}).catch(() => fail(active, new FeishuError('connection')));
|
|
106
|
+
return active.promise;
|
|
107
|
+
}
|
|
108
|
+
function stop() {
|
|
109
|
+
const active = session;
|
|
110
|
+
session = undefined;
|
|
111
|
+
state = 'stopped';
|
|
112
|
+
if (active) {
|
|
113
|
+
clearTimeout(active.timer);
|
|
114
|
+
active.reject(new FeishuError('stopped'));
|
|
115
|
+
}
|
|
116
|
+
closeAdapter();
|
|
117
|
+
}
|
|
118
|
+
async function send(operation, message, messageId) {
|
|
119
|
+
if (!message || !nonempty(message.msgType) || !nonempty(message.content) ||
|
|
120
|
+
(message.uuid !== undefined && !nonempty(message.uuid)))
|
|
121
|
+
throw new FeishuError('configuration');
|
|
122
|
+
if (operation === 'send') {
|
|
123
|
+
const target = message;
|
|
124
|
+
if (!nonempty(target.receiveId) || !receiveIdTypes.has(target.receiveIdType ?? 'chat_id'))
|
|
125
|
+
throw new FeishuError('configuration');
|
|
126
|
+
}
|
|
127
|
+
else if (!nonempty(messageId) ||
|
|
128
|
+
(message.replyInThread !== undefined && typeof message.replyInThread !== 'boolean'))
|
|
129
|
+
throw new FeishuError('configuration');
|
|
130
|
+
let response;
|
|
131
|
+
try {
|
|
132
|
+
const value = await getAdapter();
|
|
133
|
+
response = operation === 'send' ? await value.sendMessage({ ...message }) :
|
|
134
|
+
await value.replyMessage(messageId, { ...message });
|
|
135
|
+
}
|
|
136
|
+
catch {
|
|
137
|
+
throw new FeishuError(operation);
|
|
138
|
+
}
|
|
139
|
+
if (response?.code !== 0 || !nonempty(response.data?.message_id))
|
|
140
|
+
throw new FeishuError(operation, Number.isSafeInteger(response?.code) ? response.code : undefined);
|
|
141
|
+
return { messageId: response.data.message_id, ...(nonempty(response.data.chat_id) ? { chatId: response.data.chat_id } : {}) };
|
|
142
|
+
}
|
|
143
|
+
return { get state() { return state; }, start, stop,
|
|
144
|
+
sendMessage: message => send('send', message), replyMessage: (messageId, message) => send('reply', message, messageId) };
|
|
145
|
+
}
|
package/dist/index.d.ts
CHANGED
package/dist/index.js
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@quecue/notify-core",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "独立的 Telegram 与飞书通讯、格式化和传输边界库",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": { "type": "git", "url": "git+https://github.com/QueCue/notify-core.git" },
|
|
@@ -21,5 +21,8 @@
|
|
|
21
21
|
"devDependencies": {
|
|
22
22
|
"@types/node": "^22.0.0",
|
|
23
23
|
"typescript": "^5.9.0"
|
|
24
|
+
},
|
|
25
|
+
"dependencies": {
|
|
26
|
+
"@larksuiteoapi/node-sdk": "1.76.0"
|
|
24
27
|
}
|
|
25
28
|
}
|