@quecue/notify-core 0.0.0-bootstrap.0 → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,13 @@
1
+ # Changelog
2
+
3
+ English | [简体中文](CHANGELOG.md)
4
+
5
+ ## 0.1.0
6
+
7
+ First standalone release; the npm registry is authoritative for actual publication status and available versions.
8
+
9
+ - Extract independent Telegram/Feishu planning, formatting, splitting and delivery from [dev-notify](https://github.com/QueCue/dev-notify).
10
+ - Provide injectable bounded JSON transport, selected Telegram APIs, safe error types and plain-text splitting.
11
+ - Exclude application configuration, keyrings, polling loops, authorization and business state; hosts can inject translation and transport.
12
+ - Add Chinese/English usage and security documentation plus a publication checklist. There are 13 public API tests using simulated network responses and 8 release-validation tests, not exhaustive platform or real-service acceptance.
13
+ - Add a manually dispatched GitHub Actions npm release workflow: verification-only by default, separate OIDC identity and provenance for publishing, delivery of the same verified artifact, and remote integrity checks. The account owner must first initialize the npm package and configure trust.
package/CHANGELOG.md ADDED
@@ -0,0 +1,13 @@
1
+ # 更新记录
2
+
3
+ 简体中文 | [English](CHANGELOG.en.md)
4
+
5
+ ## 0.1.0
6
+
7
+ 首次独立发行;实际发布状态和可用版本以 npm registry 为准。
8
+
9
+ - 从 [dev-notify](https://github.com/QueCue/dev-notify) 提取独立的双渠道消息规划、格式化、分段和投递能力。
10
+ - 提供可注入的有界 JSON 传输、有限 Telegram API、安全错误类型及纯文本分段。
11
+ - 不包含应用配置、凭据库、更新接收循环、授权或业务状态;允许宿主注入翻译和传输。
12
+ - 补充中文/英文使用说明、安全边界和公开发布检查表;13 项公共 API 测试使用模拟网络,另有 8 项发行校验测试,不代表全平台或真实服务验收。
13
+ - 新增手动触发的 GitHub Actions npm 发行:默认仅验证,正式模式使用独立 OIDC 身份及 provenance,上传同次验证的候选包并核对远端完整性;npm 包初始化和信任配置须先由账户所有者完成。
@@ -0,0 +1,24 @@
1
+ # 贡献与本地验证
2
+
3
+ 本库仅维护 Telegram/飞书通用通讯,不接收应用专属命令、会话业务或授权策略。源码与贡献入口为 [QueCue/notify-core](https://github.com/QueCue/notify-core)。
4
+
5
+ ## 开发环境
6
+
7
+ - Node.js 22 或更高版本、npm。
8
+ - 在本仓库执行 `npm ci --ignore-scripts`、`npm test`。
9
+ - 测试默认禁止真实网络,使用虚假凭据和注入的 HTTP/等待函数;不应要求贡献者提供个人 Bot Token 或 Webhook。
10
+ - 修改 API 时同步 `.d.ts` 构建、中文/英文 README、错误边界和变更记录。不要手工编辑 `dist`。
11
+
12
+ 建议为限流、超时、格式回退、Unicode 分段、部分投递、自定义回调失败和错误脱敏补测试。保持默认导入无网络或配置副作用。传输与授权的边界应写清楚,不要使用测试通过来替代真实平台兼容承诺。
13
+
14
+ ## 提交范围
15
+
16
+ 不要提交 `.env`、Token、Webhook、真实接收人、消息原文、日志、node_modules 或包含这些内容的安装包。使用明显的假值,例如 `123:TEST_ONLY`;issue 和截图也需脱敏。
17
+
18
+ MIT 许可证及从 [dev-notify](https://github.com/QueCue/dev-notify) 提取的既有版权声明应保留。新增依赖、复制外部代码或改变发布方式前,请说明来源、许可证、目的及测试范围。
19
+
20
+ ## 版本包
21
+
22
+ 当前为 0.x API,不承诺跨版本不变;破坏性变化必须在变更记录和版本中明确体现。生成包前执行测试及 `npm pack --dry-run`,检查 [PUBLISHING.md](PUBLISHING.md)。相同版本号的正式发布包不可覆盖,不应以手改 tarball 的方式绕开版本更新。
23
+
24
+ 安全问题按 [SECURITY.md](SECURITY.md) 处理,不在公开 issue 中附秘密或完整攻击细节。仓库地址、权限和实际发布状态以维护者公开配置为准。
package/PUBLISHING.md ADDED
@@ -0,0 +1,61 @@
1
+ # npm 发布流程
2
+
3
+ 本说明面向 [QueCue/notify-core](https://github.com/QueCue/notify-core) 维护者。正式 npm 发行使用本仓库的 [GitHub Actions 工作流](https://github.com/QueCue/notify-core/actions/workflows/publish-npm.yml),不共享其他项目的发布身份。普通分支推送或版本标签推送均不会自动发布。
4
+
5
+ ## 包和发布边界
6
+
7
+ - 包名 `@quecue/notify-core`,MIT 许可证,公开 npm 包。保留从 [dev-notify](https://github.com/QueCue/dev-notify) 提取代码的版权声明。
8
+ - 没有第三方运行时依赖;开发工具由 package-lock 固定。npm 包仅含 dist 运行时/类型、公开说明、package.json 和许可证,不含测试、CI 脚本或应用凭据。
9
+ - GitHub Actions 使用托管 Ubuntu runner、Node.js 24 和 npm 11.21.0;只有正式发布任务拥有 `id-token: write`。不需要 `NPM_TOKEN`、`NODE_AUTH_TOKEN` secret 或本机 npm 登录。
10
+ - GitHub 仓库公开、工作流存在和测试通过,都不能证明 npm 账户侧信任已配置,也不等于包已经发布。
11
+
12
+ ## 首次建立 npm 包与信任
13
+
14
+ **npm 官方要求配置 Trusted Publisher 的包已经存在于 registry。** 尚不存在的包不能仅凭本工作流建立首次信任。账户所有者需先通过 npm 支持的认证流程初始化包,然后在该包的 npm 设置配置以下身份;不要把本机登录凭据或其他包的授权复制到 Actions。
15
+
16
+ | 字段 | 值 |
17
+ | --- | --- |
18
+ | Provider | GitHub Actions |
19
+ | Organization or user | `QueCue` |
20
+ | Repository | `notify-core` |
21
+ | Workflow filename | `publish-npm.yml`,不含目录 |
22
+ | Environment | 留空,本工作流不使用 environment |
23
+ | Publishing permission | 允许直接 `npm publish` |
24
+
25
+ 阶段发布也是 npm 提供的初始化方式,但会产生公开的 `0.0.0-stage` 占位版本,并需账户认证和后续审核;本工作流不会擅自创建该额外版本。具体初始化操作应由维护者明确选择及执行。若首次已直接发布 `0.1.0`,Actions 不会覆盖它或把不同 tarball 当作同一次发行,应使用下一版本验证后续 OIDC 发行。
26
+
27
+ 新建且尚未成功发布的信任配置会在两天后过期;请在包和工作流准备好后配置。成功发行会验证绑定,不能把它理解为所有已绑定配置每两天失效。本流程不自动改 dist-tag,不要求额外的标签管理权限。
28
+
29
+ ## 日常发行
30
+
31
+ 1. 检查待公开源码、历史、版本、CHANGELOG、中英文 README 和安全说明,避免 Token、Webhook、真实配置、本机路径及无关内容。
32
+ 2. 修改 package.json 和 package-lock 的版本。npm 已有且内容不同的版本必须换版本,不覆盖或重打历史版本。
33
+ 3. 本地执行 `npm ci --ignore-scripts`、`npm test`。当前回归包含模拟网络的公共 API 测试及发行校验测试,不代表全部平台、代理或真实服务验收。
34
+ 4. 将已审核的源码、锁文件和工作流推送到 GitHub。进入 Actions → **Publish npm** → **Run workflow**,选择待发行分支或提交所在分支,填写与 package.json 完全一致的 `version`。
35
+ 5. 保持 `verify_only=true` 先运行验证:安装锁定依赖、编译、测试、生成 tarball、校验精确文件白名单/元数据/SHA512,再隔离安装同一个 tarball,核对 ESM 导出及 TypeScript 类型。下载 `npm-package` artifact 检查。
36
+ 6. 确认 npm 信任已建立且版本可发行后,对**同一提交**再次运行,设置 `verify_only=false`。正式运行会重新验证,并只上传本次验证任务保存的同一 tarball;不是直接上传之前某次运行或本机目录中的包。
37
+
38
+ 工作流仅通过 `workflow_dispatch` 触发。正式版本映射 `latest`;`alpha.N`、`beta.N`、`rc.N` 映射对应预发布标签。不会让预发布额外覆盖 `latest`。手动表单通过环境变量传给脚本,不直接拼接到 shell。
39
+
40
+ ## 发布与失败核验
41
+
42
+ - 正式任务核对下载包 SHA512,使用 OIDC 执行带 provenance 的 `npm publish`,再只读核对 registry 版本、integrity 和 dist-tag。
43
+ - 包尚未初始化、查询失败或认证失败:停止,不回退到长期 Token,不借用其他仓库身份。
44
+ - 已有版本仅在 SHA512 与本次包一致时跳过上传;不同内容立即停止,不能覆盖。构建环境或文档变化也可能产生不同 tarball。
45
+ - 上传失败或结果未知不自动重发;先查询 registry。上传成功后版本可见性最多等待约 10 分钟,标签最多约 1 分钟;超时不能当作未发布。
46
+ - 不自动修正既有标签。若版本已存在但标签不匹配,应由维护者核实影响后单独处理。
47
+ - 测试或仅验证任务成功不表示发布成功。以正式发布任务结果和 registry 状态为准:
48
+
49
+ ```sh
50
+ npm view @quecue/notify-core@0.1.0 version dist.integrity --json --registry=https://registry.npmjs.org/
51
+ npm view @quecue/notify-core dist-tags --json --registry=https://registry.npmjs.org/
52
+ ```
53
+
54
+ 应用应声明正常的 npm 运行时依赖,如 `"@quecue/notify-core": "0.1.0"`,在版本可用后更新锁文件并回归;无需保存 vendor tarball、复制通讯运行时或检出兄弟仓库。
55
+
56
+ ## 官方参考
57
+
58
+ - [Trusted Publishing](https://docs.npmjs.com/trusted-publishers/)
59
+ - [npm trust:包必须已存在的前置条件](https://docs.npmjs.com/cli/v11/commands/npm-trust/)
60
+ - [阶段发布及首次包占位行为](https://docs.npmjs.com/staged-publishing/)
61
+ - [npm publish](https://docs.npmjs.com/cli/v11/commands/npm-publish)
package/README.en.md ADDED
@@ -0,0 +1,143 @@
1
+ # notify-core
2
+
3
+ English | [简体中文](README.md)
4
+
5
+ A small Node.js library for Telegram and Feishu: message planning, a Markdown rendering subset, Unicode-safe splitting, bounded JSON transport, and an injectable Telegram API. **It does not implement application-specific workflows.**
6
+
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
+
9
+ - ESM with TypeScript declarations; Node.js 22 or later.
10
+ - No third-party runtime dependencies; uses built-in `fetch` and `crypto`.
11
+ - Telegram message sending and selected query/button APIs; Feishu custom-bot Webhook sending, **not Feishu inbound events or card callbacks**.
12
+ - Importing does not read credentials, application configuration or keyrings, start polling, or perform network requests.
13
+
14
+ ## Status and installation
15
+
16
+ MIT-licensed source; npm package name: `@quecue/notify-core`. The registry is authoritative for available versions:
17
+
18
+ ```sh
19
+ npm view @quecue/notify-core version --registry=https://registry.npmjs.org/
20
+ npm install @quecue/notify-core@0.1.0
21
+ ```
22
+
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`.
24
+
25
+ For source development or unpublished changes, build a local candidate package:
26
+
27
+ ```sh
28
+ npm ci --ignore-scripts
29
+ npm test
30
+ node -e "require('node:fs').mkdirSync('releases', {recursive: true})"
31
+ npm pack --pack-destination releases
32
+ ```
33
+
34
+ Install the generated file in your own Node.js application, replacing the path:
35
+
36
+ ```sh
37
+ npm install /path/to/quecue-notify-core-0.1.0.tgz
38
+ ```
39
+
40
+ No other project needs to be checked out. Use the declared ESM entry; CommonJS `require()` and browser compatibility are not guaranteed.
41
+
42
+ ## Quick start
43
+
44
+ ```js
45
+ import { makePlan, deliver } from '@quecue/notify-core';
46
+
47
+ // The application reads and validates credentials; the library does not discover them.
48
+ const config = {
49
+ telegram: {
50
+ token: process.env.TELEGRAM_BOT_TOKEN ?? '',
51
+ chatIds: [process.env.TELEGRAM_CHAT_ID ?? ''],
52
+ },
53
+ feishu: {
54
+ webhook: process.env.FEISHU_WEBHOOK ?? '',
55
+ secret: process.env.FEISHU_SECRET ?? '',
56
+ },
57
+ };
58
+ const message = {
59
+ title: 'Build completed',
60
+ content: '**Result**: passed\n\n' + 'Full output\n'.repeat(1000),
61
+ footer: 'From CI',
62
+ level: 'success',
63
+ };
64
+
65
+ // Planning is offline. Validate credentials and recipients before choosing to send.
66
+ const plan = makePlan('telegram', message, config);
67
+ console.log(`Planned ${plan.items.length} parts`);
68
+ // const result = await deliver(plan, config); // Uncommenting performs a real send.
69
+ // if (!result.ok) console.error(result.error);
70
+ ```
71
+
72
+ For Feishu, use `makePlan('feishu', message, config)`; a non-empty `secret` enables custom-bot signing. Channel enablement, credential precedence and authorization belong to the application, not `NotifyConfig`. A non-empty string does not prove that a credential or destination is valid.
73
+
74
+ ### Offline delivery example
75
+
76
+ ```js
77
+ import { makePlan, deliver } from '@quecue/notify-core';
78
+ const config = {
79
+ telegram: { token: '123:TEST_ONLY', chatIds: ['111'] },
80
+ feishu: { webhook: '', secret: '' },
81
+ };
82
+ const plan = makePlan('telegram', {
83
+ title: 'Offline example', content: 'No network', footer: '', level: 'info',
84
+ }, config);
85
+ const result = await deliver(plan, config, async () => ({
86
+ ok: true, result: { message_id: 1 },
87
+ }));
88
+ console.log(result.ok); // true; the injected transport performs no real request.
89
+ ```
90
+
91
+ ## API and boundaries
92
+
93
+ | API | Behavior |
94
+ | --- | --- |
95
+ | `createChannels(translate?)` | Per-instance message planner and delivery functions |
96
+ | `makePlan(channel, message, config)` | Offline planning, without credential or account validation |
97
+ | `deliver(plan, config, request?, sleep?)` | Sequential delivery returning `{channel,sent,planned,ok,error?}`; stops remaining parts for a failed recipient while continuing others |
98
+ | `createFormatting(translate?)` | Markdown subset parsing and splitting, not full CommonMark |
99
+ | `splitPlainText(text, limit = 4000)` | UTF-16-unit budget without splitting surrogate pairs; not grapheme-cluster or atomic-delivery protection |
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
+ | `createTelegramApi(token, options?)` | Selected API methods, without a polling loop, offset persistence or authorization |
102
+
103
+ 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
+ Optional `reply` metadata is `{token,url,users,callbackData?}`: an identifier, a Feishu link, recipients shown Telegram buttons, and optional callback data. The default callback data is `reply:` plus `token`. **This does not grant input permission**; the host must validate identities, callback length, expiry, routing and authorization.
106
+
107
+ ### Transport and proxies
108
+
109
+ There is no built-in proxy URL setting. Inject an application-owned `request` or `fetcher` when required, preserving timeout, TLS, redirect and response-size protections. Default `fetch` proxy behavior depends on Node and startup configuration; unconditional direct connectivity is not guaranteed.
110
+
111
+ `createTransport({timeoutMs,maxResponseBytes,translate})` returns `(url,payload,fetcher?)`. This generic transport **does not restrict destination URLs**. Applications must validate schemes, domains and recipients rather than passing untrusted external URLs directly.
112
+
113
+ ### Telegram methods
114
+
115
+ ```js
116
+ import { createTelegramApi, TelegramApiError } from '@quecue/notify-core';
117
+ const api = createTelegramApi(process.env.TELEGRAM_BOT_TOKEN ?? '', {
118
+ maxRateLimitWaitSeconds: 0, // Let the application schedule retries; errors remain visible.
119
+ });
120
+ // const bot = await api('getMe', {}); // An explicit call performs network I/O.
121
+ ```
122
+
123
+ 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
+ ## Errors and delivery semantics
126
+
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 `TelegramApiError`. Custom translators, transports and callbacks are outside this guarantee and must sanitize their own errors.
128
+ - `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.
129
+ - `TelegramApiError` has `code`, `retryAfterSeconds` and `fatal`. `fatal` is true only for 401/404/409 and does not terminate the host process.
130
+ - `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
+ - `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.
132
+ - A timeout can occur after server acceptance: **no exactly-once delivery, durable offline queue, or multipart atomicity is promised**.
133
+ - `onSent` runs after reported server success. If mapping or the callback fails, `sent` may be positive while `ok` is false. Retrying the entire plan can duplicate messages.
134
+
135
+ Tests use simulated network responses, not exhaustive real-service, proxy or Node-version acceptance. Sharing this library does not share application credentials or permissions.
136
+
137
+ ## Development
138
+
139
+ - [Security boundaries and reporting](SECURITY.en.md)
140
+ - [Contribution and local checks](CONTRIBUTING.md)
141
+ - [Release checklist](PUBLISHING.md)
142
+ - [Changes](CHANGELOG.en.md)
143
+ - [MIT license](LICENSE)
package/README.md CHANGED
@@ -1,9 +1,143 @@
1
- # notify-core 注册占位包
1
+ # notify-core
2
2
 
3
- 此版本仅用于初始化 npm 包名和配置 GitHub Actions Trusted Publishing。
3
+ 简体中文 | [English](README.en.md)
4
4
 
5
- **不包含任何运行时实现,请勿安装使用。** 正式版本及可用标签以 npm registry 为准。
5
+ 面向 Node.js 的轻量 Telegram/飞书通讯库:消息规划、Markdown 子集渲染、Unicode 安全分段、HTTP 边界和可注入的 Telegram API。**不绑定具体业务或应用。**
6
6
 
7
- 源码与后续发行说明:https://github.com/QueCue/notify-core
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
+ - 无第三方运行时依赖;使用 Node 内置 `fetch` 和 `crypto`。
11
+ - Telegram:发送消息,以及有限的查询/按钮 API;飞书:自定义机器人 Webhook 发送,**不包含飞书消息或卡片回调接收服务**。
12
+ - 不读取环境变量或配置文件来取得凭据,不访问系统凭据库,不启动轮询或后台服务;单纯导入不会联网。
13
+
14
+ ## 当前状态与安装
15
+
16
+ 源码使用 MIT 许可证,npm 包名为 `@quecue/notify-core`。实际可用版本以 registry 为准:
17
+
18
+ ```sh
19
+ npm view @quecue/notify-core version --registry=https://registry.npmjs.org/
20
+ npm install @quecue/notify-core@0.1.0
21
+ ```
22
+
23
+ 应用可将它声明为普通运行时依赖,npm 会自动安装,不需要用户检出本仓库、复制 vendor 包或手工嵌入通讯源码。ESM/类型入口均通过 `@quecue/notify-core` 导入。
24
+
25
+ 如需从源码构建或测试尚未发布的修改,也可生成本地候选包:
26
+
27
+ ```sh
28
+ npm ci --ignore-scripts
29
+ npm test
30
+ node -e "require('node:fs').mkdirSync('releases', {recursive: true})"
31
+ npm pack --pack-destination releases
32
+ ```
33
+
34
+ 在你的 Node.js 项目中安装生成的文件(将路径换为实际位置):
35
+
36
+ ```sh
37
+ npm install /path/to/quecue-notify-core-0.1.0.tgz
38
+ ```
39
+
40
+ `npm ci` 需要完整源码检出,包括锁文件;不需要同时检出其他项目。运行时只支持本包声明的 ESM 入口,不保证 `require()` 或浏览器环境兼容性。
41
+
42
+ ## 快速开始:双渠道消息规划
43
+
44
+ ```js
45
+ import { makePlan, deliver } from '@quecue/notify-core';
46
+
47
+ // 环境变量由应用主动读取;库本身不发现、保存或验证你的凭据来源。
48
+ const config = {
49
+ telegram: {
50
+ token: process.env.TELEGRAM_BOT_TOKEN ?? '',
51
+ chatIds: [process.env.TELEGRAM_CHAT_ID ?? ''],
52
+ },
53
+ feishu: {
54
+ webhook: process.env.FEISHU_WEBHOOK ?? '',
55
+ secret: process.env.FEISHU_SECRET ?? '',
56
+ },
57
+ };
58
+ const message = {
59
+ title: '构建完成',
60
+ content: '**结果**:通过\n\n' + '完整输出\n'.repeat(1000),
61
+ footer: '来自 CI',
62
+ level: 'success',
63
+ };
64
+
65
+ // makePlan 只规划,不联网。由应用先校验渠道凭据和接收人,再明确发送。
66
+ const plan = makePlan('telegram', message, config);
67
+ console.log(`准备发送 ${plan.items.length} 段`);
68
+ // const result = await deliver(plan, config); // 取消注释会向 Telegram 真实发送。
69
+ // if (!result.ok) console.error(result.error);
70
+ ```
71
+
72
+ 飞书改用 `makePlan('feishu', message, config)`;`secret` 非空时按飞书自定义机器人协议签名。`enabled`、默认渠道、环境变量优先级和接收人授权都是应用职责,不是 `NotifyConfig` 的字段。接口使用结构类型;不要把非空字符串误认为已经验证了 Token 或 Webhook。
73
+
74
+ ### 离线模拟投递
75
+
76
+ ```js
77
+ import { makePlan, deliver } from '@quecue/notify-core';
78
+ const config = {
79
+ telegram: { token: '123:TEST_ONLY', chatIds: ['111'] },
80
+ feishu: { webhook: '', secret: '' },
81
+ };
82
+ const plan = makePlan('telegram', {
83
+ title: '离线示例', content: '不会联网', footer: '', level: 'info',
84
+ }, config);
85
+ const result = await deliver(plan, config, async () => ({
86
+ ok: true, result: { message_id: 1 },
87
+ }));
88
+ console.log(result.ok); // true;使用注入传输,没有真实发送。
89
+ ```
90
+
91
+ ## 核心接口
92
+
93
+ | 接口 | 用途与默认边界 |
94
+ | --- | --- |
95
+ | `createChannels(translate?)` | 创建消息规划/投递实例;翻译只作用于该实例 |
96
+ | `makePlan(channel, message, config)` | 生成发送计划,不联网、不检查账号真实有效性 |
97
+ | `deliver(plan, config, request?, sleep?)` | 顺序投递,返回 `{channel,sent,planned,ok,error?}`;失败后停止该接收人的后续分段,其他接收人继续 |
98
+ | `createFormatting(translate?)` | 创建 Markdown 子集解析与分段函数;不是完整 CommonMark 实现 |
99
+ | `splitPlainText(text, limit = 4000)` | 按 UTF-16 单元预算分段,不切断代理对;保持正文,不保证不拆开组合字形或整条消息原子投递 |
100
+ | `createTransport(options?)` | 10 秒超时、1 MiB 响应上限、禁止重定向、严格 UTF-8/JSON 对象检查;返回可注入 `fetcher` 的请求函数 |
101
+ | `createTelegramApi(token, options?)` | 固定 Telegram 方法集合,不启动轮询、不持久化 offset、不提供身份授权 |
102
+
103
+ Markdown 支持标题、引用、列表、内联强调、代码和表格的可读渲染,链接地址保留在可见文字中。Telegram 解析失败可回退纯文本;飞书卡片组件被拒绝时可尝试兼容组件。过长标题/footer 等不能安全分段时明确失败,不悄悄截断。
104
+
105
+ `reply` 是可选展示信息:`{token,url,users,callbackData?}`,分别用于按钮标识、飞书跳转 URL、展示 Telegram 按钮的接收人列表及自定义回调数据。默认回调数据为 `reply:` 加 `token`。**这些字段不授予权限**;宿主仍须校验用户、回调长度、过期时间、会话路由和接收端身份。
106
+
107
+ ### 传输与代理
108
+
109
+ 公共库不内置代理地址配置。需要代理时,应用通过 `request` 或 `fetcher` 注入自己的 HTTP 客户端,并保留超时、TLS 校验、禁止重定向和响应大小限制。默认 `fetch` 的代理行为受 Node 运行时及启动配置影响;库不会承诺无条件直连。
110
+
111
+ `createTransport({timeoutMs,maxResponseBytes,translate})` 返回的函数签名为 `(url,payload,fetcher?)`。它是通用 JSON 传输,**不限制目标 URL**;应用必须验证目标域名、协议和接收人,不能直接接受外部不可信 URL。
112
+
113
+ ### Telegram 查询与按钮
114
+
115
+ ```js
116
+ import { createTelegramApi, TelegramApiError } from '@quecue/notify-core';
117
+ const api = createTelegramApi(process.env.TELEGRAM_BOT_TOKEN ?? '', {
118
+ maxRateLimitWaitSeconds: 0, // 交由应用决定何时再请求;不会忽略限流错误。
119
+ });
120
+ // const bot = await api('getMe', {}); // 明确调用才联网。
121
+ ```
122
+
123
+ 允许的方法仅为 `sendMessage`、`editMessageReplyMarkup`、`answerCallbackQuery`、`getMe`、`getUpdates` 和 `getWebhookInfo`。不会删除现有 Webhook。`getUpdates` 与 Webhook 互斥,同一个 Bot 只能有一个更新消费者;offset、去重、积压消息、业务授权和进程互斥由应用管理。
124
+
125
+ ## 错误、重试与投递语义
126
+
127
+ - 内置传输不把底层 URL、响应原文或网络异常原文放进它生成的安全错误。`NotifyError` 是公共基类,`TransportError`、`HttpError` 和 `TelegramApiError` 可按类型处理。自定义翻译/传输/回调不在此保证内,调用方须自行脱敏。
128
+ - `HttpError.status` 是已解析响应的 HTTP 状态;无效 JSON/UTF-8 等可能先表现为 `TransportError`。不要假设所有 HTTP 401 都必然映射为某一种错误。
129
+ - `TelegramApiError` 提供 `code`、`retryAfterSeconds`、`fatal`;`fatal` 只对 401/404/409 为真,是供宿主参考的标记,不会自动退出进程。
130
+ - `createTelegramApi` 默认对一次 429 进行至多一次等待重试,等待阈值为 60 秒;`maxRateLimitWaitSeconds: 0` 关闭内层等待。其余网络失败不自动重发。
131
+ - `deliver` 有自己的策略:Telegram 明确 429 且有效 `retry_after` 在 60 秒内时有限重试;格式兼容回退只处理当前分段;飞书成功分段间等待 250 毫秒。上述回退叠加时不能理解为整个调用只会发生两次 HTTP 请求。
132
+ - 服务端可能已接受消息,但响应丢失,投递结果会未知;**不保证恰好一次、离线补发或整个长消息的原子性**。
133
+ - `onSent` 在服务端报告成功后调用。若映射/回调失败,`sent` 仍可能大于零而 `ok` 为 false;不要因此重发整条计划,否则可能重复通知。
134
+
135
+ ## 开发、版本与安全
136
+
137
+ 当前自动化测试使用模拟网络,不代表已完成全部 Node 版本、代理环境或真实平台验收。复用库源码不代表共享应用的凭据或系统权限。
138
+
139
+ - [安全边界与报告方式](SECURITY.md)
140
+ - [贡献与本地验证](CONTRIBUTING.md)
141
+ - [发布前检查与版本包更新](PUBLISHING.md)
142
+ - [变更记录](CHANGELOG.md)
143
+ - [MIT 许可证](LICENSE)
package/SECURITY.en.md ADDED
@@ -0,0 +1,35 @@
1
+ # Security boundaries
2
+
3
+ English | [简体中文](SECURITY.md)
4
+
5
+ This is a communication library, not an authorization system or security agent. MIT licensing does not imply an independent security audit or suitability for every environment; no such audit is claimed.
6
+
7
+ ## Data and network
8
+
9
+ - Importing does not access the network, environment credentials, application configuration or system keyrings. Explicit sends/API calls transmit selected content to the configured service, whose retention is outside this library's control.
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
+ - 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
+ - Proxy configuration belongs to the host. Default fetch behavior depends on Node startup and network settings; unconditional proxy bypass is not promised.
13
+ - 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
+ - `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
+ ## Receiving and authorization
17
+
18
+ - `getUpdates` is a request API, not a background receiver, Webhook server, offset store, deduplicator or sender authorization system. Feishu supports custom-bot Webhook sending only, not inbound events.
19
+ - A Bot must have one update consumer. The library does not delete Webhooks, take over other consumers or automatically execute backlogged actions.
20
+ - `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
+
22
+ ## Retries and state
23
+
24
+ - A timeout may follow server acceptance; delivery may be unknown. Exactly-once or atomic multipart delivery is not promised.
25
+ - Explicit rate-limit or format rejection can trigger bounded retries/fallbacks, not repeated application-side effects.
26
+ - `onSent` runs after reported delivery; a callback failure cannot undo the message. Do not blindly resend a whole plan because `ok` is false.
27
+ - The library does not persist queues, tokens, content, offsets or logs. Application storage, logs, backups, permissions and updates require separate assessment.
28
+
29
+ ## Reporting vulnerabilities
30
+
31
+ Source repository: [QueCue/notify-core](https://github.com/QueCue/notify-core). GitHub private vulnerability reporting is not claimed to be enabled, and no dedicated public security email is currently specified.
32
+
33
+ Prefer a private report if the Security page offers one. Otherwise, open a non-sensitive issue requesting a private contact, without publishing exploit details or secrets. Never include tokens, Webhooks or private chat content in a public issue. Revoke/rotate exposed credentials first; deleting text does not undo exposure.
34
+
35
+ Include the affected version, reproduction conditions, expected/actual behavior and sanitized evidence. For ordinary contributions, see [CONTRIBUTING.md](CONTRIBUTING.md).
package/SECURITY.md ADDED
@@ -0,0 +1,35 @@
1
+ # 安全边界
2
+
3
+ 简体中文 | [English](SECURITY.en.md)
4
+
5
+ 本项目是通讯库,不是权限系统或安全代理。代码采用 MIT 许可证,但尚未经过独立安全审计,不应据此声称适用于所有环境。
6
+
7
+ ## 数据与网络
8
+
9
+ - 导入库不会联网、读取应用配置、环境凭据或系统凭据库。应用显式调用投递/API 后,请求正文才发送到所选平台;外部平台的数据保留规则不由本库控制。
10
+ - Telegram API 工厂固定官方 HTTPS 基址并限制方法;通用 JSON 传输和飞书 Webhook 配置并不构成 URL 访问控制。宿主必须验证协议、目的地域名、接收人以及跳转链接,不能将外部不可信 URL 直接交给传输。
11
+ - 默认传输保留运行时 TLS 验证,禁止重定向,默认超时 10 秒、响应最多 1 MiB,严格检查 UTF-8 和 JSON 对象。限制可配置;降低保护或替换传输后的安全性由宿主承担。
12
+ - 代理由宿主提供。默认 fetch 的行为受 Node 启动和网络配置影响;库不保证完全不使用代理,也不提供自动代理发现或凭据隔离。
13
+ - 内置错误对 URL、响应原文、底层异常采用安全摘要。不要把这理解为对任何用户输入的通用脱敏:标题、正文、footer、按钮内容、自定义翻译、注入传输和业务回调仍可能包含敏感信息。
14
+ - `NotifyConfig` 是类型约定,不会查询凭据是否有效,也不实现全面运行时 schema 验证。缺少、错误或已撤销的凭据由应用和服务端处理。
15
+
16
+ ## 接收与授权
17
+
18
+ - `getUpdates` 只是请求接口,没有内置后台循环、Webhook 接收器、offset 保存、重复更新过滤或发送者授权。飞书当前只有自定义机器人 Webhook 发送,不提供事件接收服务。
19
+ - 只能由一个消费者读取同一 Bot 的更新;本库不会删除现有 Webhook、抢占其他消费者或自动恢复积压命令。
20
+ - `reply.users` 只控制展示给哪些接收人按钮,不授予回调提交权。应用必须自行核验用户、聊天、请求身份、长度、时效和路由。
21
+
22
+ ## 重试与状态
23
+
24
+ - 网络超时可能发生在服务端已接受消息之后;投递可能未知,不保证恰好一次或长消息原子发送。
25
+ - 明确的限流和格式兼容拒绝可能触发有界重试/回退;它们不允许宿主重复执行有副作用的业务操作。
26
+ - `onSent` 在成功发送后调用,其失败不能撤销已投递消息。不要因为 `ok:false` 就不加区分地重发整个计划。
27
+ - 库不保存持久队列、Token、消息正文、offset 或日志。应用自己的存储、日志、备份、权限和更新机制需单独评估。
28
+
29
+ ## 报告安全问题
30
+
31
+ 源码仓库为 [QueCue/notify-core](https://github.com/QueCue/notify-core)。目前未声明已启用 GitHub 私密漏洞报告,也没有公开的专用安全邮箱。
32
+
33
+ 如 Security 页面提供私密报告入口,请优先使用;否则可以提交不含漏洞细节或秘密的 issue 请求私密联系方式。不要在公开 issue 中发布 Token、Webhook、个人聊天内容或可直接用于攻击的细节。若凭据已经泄漏,请先在平台撤销/轮换;删除文本不能撤销泄漏。
34
+
35
+ 报告时提供受影响版本、复现条件、预期与实际行为及脱敏证据。普通功能问题与贡献流程见 [CONTRIBUTING.md](CONTRIBUTING.md)。
@@ -0,0 +1,49 @@
1
+ import { type Translator } from './messages.js';
2
+ import { type Request } from './transport.js';
3
+ export type Channel = 'telegram' | 'feishu';
4
+ export interface NotifyConfig {
5
+ telegram: {
6
+ token: string;
7
+ chatIds: string[];
8
+ };
9
+ feishu: {
10
+ webhook: string;
11
+ secret: string;
12
+ };
13
+ }
14
+ export interface Message {
15
+ title: string;
16
+ content: string;
17
+ footer: string;
18
+ level: 'info' | 'success' | 'warn' | 'error';
19
+ reply?: {
20
+ token: string;
21
+ url: string;
22
+ users: string[];
23
+ callbackData?: string;
24
+ };
25
+ }
26
+ export interface Item {
27
+ payload: Record<string, any>;
28
+ plain?: string;
29
+ recipient: string;
30
+ }
31
+ export interface Plan {
32
+ channel: Channel;
33
+ items: Item[];
34
+ recipients: number;
35
+ onSent?: (item: Item, response: Record<string, any>) => Promise<void>;
36
+ }
37
+ export interface Result {
38
+ channel: Channel;
39
+ sent: number;
40
+ planned: number;
41
+ ok: boolean;
42
+ error?: string;
43
+ }
44
+ export declare function createChannels(t?: Translator): {
45
+ makePlan: (channel: Channel, message: Message, config: NotifyConfig) => Plan;
46
+ deliver: (plan: Plan, config: NotifyConfig, request?: Request, sleep?: (ms: number) => Promise<unknown>) => Promise<Result>;
47
+ requestJson: (url: string, payload: Record<string, any>, fetcher?: typeof fetch) => Promise<Record<string, any>>;
48
+ };
49
+ export declare const makePlan: (channel: Channel, message: Message, config: NotifyConfig) => Plan, deliver: (plan: Plan, config: NotifyConfig, request?: Request, sleep?: (ms: number) => Promise<unknown>) => Promise<Result>;
@@ -0,0 +1,123 @@
1
+ import { createHmac } from 'node:crypto';
2
+ import { setTimeout as delay } from 'node:timers/promises';
3
+ import { createFormatting, escapeHtml, partTitle } from './formatting.js';
4
+ import { defaultTranslate } from './messages.js';
5
+ import { createTransport, TransportError } from './transport.js';
6
+ const icons = { info: 'ℹ️', success: '✅', warn: '⚠️', error: '❌' };
7
+ const colors = { info: 'blue', success: 'green', warn: 'yellow', error: 'red' };
8
+ const bytes = (data) => Buffer.byteLength(JSON.stringify(data), 'utf8');
9
+ export function createChannels(t = defaultTranslate) {
10
+ const { parseMarkdown, telegramParts, feishuParts } = createFormatting(t);
11
+ const requestJson = createTransport({ translate: t });
12
+ function text(title, content, footer, icon, html) {
13
+ const sections = [`${icon} ${html ? `<b>${escapeHtml(title)}</b>` : title}`];
14
+ if (content)
15
+ sections.push(content);
16
+ if (footer)
17
+ sections.push('────────\n' + (html ? `<i>${escapeHtml(footer)}</i>` : footer));
18
+ return sections.join('\n\n');
19
+ }
20
+ function card(message, title, content) {
21
+ const elements = [];
22
+ if (content)
23
+ elements.push({ tag: 'markdown', content });
24
+ if (message.footer)
25
+ elements.push({ tag: 'hr' }, { tag: 'note', elements: [{ tag: 'plain_text', content: message.footer }] });
26
+ if (message.reply?.url)
27
+ elements.push({ tag: 'action', actions: [{ tag: 'button', text: { tag: 'plain_text', content: t('channels.feishuReplyButton') }, url: message.reply.url, type: 'primary' }] });
28
+ return { msg_type: 'interactive', card: { config: { wide_screen_mode: true }, header: { template: colors[message.level], title: { tag: 'plain_text', content: title } }, elements } };
29
+ }
30
+ function makePlan(channel, message, config) {
31
+ if (!message.title.trim())
32
+ throw new Error(t('channels.emptyTitle'));
33
+ const blocks = parseMarkdown(message.content);
34
+ if (channel === 'telegram') {
35
+ const icon = icons[message.level], reserve = String(Math.max(1, message.content.length)).length * 2 + 16;
36
+ const budget = 3900 - text(message.title, '', message.footer, icon, false).length - reserve;
37
+ const chunks = telegramParts(blocks, budget), items = [];
38
+ for (const chatId of config.telegram.chatIds)
39
+ chunks.forEach((chunk, index) => {
40
+ const title = partTitle(message.title, index, chunks.length), plain = text(title, chunk.plain, message.footer, icon, false);
41
+ if (plain.length > 4096)
42
+ throw new Error(t('channels.telegramTooLong'));
43
+ items.push({ recipient: chatId, plain, payload: { chat_id: chatId,
44
+ text: text(title, chunk.html, message.footer, icon, true), parse_mode: 'HTML', disable_web_page_preview: true,
45
+ ...(message.reply?.users.includes(chatId) ? { reply_markup: { inline_keyboard: [[{ text: t('channels.telegramReplyButton'), callback_data: message.reply.callbackData ?? 'reply:' + message.reply.token }]] } } : {}) } });
46
+ });
47
+ return { channel, items, recipients: config.telegram.chatIds.length };
48
+ }
49
+ const budget = Math.min(10000, Math.floor((28000 - bytes(card(message, message.title, '')) - 512) / 6));
50
+ const chunks = feishuParts(blocks, budget);
51
+ const items = chunks.map((chunk, index) => {
52
+ const payload = card(message, partTitle(message.title, index, chunks.length), chunk);
53
+ if (bytes(payload) + 128 > 28000)
54
+ throw new Error(t('channels.feishuTooLong'));
55
+ return { recipient: 'feishu', payload };
56
+ });
57
+ return { channel, items, recipients: 1 };
58
+ }
59
+ async function deliver(plan, config, request = requestJson, sleep = delay) {
60
+ let sent = 0, error;
61
+ const failed = new Set(), telegram = plan.channel === 'telegram';
62
+ const url = telegram ? `https://api.telegram.org/bot${config.telegram.token}/sendMessage` : config.feishu.webhook;
63
+ const post = async (payload) => {
64
+ if (!telegram && config.feishu.secret) {
65
+ const timestamp = String(Math.floor(Date.now() / 1000));
66
+ payload.timestamp = timestamp;
67
+ payload.sign = createHmac('sha256', `${timestamp}\n${config.feishu.secret}`).update('').digest('base64');
68
+ }
69
+ let response = await request(url, payload);
70
+ if (telegram && response.error_code === 429) {
71
+ const wait = response.parameters?.retry_after;
72
+ if (typeof wait === 'number' && Number.isFinite(wait) && wait > 0 && wait <= 60) {
73
+ await sleep(wait * 1000);
74
+ response = await request(url, payload);
75
+ }
76
+ }
77
+ return response;
78
+ };
79
+ for (let index = 0; index < plan.items.length; index++) {
80
+ const item = plan.items[index];
81
+ if (failed.has(item.recipient))
82
+ continue;
83
+ const payload = structuredClone(item.payload);
84
+ try {
85
+ let response = await post(payload);
86
+ if (telegram && response.ok !== true && /parse|entit/i.test(String(response.description ?? ''))) {
87
+ delete payload.parse_mode;
88
+ payload.text = item.plain;
89
+ response = await post(payload);
90
+ }
91
+ else if (!telegram && response.code !== 0) {
92
+ let changed = false;
93
+ payload.card.elements = payload.card.elements.map((element) => {
94
+ if (element.tag !== 'markdown')
95
+ return element;
96
+ changed = true;
97
+ return { tag: 'div', text: { tag: 'lark_md', content: element.content } };
98
+ });
99
+ if (changed)
100
+ response = await post(payload);
101
+ }
102
+ if (telegram ? response.ok === true : response.code === 0) {
103
+ sent++;
104
+ if (plan.onSent)
105
+ await plan.onSent(item, response);
106
+ }
107
+ else {
108
+ failed.add(item.recipient);
109
+ error = t('channels.rejected');
110
+ }
111
+ }
112
+ catch (cause) {
113
+ failed.add(item.recipient);
114
+ error = cause instanceof TransportError ? cause.message : t('channels.sendFailed');
115
+ }
116
+ if (!telegram && !failed.has(item.recipient) && index + 1 < plan.items.length)
117
+ await sleep(250);
118
+ }
119
+ return { channel: plan.channel, sent, planned: plan.items.length, ok: sent > 0 && sent === plan.items.length && !error, error };
120
+ }
121
+ return { makePlan, deliver, requestJson };
122
+ }
123
+ export const { makePlan, deliver } = createChannels();
@@ -0,0 +1,24 @@
1
+ import { type Translator } from './messages.js';
2
+ export interface Span {
3
+ text: string;
4
+ tags: string[];
5
+ }
6
+ export interface Block {
7
+ spans: Span[];
8
+ code: boolean;
9
+ }
10
+ export declare const escapeHtml: (text: string) => string;
11
+ export declare const partTitle: (title: string, index: number, total: number) => string;
12
+ export declare function createFormatting(t?: Translator): {
13
+ parseMarkdown: (content: string) => Block[];
14
+ visibleText: (blocks: Block[]) => string;
15
+ telegramParts: (blocks: Block[], budget: number) => {
16
+ html: string;
17
+ plain: string;
18
+ }[];
19
+ feishuParts: (blocks: Block[], budget: number) => string[];
20
+ };
21
+ export declare const parseMarkdown: (content: string) => Block[], visibleText: (blocks: Block[]) => string, telegramParts: (blocks: Block[], budget: number) => {
22
+ html: string;
23
+ plain: string;
24
+ }[], feishuParts: (blocks: Block[], budget: number) => string[];
@@ -0,0 +1,197 @@
1
+ // 从原 dev-notify 格式化实现提取;无配置、凭据、网络或集成依赖。
2
+ import { defaultTranslate } from './messages.js';
3
+ export const escapeHtml = (text) => text.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
4
+ export const partTitle = (title, index, total) => total > 1 ? `${title} (${index + 1}/${total})` : title;
5
+ export function createFormatting(t = defaultTranslate) {
6
+ function inline(text, tags = [], depth = 0) {
7
+ if (depth > 8)
8
+ return [{ text, tags }];
9
+ const expression = /`([^`\n]+)`|\[([^\]\n]+)\]\((https?:\/\/[^\s)]+)\)|\*\*(\S(?:.*?\S)?)\*\*|__(\S(?:.*?\S)?)__|~~(\S(?:.*?\S)?)~~|(?<![\w*])\*([^*\n]+)\*(?![\w*])|(?<![\w_])_([^_\n]+)_(?![\w_])/g;
10
+ const spans = [];
11
+ let cursor = 0;
12
+ for (const match of text.matchAll(expression)) {
13
+ if (match.index > cursor)
14
+ spans.push({ text: text.slice(cursor, match.index), tags });
15
+ if (match[1] !== undefined)
16
+ spans.push({ text: match[1], tags: [...tags, 'code'] });
17
+ else if (match[2] !== undefined)
18
+ spans.push(...inline(match[2], tags, depth + 1), { text: ` (${match[3]})`, tags });
19
+ else {
20
+ const content = match[4] ?? match[5] ?? match[6] ?? match[7] ?? match[8];
21
+ const tag = match[4] !== undefined || match[5] !== undefined ? 'b' : match[6] !== undefined ? 's' : 'i';
22
+ spans.push(...inline(content, [...tags, tag], depth + 1));
23
+ }
24
+ cursor = match.index + match[0].length;
25
+ }
26
+ if (cursor < text.length)
27
+ spans.push({ text: text.slice(cursor), tags });
28
+ return spans.length ? spans : [{ text: '', tags }];
29
+ }
30
+ const cells = (line) => line.trim().replace(/^\|/, '').replace(/\|$/, '').split('|').map(cell => inline(cell.trim()).map(s => s.text).join(''));
31
+ const separator = (line) => line.includes('|') && line.trim().replace(/^\|/, '').replace(/\|$/, '').split('|').every(cell => /^:?-{3,}:?$/.test(cell.trim()));
32
+ function width(text) {
33
+ let size = 0;
34
+ for (const ch of text) {
35
+ const code = ch.codePointAt(0);
36
+ size += code >= 0x1100 && (code <= 0x115f || code >= 0x2e80 && code <= 0xa4cf || code >= 0xac00 && code <= 0xd7af || code >= 0xff01 && code <= 0xff60 || code >= 0x1f300) ? 2 : 1;
37
+ }
38
+ return size;
39
+ }
40
+ function table(lines) {
41
+ const rows = lines.filter((_, index) => index !== 1).map(cells);
42
+ const count = Math.max(...rows.map(row => row.length));
43
+ const widths = Array.from({ length: count }, (_, col) => Math.max(3, ...rows.map(row => width(row[col] ?? ''))));
44
+ const output = [];
45
+ rows.forEach((row, index) => {
46
+ output.push(widths.map((size, col) => (row[col] ?? '') + ' '.repeat(size - width(row[col] ?? ''))).join(' | '));
47
+ if (!index)
48
+ output.push(widths.map(size => '-'.repeat(size)).join('-+-'));
49
+ });
50
+ return output.join('\n');
51
+ }
52
+ function parseMarkdown(content) {
53
+ const lines = content.replace(/\r\n?/g, '\n').replace(/\0/g, '').split('\n');
54
+ const blocks = [];
55
+ let index = 0;
56
+ while (index < lines.length) {
57
+ const line = lines[index], fence = /^\s*(`{3,}|~{3,})(.*)$/.exec(line);
58
+ if (fence) {
59
+ const marker = fence[1][0], closing = new RegExp(`^\\s*${marker === '`' ? '`' : '~'}{${fence[1].length},}\\s*$`);
60
+ const body = [];
61
+ index++;
62
+ while (index < lines.length && !closing.test(lines[index]))
63
+ body.push(lines[index++]);
64
+ if (index < lines.length)
65
+ index++;
66
+ blocks.push({ spans: [{ text: body.join('\n'), tags: [] }], code: true });
67
+ continue;
68
+ }
69
+ if (index + 1 < lines.length && line.includes('|') && separator(lines[index + 1])) {
70
+ const rows = [line, lines[index + 1]];
71
+ index += 2;
72
+ while (index < lines.length && lines[index].trim() && lines[index].includes('|'))
73
+ rows.push(lines[index++]);
74
+ blocks.push({ spans: [{ text: table(rows), tags: [] }], code: true });
75
+ continue;
76
+ }
77
+ const heading = /^\s{0,3}#{1,6}\s+(.*)$/.exec(line), quote = /^\s*>\s?(.*)$/.exec(line), list = /^(\s*)[-*+]\s+(.*)$/.exec(line);
78
+ let spans;
79
+ if (heading)
80
+ spans = inline(heading[1], ['b']);
81
+ else if (/^\s*([-*_])(?:\s*\1){2,}\s*$/.test(line))
82
+ spans = [{ text: '────────', tags: [] }];
83
+ else if (quote)
84
+ spans = inline(quote[1], ['blockquote']);
85
+ else if (list)
86
+ spans = inline(`${list[1]}• ${list[2].replace(/^\[ \] /, '☐ ').replace(/^\[x\] /i, '☑ ')}`);
87
+ else
88
+ spans = inline(line);
89
+ blocks.push({ spans, code: false });
90
+ index++;
91
+ }
92
+ return blocks;
93
+ }
94
+ const visibleText = (blocks) => blocks.map(block => block.spans.map(span => span.text).join('')).join('\n');
95
+ function cut(text, budget, measure) {
96
+ if (budget < 1)
97
+ throw new Error(t('format.noSpace'));
98
+ let used = 0, end = 0;
99
+ for (const ch of text) {
100
+ const size = measure(ch);
101
+ if (used + size > budget)
102
+ break;
103
+ used += size;
104
+ end += ch.length;
105
+ }
106
+ if (!end && text)
107
+ throw new Error(t('format.oneChar'));
108
+ if (end < text.length)
109
+ for (const boundary of ['\n\n', '\n', ' ']) {
110
+ const offset = text.slice(0, end).lastIndexOf(boundary);
111
+ if (offset >= end / 2) {
112
+ end = offset + boundary.length;
113
+ break;
114
+ }
115
+ }
116
+ return [text.slice(0, end), text.slice(end)];
117
+ }
118
+ function telegramParts(blocks, budget) {
119
+ if (budget < 2)
120
+ throw new Error(t('format.telegramHeader'));
121
+ const output = [];
122
+ let html = '', plain = '';
123
+ const flush = () => { if (plain)
124
+ output.push({ html, plain }); html = ''; plain = ''; };
125
+ blocks.forEach((block, index) => {
126
+ const spans = index ? [{ text: '\n', tags: [] }, ...block.spans] : block.spans;
127
+ for (const span of spans) {
128
+ let rest = span.text;
129
+ while (rest) {
130
+ if (budget - plain.length < [...rest][0].length)
131
+ flush();
132
+ const [piece, remaining] = cut(rest, budget - plain.length, text => text.length);
133
+ const actualTags = block.code && !(index && span === spans[0]) ? ['pre'] : span.tags;
134
+ html += actualTags.map(tag => `<${tag}>`).join('') + escapeHtml(piece) + [...actualTags].reverse().map(tag => `</${tag}>`).join('');
135
+ plain += piece;
136
+ rest = remaining;
137
+ if (rest)
138
+ flush();
139
+ }
140
+ }
141
+ });
142
+ flush();
143
+ return output.length ? output : [{ html: '', plain: '' }];
144
+ }
145
+ function feishuParts(blocks, budget) {
146
+ if (budget < 128)
147
+ throw new Error(t('format.feishuHeader'));
148
+ const output = [];
149
+ let current = '';
150
+ const size = (text) => Buffer.byteLength(text, 'utf8');
151
+ const flush = () => { if (current)
152
+ output.push(current); current = ''; };
153
+ blocks.forEach((block, index) => {
154
+ if (index) {
155
+ if (size(current) + 1 > budget)
156
+ flush();
157
+ current += '\n';
158
+ }
159
+ for (const span of block.spans) {
160
+ let rest = span.text, prefix = '', suffix = '';
161
+ if (block.code) {
162
+ const runs = rest.match(/`+/g) ?? [], marker = '`'.repeat(Math.max(3, ...runs.map(run => run.length + 1)));
163
+ prefix = marker + 'text\n';
164
+ suffix = '\n' + marker;
165
+ if (size(prefix + suffix) > budget / 2) {
166
+ prefix = '';
167
+ suffix = '';
168
+ }
169
+ }
170
+ else
171
+ for (const tag of span.tags) {
172
+ const wrapper = tag === 'b' ? '**' : tag === 'i' ? '*' : tag === 's' ? '~~' : tag === 'code' ? '`' : '';
173
+ if (tag === 'blockquote')
174
+ prefix += '▎ ';
175
+ else {
176
+ prefix += wrapper;
177
+ suffix = wrapper + suffix;
178
+ }
179
+ }
180
+ while (rest) {
181
+ const overhead = size(prefix + suffix);
182
+ if (size(current) + overhead + size([...rest][0]) > budget)
183
+ flush();
184
+ const [piece, remaining] = cut(rest, budget - size(current) - overhead, size);
185
+ current += prefix + piece + suffix;
186
+ rest = remaining;
187
+ if (rest)
188
+ flush();
189
+ }
190
+ }
191
+ });
192
+ flush();
193
+ return output.length ? output : [''];
194
+ }
195
+ return { parseMarkdown, visibleText, telegramParts, feishuParts };
196
+ }
197
+ export const { parseMarkdown, visibleText, telegramParts, feishuParts } = createFormatting();
@@ -0,0 +1,4 @@
1
+ export * from './messages.js';
2
+ export * from './formatting.js';
3
+ export * from './transport.js';
4
+ export * from './channels.js';
package/dist/index.js ADDED
@@ -0,0 +1,4 @@
1
+ export * from './messages.js';
2
+ export * from './formatting.js';
3
+ export * from './transport.js';
4
+ export * from './channels.js';
@@ -0,0 +1,23 @@
1
+ declare const defaults: {
2
+ readonly 'channels.emptyTitle': "通知标题不能为空。";
3
+ readonly 'channels.telegramTooLong': "Telegram 分段后仍然超过消息长度限制。";
4
+ readonly 'channels.feishuTooLong': "飞书分段后仍然超过请求大小限制。";
5
+ readonly 'channels.noResponse': "服务端没有返回内容。";
6
+ readonly 'channels.responseTooLarge': "服务端响应过大,已停止读取。";
7
+ readonly 'channels.notJsonObject': "服务端返回的不是 JSON 对象。";
8
+ readonly 'channels.network': "网络请求失败或响应无效。为避免重复通知,不会自动重发;这条消息可能没有送达。";
9
+ readonly 'channels.rejected': "渠道拒绝了这次发送。请检查凭据、接收人授权、签名和频率限制。";
10
+ readonly 'channels.sendFailed': "发送失败,这条消息可能没有送达。";
11
+ readonly 'channels.feishuReplyButton': "在 Telegram 回复";
12
+ readonly 'channels.telegramReplyButton': "回复";
13
+ readonly 'format.noSpace': "可用空间不足,无法分段。";
14
+ readonly 'format.oneChar': "可用空间连一个字符都放不下。";
15
+ readonly 'format.telegramHeader': "Telegram 标题或底部信息太长,没有剩余空间放正文。";
16
+ readonly 'format.feishuHeader': "飞书标题或底部信息太长,没有剩余空间放正文。";
17
+ };
18
+ export type TranslationKey = keyof typeof defaults;
19
+ export type Translator = (key: TranslationKey) => string;
20
+ export declare const defaultTranslate: Translator;
21
+ export declare class NotifyError extends Error {
22
+ }
23
+ export {};
@@ -0,0 +1,20 @@
1
+ const defaults = {
2
+ 'channels.emptyTitle': '通知标题不能为空。',
3
+ 'channels.telegramTooLong': 'Telegram 分段后仍然超过消息长度限制。',
4
+ 'channels.feishuTooLong': '飞书分段后仍然超过请求大小限制。',
5
+ 'channels.noResponse': '服务端没有返回内容。',
6
+ 'channels.responseTooLarge': '服务端响应过大,已停止读取。',
7
+ 'channels.notJsonObject': '服务端返回的不是 JSON 对象。',
8
+ 'channels.network': '网络请求失败或响应无效。为避免重复通知,不会自动重发;这条消息可能没有送达。',
9
+ 'channels.rejected': '渠道拒绝了这次发送。请检查凭据、接收人授权、签名和频率限制。',
10
+ 'channels.sendFailed': '发送失败,这条消息可能没有送达。',
11
+ 'channels.feishuReplyButton': '在 Telegram 回复',
12
+ 'channels.telegramReplyButton': '回复',
13
+ 'format.noSpace': '可用空间不足,无法分段。',
14
+ 'format.oneChar': '可用空间连一个字符都放不下。',
15
+ 'format.telegramHeader': 'Telegram 标题或底部信息太长,没有剩余空间放正文。',
16
+ 'format.feishuHeader': '飞书标题或底部信息太长,没有剩余空间放正文。'
17
+ };
18
+ export const defaultTranslate = key => defaults[key];
19
+ export class NotifyError extends Error {
20
+ }
@@ -0,0 +1,29 @@
1
+ import { NotifyError, type Translator } from './messages.js';
2
+ export type Request = (url: string, payload: Record<string, any>) => Promise<Record<string, any>>;
3
+ export declare class TransportError extends NotifyError {
4
+ }
5
+ export declare class HttpError extends TransportError {
6
+ readonly status: number;
7
+ constructor(status: number);
8
+ }
9
+ export interface TransportOptions {
10
+ translate?: Translator;
11
+ timeoutMs?: number;
12
+ maxResponseBytes?: number;
13
+ }
14
+ export declare function createTransport(options?: TransportOptions): (url: string, payload: Record<string, any>, fetcher?: typeof fetch) => Promise<Record<string, any>>;
15
+ export declare const requestJson: (url: string, payload: Record<string, any>, fetcher?: typeof fetch) => Promise<Record<string, any>>;
16
+ export type TelegramMethod = 'sendMessage' | 'editMessageReplyMarkup' | 'answerCallbackQuery' | 'getMe' | 'getUpdates' | 'getWebhookInfo';
17
+ export declare class TelegramApiError extends NotifyError {
18
+ readonly code: number;
19
+ readonly retryAfterSeconds: number;
20
+ readonly fatal: boolean;
21
+ constructor(code: number, retryAfterSeconds?: number);
22
+ }
23
+ export interface TelegramClientOptions {
24
+ request?: Request;
25
+ sleep?: (ms: number) => Promise<unknown>;
26
+ maxRateLimitWaitSeconds?: number;
27
+ }
28
+ export declare function createTelegramApi(token: string, options?: TelegramClientOptions): (method: string, payload: Record<string, unknown>) => Promise<any>;
29
+ export declare function splitPlainText(text: string, limit?: number): string[];
@@ -0,0 +1,118 @@
1
+ import { setTimeout as delay } from 'node:timers/promises';
2
+ import { defaultTranslate, NotifyError } from './messages.js';
3
+ export class TransportError extends NotifyError {
4
+ }
5
+ export class HttpError extends TransportError {
6
+ status;
7
+ constructor(status) {
8
+ super(`HTTP 请求失败(状态码 ${status})。`);
9
+ this.status = status;
10
+ }
11
+ }
12
+ export function createTransport(options = {}) {
13
+ const t = options.translate ?? defaultTranslate;
14
+ const timeoutMs = options.timeoutMs ?? 10000, maximum = options.maxResponseBytes ?? 1024 * 1024;
15
+ if (!Number.isSafeInteger(timeoutMs) || timeoutMs < 1 || !Number.isSafeInteger(maximum) || maximum < 1)
16
+ throw new NotifyError('传输边界配置无效。');
17
+ return async function requestJson(url, payload, fetcher = fetch) {
18
+ try {
19
+ const response = await fetcher(url, { method: 'POST', body: JSON.stringify(payload),
20
+ headers: { 'Content-Type': 'application/json' }, redirect: 'error', signal: AbortSignal.timeout(timeoutMs) });
21
+ if (!response.body)
22
+ throw new TransportError(t('channels.noResponse'));
23
+ const reader = response.body.getReader(), chunks = [];
24
+ let size = 0;
25
+ try {
26
+ while (true) {
27
+ const { done, value } = await reader.read();
28
+ if (done)
29
+ break;
30
+ size += value.byteLength;
31
+ if (size > maximum) {
32
+ await reader.cancel();
33
+ throw new TransportError(t('channels.responseTooLarge'));
34
+ }
35
+ chunks.push(value);
36
+ }
37
+ }
38
+ finally {
39
+ reader.releaseLock();
40
+ }
41
+ const result = JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(Buffer.concat(chunks)));
42
+ if (!result || typeof result !== 'object' || Array.isArray(result))
43
+ throw new TransportError(t('channels.notJsonObject'));
44
+ // 保留渠道的明确拒绝包供 429 等策略处理;HTTP 失败绝不能因正文声称成功而算投递成功。
45
+ if (!response.ok && result.ok !== false && !(Number.isInteger(result.code) && result.code !== 0))
46
+ throw new HttpError(response.status);
47
+ return result;
48
+ }
49
+ catch (error) {
50
+ if (error instanceof TransportError)
51
+ throw error;
52
+ throw new TransportError(t('channels.network'));
53
+ }
54
+ };
55
+ }
56
+ export const requestJson = createTransport();
57
+ const methods = new Set(['sendMessage', 'editMessageReplyMarkup', 'answerCallbackQuery', 'getMe', 'getUpdates', 'getWebhookInfo']);
58
+ export class TelegramApiError extends NotifyError {
59
+ code;
60
+ retryAfterSeconds;
61
+ fatal;
62
+ constructor(code, retryAfterSeconds = 5) {
63
+ super(`Telegram API 调用失败(错误码 ${code})。`);
64
+ this.code = code;
65
+ this.retryAfterSeconds = retryAfterSeconds;
66
+ this.fatal = [401, 404, 409].includes(code);
67
+ }
68
+ }
69
+ export function createTelegramApi(token, options = {}) {
70
+ if (!/^\d+:[A-Za-z0-9_-]+$/.test(token))
71
+ throw new NotifyError('Telegram Token 格式无效。');
72
+ const request = options.request ?? requestJson, sleep = options.sleep ?? delay;
73
+ const maxWait = options.maxRateLimitWaitSeconds ?? 60;
74
+ return async (method, payload) => {
75
+ if (!methods.has(method))
76
+ throw new NotifyError('不允许的 Telegram API 方法。');
77
+ for (let attempt = 0;; attempt++) {
78
+ let response;
79
+ try {
80
+ response = await request(`https://api.telegram.org/bot${token}/${method}`, payload);
81
+ }
82
+ catch (error) {
83
+ if (error instanceof HttpError)
84
+ throw new TelegramApiError(error.status);
85
+ throw error;
86
+ }
87
+ if (response.ok === true) {
88
+ if (!Object.hasOwn(response, 'result'))
89
+ throw new NotifyError('Telegram 返回缺少结果。');
90
+ return response.result;
91
+ }
92
+ const code = Number.isSafeInteger(response.error_code) ? response.error_code : 0;
93
+ const rawWait = response.parameters?.retry_after;
94
+ const wait = typeof rawWait === 'number' && Number.isFinite(rawWait) && rawWait > 0 ? rawWait : 5;
95
+ if (code === 429 && attempt === 0 && wait <= maxWait) {
96
+ await sleep(wait * 1000);
97
+ continue;
98
+ }
99
+ throw new TelegramApiError(code, Math.max(1, Math.min(300, Math.ceil(wait))));
100
+ }
101
+ };
102
+ }
103
+ export function splitPlainText(text, limit = 4000) {
104
+ if (!Number.isSafeInteger(limit) || limit < 2 || limit > 4096)
105
+ throw new NotifyError('消息分段上限无效。');
106
+ const parts = [];
107
+ let part = '';
108
+ for (const ch of text) {
109
+ if (part.length + ch.length > limit) {
110
+ parts.push(part);
111
+ part = '';
112
+ }
113
+ part += ch;
114
+ }
115
+ if (part || !parts.length)
116
+ parts.push(part);
117
+ return parts;
118
+ }
package/package.json CHANGED
@@ -1,19 +1,25 @@
1
1
  {
2
2
  "name": "@quecue/notify-core",
3
- "version": "0.0.0-bootstrap.0",
4
- "description": "Package registration placeholder. Do not install; no runtime implementation.",
3
+ "version": "0.1.0",
4
+ "description": "独立的 Telegram 与飞书通讯、格式化和传输边界库",
5
5
  "license": "MIT",
6
- "repository": {
7
- "type": "git",
8
- "url": "git+https://github.com/QueCue/notify-core.git"
9
- },
6
+ "repository": { "type": "git", "url": "git+https://github.com/QueCue/notify-core.git" },
10
7
  "homepage": "https://github.com/QueCue/notify-core#readme",
11
- "files": [
12
- "README.md",
13
- "LICENSE"
14
- ],
15
- "publishConfig": {
16
- "access": "public",
17
- "registry": "https://registry.npmjs.org/"
8
+ "bugs": { "url": "https://github.com/QueCue/notify-core/issues" },
9
+ "publishConfig": { "access": "public", "registry": "https://registry.npmjs.org/" },
10
+ "type": "module",
11
+ "main": "./dist/index.js",
12
+ "types": "./dist/index.d.ts",
13
+ "exports": { ".": { "types": "./dist/index.d.ts", "import": "./dist/index.js" } },
14
+ "files": ["dist", "README.md", "README.en.md", "SECURITY.md", "SECURITY.en.md", "CONTRIBUTING.md", "PUBLISHING.md", "CHANGELOG.md", "CHANGELOG.en.md", "LICENSE"],
15
+ "engines": { "node": ">=22.0.0" },
16
+ "scripts": {
17
+ "build": "tsc -p tsconfig.json",
18
+ "test": "npm run build && node --test tests/*.test.mjs",
19
+ "prepack": "npm run build"
20
+ },
21
+ "devDependencies": {
22
+ "@types/node": "^22.0.0",
23
+ "typescript": "^5.9.0"
18
24
  }
19
25
  }