@zhin.js/adapter-github 1.1.26 → 1.1.27
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +93 -0
- package/LICENSE +21 -0
- package/README.md +4 -4
- package/agents/github/messages.js +20 -0
- package/agents/github/messages.ts +20 -0
- package/agents/github/skills/github-account/handlers.js +67 -22
- package/agents/github/skills/github-account/handlers.ts +68 -28
- package/lib/gh-client.d.ts +27 -7
- package/lib/gh-client.js +71 -25
- package/lib/protocol.d.ts +1 -7
- package/lib/protocol.js +1 -23
- package/lib/webhook.d.ts +1 -2
- package/lib/webhook.js +10 -23
- package/package.json +30 -28
- package/schema.json +0 -56
- package/src/gh-client.ts +86 -31
- package/src/protocol.ts +2 -28
- package/src/webhook.ts +10 -27
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,98 @@
|
|
|
1
1
|
# @zhin.js/adapter-github
|
|
2
2
|
|
|
3
|
+
## 1.1.27
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- e1fda87: fix: 按审计待审清单处理(安全、功能、清理)
|
|
8
|
+
|
|
9
|
+
**行为变更(升级前请阅读)**
|
|
10
|
+
|
|
11
|
+
- kernel / core / zhin.js:出站文本中的 `${…}` 不再用 `vm` 求值,只替换 `TemplateContext` 上能解析到值的属性路径(如 `${sender.name}`);表达式、函数调用、未知名字原样保留。整套 vm 沙箱(`evaluate` / `execute` / `compiler` / `getValueWithRuntime`)已移除。
|
|
12
|
+
- kernel / core / zhin.js:移除零引用的遗留导出——`Scheduler`、错误管理器与 `ZhinError` 子类、`compose`、`segment.from/toString`、AI 触发/准入函数、`@zhin.js/core/queue-im-field-contract`、`zhin.js/contexts`。
|
|
13
|
+
- agent:readonly 执行预设不再放行重定向、后台 `&`、进程替换、`find -delete/-exec`、`sed -i`、`sort -o`、`tee`、`xargs`、awk;curl/wget 校验每个 URL 与裸主机并识别更多上传参数;自定义 `execAllowlist` 中的只读命令带写入风险时改走审批。沙箱超时/取消会杀掉进程组或具名容器,默认镜像改为 `node:22-bookworm-slim` + `bash`。移除无生产调用的 `AuditLogger`、`AnomalyDetector`、旧 vision 回合等导出。
|
|
14
|
+
- ai:移除 `storage`、`FileStateCache`、`CostTracker`、`model-name-utils`、`./agent-step-checkpoint` 子路径、`ContextManager` 及旧压缩管线;延迟工具快照改为有界 LRU。
|
|
15
|
+
- cli:`zhin send` / `watch` / `schedule` 改为与运行中的 Host 通信(新增 `POST /message/send` 与 `/assistant/jobs` 写接口),`schedule` 不再支持离线改文件;移除 `zhin uninstall service`(用 `zhin service uninstall`)、`build --analyze`、`install -S`;`zhin stop` 杀不掉进程时保留 pid 文件并以非零退出;`runtime migrate` 新增 `verify`;未设置 `TZ` 时调度默认时区改为系统时区;不带 `ZHIN_SUPERVISOR_PID` 的进程被 init 接管后不再自行退出。
|
|
16
|
+
- schedule:后台 reconcile 出错不再产生未处理 rejection(新增 `onStoreError`);启动途中 `stop()` 不再遗留定时器。
|
|
17
|
+
- schema:元组校验与 `safeParse`、JSON Schema 一致(空数组不通过,缺省的可选元组为 `undefined`)。
|
|
18
|
+
- client / contract:删除无后端路由的 agent-stream API、未使用的 `definePage` 与旧 PageManager 类型;`@zhin.js/contract` 不再依赖 `@zhin.js/ai`;客户端收到 401 会清除已保存的凭据;安装/更新/卸载 RPC 默认超时 120 秒。
|
|
19
|
+
- a2a:流式响应中途出错写入 SSE error 并结束;未配置 token 的非生产环境只接受本机请求;任务存储有上限与过期;Mailbox 凭据回退与生产环境缺密钥检查修复。
|
|
20
|
+
- speech:`provider: openai` 不再套用 Ollama 的默认 host/model;识别语言改为可配置,不再写死 `zh`。
|
|
21
|
+
- adapter-lark:支持 `encryptKey` 加密事件解密。adapter-github:Device Flow 可取消、带超时并回复失败原因。adapter-wecom / dingtalk:请求加超时并校验 HTTP 状态。adapter-weixin-ilink:输入关闭期间暂停轮询。adapter-milky:SSE 支持 CRLF、缓冲上限与空闲重连。adapter-onebot-events:好友删除通知改为 `notice.friend.decrease`。
|
|
22
|
+
- plugin-group-suite:`teachAllowRegex` 默认关闭,正则安全检查更严格。plugin-qrcode:改为本地生成,回复 data URL 图片。plugin-rss / short-url / link-poster:拒绝解析到内网的地址;rss 修复条目每周重复推送;short-url 的 Console 创建改为真实调用 is.gd。plugin-lottery:按 `historyLimit` 同步。
|
|
23
|
+
|
|
24
|
+
**其余**
|
|
25
|
+
|
|
26
|
+
- host-http:插件市场详情加缓存与包名校验,SSE 回放容忍背压。host-http-contract:新增 `readTextBody()`,九个适配器改用它。
|
|
27
|
+
- pagemanager:页面外壳 importmap 升级到 React 19.2.8 / react-router 7.12.0。console-contract:`definePage` 接受 `description` / `group` / `parent` / `fullWidth`。
|
|
28
|
+
- command:进度提示本地化,匹配不再重复拷贝消息段。runtime:源码监听降级后退避重试原生监听。
|
|
29
|
+
- html-renderer:删除未接线的 `aiTextAsImage`。create-zhin-app / scaffold-wizard:移除不起作用的"全局安装 CLI"与运行时选择提示。
|
|
30
|
+
- 依赖声明清理、发布清单不再携带解析不到的 `src` 目录、移除未使用的 Skill 挂载、README 与示例配置按当前实现更正。
|
|
31
|
+
|
|
32
|
+
- 120e225: 聊天角色(ADR 0056,2026-11-12 修订):zhin 角色 owner / admin / trusted 分四级、只来自授予;发言者的平台身份与机器人自身的平台身份是独立维度,命令、Agent 工具 / 技能、中间件与按钮按三条维度共用一个评估器过滤。升级说明见 docs/migrations/chat-roles.md。
|
|
33
|
+
|
|
34
|
+
- zhin.js:实例首次启动把适配器 `master` / `trusted`、`endpoints[i].master|trusted` 与 `ai.trigger.masters|trusted` 迁入项目数据库 `chat_roles`(先记入 `.zhin/chat-roles-migration.json`,再从配置移除并记日志);之后配置再出现这些字段时启动报错并指向升级指南。平台身份不再映射为 zhin 角色:删除 `roles.mappings`(适配器块与 `endpoints[i].roles` 出现即启动报错)、`chat_role_mappings` 表与 Console `chat-roles.mappings` / `chat-roles.set-mappings`;`features.selfService.platformAdmins|roles|allowUsers` 删除;群内「功能」「角色」命令只认 zhin 角色,平台群主不再自动具备;聊天改配置、聊天重启、Agent 主人审批按 zhin `owner` 判断。
|
|
35
|
+
- @zhin.js/core(minor,破坏性):角色模型与解析移入 core(`ChatRoleService`、`createChatRoleSenderEnricher`),入站消息带 `sender.chatRole` 与归一的 `sender.platform`;删除 `SenderRole`、`senderRolesFromMessage`、`roleSatisfies` 等,`master` 改名 `owner`;删除映射规则 API(`createMappingChatRoleSource`、`DEFAULT_CHAT_ROLE_MAPPINGS` 等)与 `ChatRoleSource`;`createSenderEnricher` / `createConfigurationChatAuthorizer` 改为基于聊天角色。IM Runtime 为 PermissionHost 提供机器人自身身份(经 Adapter 索引缓存),机器人的角色变化事件使缓存失效;命令拒绝回复说明缺少的平台 / 机器人条件;`<Button permit>` 按点击者鉴权;场景治理工具工厂同时要求 `platform:*` 与 `bot:*`;`ai.trigger.masters|trusted` 删除。
|
|
36
|
+
- @zhin.js/permission(minor,破坏性):permit 语法 `owner` / `admin` / `trusted` / `role(...)`(zhin 角色)、`platform:owner|admin`、`platform-role:<id>`、`platform-permission:<name>`(发言者平台身份),新增 `bot:owner|admin`、`bot-role:<id>`、`bot-permission:<name>`(机器人自身平台身份,`PermissionSubject.bot`);`PermissionHost` 新增 `setBotStandingResolver`、`withBotStanding`、`firstDenied`,`checkAll` 可带 `source` 解析机器人身份;新增 `describeDeniedPermit`、`permitsNeedBotStanding`。删除 `role(master)`、`scene_admin`、`scene_owner`、`platform(adapter,perm)` 及 `registerPlatform`、`createSceneRolePlatformChecker`、`platformPermission`、`definePlatformPermission`,定义时报错并给出替代写法。
|
|
37
|
+
- @zhin.js/command(minor,破坏性):命令会话的发送者改为 `chatRole` / `platform`,不再从 metadata 读角色;`CONVERSATION_MANAGER_PERMIT` 改为 `admin`(平台群主不再自动满足);分发结果 `denied` 带 `denial`(第一条不满足的规则与原因);指令菜单只列出当前发送者在本会话可用的命令。
|
|
38
|
+
- @zhin.js/agent(minor,破坏性):危险工具、文件 / exec 策略、主人审批、Agent 回合主体只读 zhin 角色(`principal.roles` 只含展开的聊天角色,平台地位在 `principal.platform`);`SenderRole` → `PrincipalRole`,`SELF_CHANGE_MASTER_ROLE` → `SELF_CHANGE_OWNER_ROLE`;回合开始时读取机器人自身身份(`contextState.bot.platform`),按三条维度过滤 tool / skill / Agent 包后才披露,执行工具时再校验一次;群规「群主和管理员豁免」改为平台群主 / 管理员与 zhin `owner`。
|
|
39
|
+
- @zhin.js/feature-kit:`OperationSession.sender` 改为 `chatRole` / `platform`。
|
|
40
|
+
- @zhin.js/im-contract:新增 `PlatformStanding`、`SelfStandingRequest`、`standingFromMemberRole`。
|
|
41
|
+
- @zhin.js/adapter:入站 sender 可带 `platform`;账号管理命令需要聊天角色 `owner`;`EndpointManagement` 新增可选 `inspectSelfStanding`,能力画像新增 `scene.selfStanding`(声明即须实现),`AdapterIndex.selfStanding` / `invalidateSelfStanding` 按会话缓存机器人自身身份。
|
|
42
|
+
- @zhin.js/middleware:入站中间件可声明 `permit`,不满足时跳过该中间件(机器人身份只读缓存)。
|
|
43
|
+
- @zhin.js/tool:定义工具时拒绝已移除的 permit 写法。
|
|
44
|
+
- @zhin.js/ai:注释更新。
|
|
45
|
+
- @zhin.js/console-protocol:`chat-roles.list|grant|revoke`(`iam.manage`);`ConsoleChatRoleSource` 只有 `grant`。
|
|
46
|
+
- @zhin.js/runtime:Host 配置 Schema 删除 `features.selfService.platformAdmins|roles|allowUsers`。
|
|
47
|
+
- @zhin.js/testing:伪造消息与 `PluginTestKit` 用 `sender.chatRole` 声明测试身份。
|
|
48
|
+
- @zhin.js/scaffold-wizard:ICQQ 向导不再写 `master`,完成后提示在 Console 账号「角色」页签或聊天 `角色 授予` 设置 owner。
|
|
49
|
+
- 适配器:配置 Schema 删除 `master` / `trusted`(以及预览版的 `roles`);群管理类工具同时声明发言者条件与机器人条件(如 `platform:admin` + `bot:admin`、`bot-permission:manage_roles`),NapCat / OneBot11 工具不再使用已移除的 `platform(adapter,…)` 写法。
|
|
50
|
+
- 插件:内容审核以 `inbound.bypass`(permit 列表,默认 `owner` 或 `platform:admin`)取代 `masters` / `inbound.bypassMasters`;群管、音乐、日历、地震、B 站、轮盘赌的权限改用 zhin 角色,订阅类命令只认 zhin `admin` / `owner`。
|
|
51
|
+
|
|
52
|
+
- 5cd9d3d: HTTP 路由契约改为 Web 标准 `Request` / `Response`(ADR 0054 §2,B1.0)
|
|
53
|
+
|
|
54
|
+
- host-http-contract:路由处理函数签名改为 `(request, context) => Response`,`context` 提供 `url`、`params`、`principal`、`remoteAddress`;新增 `readBodyBytes` / `readTextBody` / `readJsonBody`(带大小上限,超限先读完再报 413)、`jsonResponse` / `emptyResponse`、带背压的 `createResponseStream`(SSE、大文件下载)与 `WsConnection`。契约不再引用 `node:http` / `ws`。
|
|
55
|
+
- host-http:Node 服务器移到 `@zhin.js/host-http/node`(`createHttpHost`),负责 Node 请求与 `Request` / `Response` 的互转;认证结果经 `context.principal` 传给路由,删除旧的按请求取主体的辅助函数;未预期的异常只返回通用错误信息,细节写日志。
|
|
56
|
+
- runtime、mcp、a2a、gateway、zhin.js、calendar 插件与各适配器的 Webhook / 反向 WebSocket 路由迁移到新契约,行为不变。
|
|
57
|
+
|
|
58
|
+
- 68659cd: `zod` 改为运行时依赖(原为可选对等依赖):插件运行时在启动时就会加载这些包的约定目录(Agent 工具、命令等),其中静态导入了 `zod`;生成的项目里没有安装可选对等依赖,启动会报 `Cannot find package 'zod'`。范围为 `^4.6.7`(已修复的版本)。
|
|
59
|
+
- Updated dependencies [53932e5]
|
|
60
|
+
- Updated dependencies [57c6cdc]
|
|
61
|
+
- Updated dependencies [e1fda87]
|
|
62
|
+
- Updated dependencies [4e8ba38]
|
|
63
|
+
- Updated dependencies [a9c7a13]
|
|
64
|
+
- Updated dependencies [120e225]
|
|
65
|
+
- Updated dependencies [c2bbdcf]
|
|
66
|
+
- Updated dependencies [c2bbdcf]
|
|
67
|
+
- Updated dependencies [63595fc]
|
|
68
|
+
- Updated dependencies [fd055d2]
|
|
69
|
+
- Updated dependencies [4e8ba38]
|
|
70
|
+
- Updated dependencies [cc7a5f9]
|
|
71
|
+
- Updated dependencies [4e8ba38]
|
|
72
|
+
- Updated dependencies [f8bf684]
|
|
73
|
+
- Updated dependencies [e0bc008]
|
|
74
|
+
- Updated dependencies [5cd9d3d]
|
|
75
|
+
- Updated dependencies [5cd9d3d]
|
|
76
|
+
- Updated dependencies [c2bbdcf]
|
|
77
|
+
- Updated dependencies [f8bf684]
|
|
78
|
+
- Updated dependencies [663473b]
|
|
79
|
+
- Updated dependencies [c606d51]
|
|
80
|
+
- Updated dependencies [5cd9d3d]
|
|
81
|
+
- Updated dependencies [d655ef0]
|
|
82
|
+
- Updated dependencies [1e54b1e]
|
|
83
|
+
- @zhin.js/adapter@1.1.33
|
|
84
|
+
- @zhin.js/core@1.2.0
|
|
85
|
+
- @zhin.js/logger@1.1.3
|
|
86
|
+
- @zhin.js/host-http@1.2.0
|
|
87
|
+
- @zhin.js/feature-kit@1.1.16
|
|
88
|
+
- @zhin.js/agent-feature@1.1.17
|
|
89
|
+
- @zhin.js/console-contract@1.1.6
|
|
90
|
+
- @zhin.js/host-http-contract@1.2.0
|
|
91
|
+
- @zhin.js/skill@1.1.17
|
|
92
|
+
- @zhin.js/tool@1.1.19
|
|
93
|
+
- @zhin.js/im-contract@1.1.14
|
|
94
|
+
- @zhin.js/page@1.1.18
|
|
95
|
+
|
|
3
96
|
## 1.1.26
|
|
4
97
|
|
|
5
98
|
### Patch Changes
|
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 凉菜
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -16,14 +16,14 @@ GitHub Plugin Runtime 适配器 — Issue/PR 评论区即聊天通道,GitHub A
|
|
|
16
16
|
pnpm add @zhin.js/adapter-github
|
|
17
17
|
```
|
|
18
18
|
|
|
19
|
-
Webhook 需要 Root 提供 `@zhin.js/host-http`(`zhin
|
|
19
|
+
Webhook 需要 Root 提供 `@zhin.js/host-http`(`zhin start` 拉起的实例默认装配,HTTP 由管理网关承载)。
|
|
20
20
|
|
|
21
21
|
## 前置条件
|
|
22
22
|
|
|
23
23
|
1. 创建 GitHub App,记录 App ID、私钥与 Webhook Secret。
|
|
24
24
|
2. 授予目标仓库所需的 Issues、Pull requests 与 Contents 权限。
|
|
25
25
|
3. 将 Webhook 指向公网 HTTPS 的 `/github/webhook`,并订阅 Issue、PR 与评论事件。
|
|
26
|
-
4. 把 App
|
|
26
|
+
4. 把 App 安装到目标仓库;仓库会话按稳定的 `owner/repo` 地址匹配。
|
|
27
27
|
|
|
28
28
|
## 配置(Plugin Runtime)
|
|
29
29
|
|
|
@@ -103,7 +103,7 @@ github:
|
|
|
103
103
|
|
|
104
104
|
新增仅保存,需手动启动;运行账号的普通配置更新只重连该账号。身份变化走明确替换,新账号不会继承旧 Session 或登录状态。账号凭据变化先按平台实际只读接口核验;平台无法核验时需明确替换。空白或遮蔽的密码不会覆盖既有密码。
|
|
105
105
|
|
|
106
|
-
读取需要 `github:read
|
|
106
|
+
读取需要 `github:read`(`read` 级别,内置角色 `viewer` 及以上都有),写入需要 `github:write`(`operate` 级别,`operator` 及以上都有,绑定到本适配器的角色也算);首次迁移还需管理员身份,见[插件权限的级别](https://zhin.js.org/console/#插件权限的级别)。无持久化存储时管理操作不可用,提交结果未知时先刷新核实,不重复提交。
|
|
107
107
|
|
|
108
108
|
## 故障排查
|
|
109
109
|
|
|
@@ -111,7 +111,7 @@ github:
|
|
|
111
111
|
| --- | --- |
|
|
112
112
|
| Webhook 401 | 检查 Secret、`X-Hub-Signature-256` 与原始请求体 |
|
|
113
113
|
| App 鉴权失败 | 检查 App ID、私钥 PEM/文件路径与服务器时钟 |
|
|
114
|
-
|
|
|
114
|
+
| 评论没有到达 Bot | 先查 Endpoint 收件箱,再核对 Endpoint 配置与规范的 `owner/repo` |
|
|
115
115
|
| 能收事件但不能回复 | 检查 installation 与仓库权限;PAT MCP 不替代 App 出站 |
|
|
116
116
|
|
|
117
117
|
## License
|
|
@@ -33,6 +33,26 @@ export const messages = createMessages({
|
|
|
33
33
|
"zh-CN": "✅ GitHub 账号绑定成功!\n👤 {login}",
|
|
34
34
|
"en": "✅ GitHub account linked!\n👤 {login}"
|
|
35
35
|
},
|
|
36
|
+
"bindInProgress": {
|
|
37
|
+
"zh-CN": "⏳ 已有一个 GitHub 账号绑定正在等待授权,请先完成或等待其过期。",
|
|
38
|
+
"en": "⏳ A GitHub account link is already waiting for authorization. Finish it or wait for it to expire."
|
|
39
|
+
},
|
|
40
|
+
"bindExpired": {
|
|
41
|
+
"zh-CN": "⌛ GitHub 授权已超时,账号未绑定。请重新发起账号绑定。",
|
|
42
|
+
"en": "⌛ GitHub authorization timed out; no account was linked. Start account linking again."
|
|
43
|
+
},
|
|
44
|
+
"bindDenied": {
|
|
45
|
+
"zh-CN": "🚫 GitHub 授权被拒绝,账号未绑定。",
|
|
46
|
+
"en": "🚫 GitHub authorization was denied; no account was linked."
|
|
47
|
+
},
|
|
48
|
+
"bindFailed": {
|
|
49
|
+
"zh-CN": "❌ GitHub 账号绑定失败: {error}",
|
|
50
|
+
"en": "❌ GitHub account linking failed: {error}"
|
|
51
|
+
},
|
|
52
|
+
"bindVerifyFailed": {
|
|
53
|
+
"zh-CN": "❌ 已获得授权,但无法确认 GitHub 账号身份,账号未绑定。请重新发起账号绑定。",
|
|
54
|
+
"en": "❌ Authorization succeeded, but the GitHub account could not be verified; no account was linked. Start account linking again."
|
|
55
|
+
},
|
|
36
56
|
"flowFailed": {
|
|
37
57
|
"zh-CN": "❌ Device Flow 启动失败: {error}",
|
|
38
58
|
"en": "❌ Could not start Device Flow: {error}"
|
|
@@ -33,6 +33,26 @@ export const messages = createMessages({
|
|
|
33
33
|
"zh-CN": "✅ GitHub 账号绑定成功!\n👤 {login}",
|
|
34
34
|
"en": "✅ GitHub account linked!\n👤 {login}"
|
|
35
35
|
},
|
|
36
|
+
"bindInProgress": {
|
|
37
|
+
"zh-CN": "⏳ 已有一个 GitHub 账号绑定正在等待授权,请先完成或等待其过期。",
|
|
38
|
+
"en": "⏳ A GitHub account link is already waiting for authorization. Finish it or wait for it to expire."
|
|
39
|
+
},
|
|
40
|
+
"bindExpired": {
|
|
41
|
+
"zh-CN": "⌛ GitHub 授权已超时,账号未绑定。请重新发起账号绑定。",
|
|
42
|
+
"en": "⌛ GitHub authorization timed out; no account was linked. Start account linking again."
|
|
43
|
+
},
|
|
44
|
+
"bindDenied": {
|
|
45
|
+
"zh-CN": "🚫 GitHub 授权被拒绝,账号未绑定。",
|
|
46
|
+
"en": "🚫 GitHub authorization was denied; no account was linked."
|
|
47
|
+
},
|
|
48
|
+
"bindFailed": {
|
|
49
|
+
"zh-CN": "❌ GitHub 账号绑定失败: {error}",
|
|
50
|
+
"en": "❌ GitHub account linking failed: {error}"
|
|
51
|
+
},
|
|
52
|
+
"bindVerifyFailed": {
|
|
53
|
+
"zh-CN": "❌ 已获得授权,但无法确认 GitHub 账号身份,账号未绑定。请重新发起账号绑定。",
|
|
54
|
+
"en": "❌ Authorization succeeded, but the GitHub account could not be verified; no account was linked. Start account linking again."
|
|
55
|
+
},
|
|
36
56
|
"flowFailed": {
|
|
37
57
|
"zh-CN": "❌ Device Flow 启动失败: {error}",
|
|
38
58
|
"en": "❌ Could not start Device Flow: {error}"
|
|
@@ -8,6 +8,8 @@ function oauthModel(client) {
|
|
|
8
8
|
const db = client.database;
|
|
9
9
|
return db?.models?.get('github_oauth_users');
|
|
10
10
|
}
|
|
11
|
+
/** `platform\u0000uid` keys with a device flow still polling. */
|
|
12
|
+
const pendingBinds = new Set();
|
|
11
13
|
function depsLogger() {
|
|
12
14
|
return {
|
|
13
15
|
debug: (...args) => console.debug(...args),
|
|
@@ -31,10 +33,24 @@ export async function executeGithubBind(_args, client, commMessage, preference)
|
|
|
31
33
|
if (existing) {
|
|
32
34
|
return t(locale, 'alreadyBound', { login: existing.github_login });
|
|
33
35
|
}
|
|
36
|
+
// One device flow per user: a second request would race the first one's insert.
|
|
37
|
+
const flowKey = `${msg.clientAdapter}\u0000${msg.sender.id}`;
|
|
38
|
+
if (pendingBinds.has(flowKey))
|
|
39
|
+
return t(locale, 'bindInProgress');
|
|
40
|
+
pendingBinds.add(flowKey);
|
|
41
|
+
const platform = msg.clientAdapter;
|
|
42
|
+
const senderId = msg.sender.id;
|
|
43
|
+
const reply = async (text) => {
|
|
44
|
+
try {
|
|
45
|
+
await msg.$reply?.(text);
|
|
46
|
+
}
|
|
47
|
+
catch (err) {
|
|
48
|
+
log.warn(formatCompact({ op: 'device_flow_reply', ok: false, platform, sender: senderId, error: err instanceof Error ? err.message : String(err) }));
|
|
49
|
+
}
|
|
50
|
+
};
|
|
34
51
|
try {
|
|
35
52
|
const host = client.host;
|
|
36
53
|
const codeResp = await GhClient.deviceFlowRequestCode(clientId, host);
|
|
37
|
-
const tokenPromise = GhClient.deviceFlowPollToken(clientId, codeResp.device_code, codeResp.interval, codeResp.expires_in, host);
|
|
38
54
|
const replyMsg = [
|
|
39
55
|
t(locale, 'openDeviceLink'),
|
|
40
56
|
` ${codeResp.verification_uri}`,
|
|
@@ -43,32 +59,61 @@ export async function executeGithubBind(_args, client, commMessage, preference)
|
|
|
43
59
|
'',
|
|
44
60
|
t(locale, 'waiting', { minutes: Math.floor(codeResp.expires_in / 60) }),
|
|
45
61
|
].join('\n');
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
62
|
+
void (async () => {
|
|
63
|
+
try {
|
|
64
|
+
const result = await GhClient.deviceFlowPollToken({
|
|
65
|
+
clientId,
|
|
66
|
+
deviceCode: codeResp.device_code,
|
|
67
|
+
interval: codeResp.interval,
|
|
68
|
+
expiresIn: codeResp.expires_in,
|
|
69
|
+
host,
|
|
70
|
+
signal: client.lifecycleSignal,
|
|
71
|
+
});
|
|
72
|
+
// Endpoint stopped: nothing to tell the user through a closed endpoint.
|
|
73
|
+
if (result.status === 'aborted')
|
|
74
|
+
return;
|
|
75
|
+
if (result.status !== 'authorized') {
|
|
76
|
+
log.warn(formatCompact({ op: 'device_flow', ok: false, status: result.status, platform, sender: senderId }));
|
|
77
|
+
await reply(result.status === 'error'
|
|
78
|
+
? t(locale, 'bindFailed', { error: result.error })
|
|
79
|
+
: result.status === 'expired'
|
|
80
|
+
? t(locale, 'bindExpired')
|
|
81
|
+
: t(locale, 'bindDenied'));
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
const userGh = new GhClient({ host, token: result.access_token, signal: client.lifecycleSignal });
|
|
85
|
+
const authResult = await userGh.verifyAuth();
|
|
86
|
+
if (!authResult.ok || !authResult.user) {
|
|
87
|
+
log.warn(formatCompact({ op: 'device_flow', ok: false, status: 'verify_failed', platform, sender: senderId }));
|
|
88
|
+
await reply(t(locale, 'bindVerifyFailed'));
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
const login = authResult.user;
|
|
92
|
+
await model.insert({
|
|
93
|
+
id: Date.now(),
|
|
94
|
+
platform,
|
|
95
|
+
platform_uid: senderId,
|
|
96
|
+
github_login: login,
|
|
97
|
+
access_token: result.access_token,
|
|
98
|
+
created_at: Date.now(),
|
|
99
|
+
});
|
|
100
|
+
log.debug(formatCompact({ op: 'bind', platform, sender: senderId, login }));
|
|
101
|
+
await reply(t(locale, 'bound', { login }));
|
|
102
|
+
}
|
|
103
|
+
catch (err) {
|
|
104
|
+
log.error('GitHub Device Flow 错误:', err);
|
|
105
|
+
if (!client.lifecycleSignal?.aborted) {
|
|
106
|
+
await reply(t(locale, 'bindFailed', { error: err instanceof Error ? err.message : String(err) }));
|
|
107
|
+
}
|
|
50
108
|
}
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
const login = authResult.ok ? authResult.user : 'unknown';
|
|
54
|
-
await model.insert({
|
|
55
|
-
id: Date.now(),
|
|
56
|
-
platform: msg.clientAdapter,
|
|
57
|
-
platform_uid: msg.sender.id,
|
|
58
|
-
github_login: login,
|
|
59
|
-
access_token: tokenData.access_token,
|
|
60
|
-
created_at: Date.now(),
|
|
61
|
-
});
|
|
62
|
-
log.debug(formatCompact({ op: 'bind', platform: msg.clientAdapter, sender: msg.sender.id, login }));
|
|
63
|
-
if (msg?.$reply) {
|
|
64
|
-
await msg.$reply(t(locale, 'bound', { login }));
|
|
109
|
+
finally {
|
|
110
|
+
pendingBinds.delete(flowKey);
|
|
65
111
|
}
|
|
66
|
-
})
|
|
67
|
-
log.error('GitHub Device Flow 错误:', err);
|
|
68
|
-
});
|
|
112
|
+
})();
|
|
69
113
|
return replyMsg;
|
|
70
114
|
}
|
|
71
115
|
catch (e) {
|
|
116
|
+
pendingBinds.delete(flowKey);
|
|
72
117
|
return t(locale, 'flowFailed', { error: e instanceof Error ? e.message : String(e) });
|
|
73
118
|
}
|
|
74
119
|
}
|
|
@@ -17,6 +17,9 @@ function oauthModel(client: GithubEndpoint) {
|
|
|
17
17
|
} | undefined;
|
|
18
18
|
}
|
|
19
19
|
|
|
20
|
+
/** `platform\u0000uid` keys with a device flow still polling. */
|
|
21
|
+
const pendingBinds = new Set<string>();
|
|
22
|
+
|
|
20
23
|
function depsLogger() {
|
|
21
24
|
return {
|
|
22
25
|
debug: (...args: unknown[]) => console.debug(...args),
|
|
@@ -42,12 +45,24 @@ export async function executeGithubBind(_args: Record<string, never>, client: Gi
|
|
|
42
45
|
return t(locale, 'alreadyBound', { login: existing.github_login });
|
|
43
46
|
}
|
|
44
47
|
|
|
48
|
+
// One device flow per user: a second request would race the first one's insert.
|
|
49
|
+
const flowKey = `${msg.clientAdapter}\u0000${msg.sender.id}`;
|
|
50
|
+
if (pendingBinds.has(flowKey)) return t(locale, 'bindInProgress');
|
|
51
|
+
pendingBinds.add(flowKey);
|
|
52
|
+
|
|
53
|
+
const platform = msg.clientAdapter;
|
|
54
|
+
const senderId = msg.sender.id;
|
|
55
|
+
const reply = async (text: string): Promise<void> => {
|
|
56
|
+
try {
|
|
57
|
+
await msg.$reply?.(text);
|
|
58
|
+
} catch (err) {
|
|
59
|
+
log.warn(formatCompact({ op: 'device_flow_reply', ok: false, platform, sender: senderId, error: err instanceof Error ? err.message : String(err) }));
|
|
60
|
+
}
|
|
61
|
+
};
|
|
62
|
+
|
|
45
63
|
try {
|
|
46
64
|
const host = client.host;
|
|
47
65
|
const codeResp = await GhClient.deviceFlowRequestCode(clientId, host);
|
|
48
|
-
const tokenPromise = GhClient.deviceFlowPollToken(
|
|
49
|
-
clientId, codeResp.device_code, codeResp.interval, codeResp.expires_in, host,
|
|
50
|
-
);
|
|
51
66
|
|
|
52
67
|
const replyMsg = [
|
|
53
68
|
t(locale, 'openDeviceLink'),
|
|
@@ -58,35 +73,60 @@ export async function executeGithubBind(_args: Record<string, never>, client: Gi
|
|
|
58
73
|
t(locale, 'waiting', { minutes: Math.floor(codeResp.expires_in / 60) }),
|
|
59
74
|
].join('\n');
|
|
60
75
|
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
76
|
+
void (async () => {
|
|
77
|
+
try {
|
|
78
|
+
const result = await GhClient.deviceFlowPollToken({
|
|
79
|
+
clientId,
|
|
80
|
+
deviceCode: codeResp.device_code,
|
|
81
|
+
interval: codeResp.interval,
|
|
82
|
+
expiresIn: codeResp.expires_in,
|
|
83
|
+
host,
|
|
84
|
+
signal: client.lifecycleSignal,
|
|
85
|
+
});
|
|
86
|
+
// Endpoint stopped: nothing to tell the user through a closed endpoint.
|
|
87
|
+
if (result.status === 'aborted') return;
|
|
88
|
+
if (result.status !== 'authorized') {
|
|
89
|
+
log.warn(formatCompact({ op: 'device_flow', ok: false, status: result.status, platform, sender: senderId }));
|
|
90
|
+
await reply(result.status === 'error'
|
|
91
|
+
? t(locale, 'bindFailed', { error: result.error })
|
|
92
|
+
: result.status === 'expired'
|
|
93
|
+
? t(locale, 'bindExpired')
|
|
94
|
+
: t(locale, 'bindDenied'));
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const userGh = new GhClient({ host, token: result.access_token, signal: client.lifecycleSignal });
|
|
99
|
+
const authResult = await userGh.verifyAuth();
|
|
100
|
+
if (!authResult.ok || !authResult.user) {
|
|
101
|
+
log.warn(formatCompact({ op: 'device_flow', ok: false, status: 'verify_failed', platform, sender: senderId }));
|
|
102
|
+
await reply(t(locale, 'bindVerifyFailed'));
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
const login = authResult.user;
|
|
106
|
+
|
|
107
|
+
await model.insert({
|
|
108
|
+
id: Date.now(),
|
|
109
|
+
platform,
|
|
110
|
+
platform_uid: senderId,
|
|
111
|
+
github_login: login,
|
|
112
|
+
access_token: result.access_token,
|
|
113
|
+
created_at: Date.now(),
|
|
114
|
+
});
|
|
115
|
+
log.debug(formatCompact({ op: 'bind', platform, sender: senderId, login }));
|
|
116
|
+
await reply(t(locale, 'bound', { login }));
|
|
117
|
+
} catch (err) {
|
|
118
|
+
log.error('GitHub Device Flow 错误:', err);
|
|
119
|
+
if (!client.lifecycleSignal?.aborted) {
|
|
120
|
+
await reply(t(locale, 'bindFailed', { error: err instanceof Error ? err.message : String(err) }));
|
|
121
|
+
}
|
|
122
|
+
} finally {
|
|
123
|
+
pendingBinds.delete(flowKey);
|
|
83
124
|
}
|
|
84
|
-
})
|
|
85
|
-
log.error('GitHub Device Flow 错误:', err);
|
|
86
|
-
});
|
|
125
|
+
})();
|
|
87
126
|
|
|
88
127
|
return replyMsg;
|
|
89
128
|
} catch (e: unknown) {
|
|
129
|
+
pendingBinds.delete(flowKey);
|
|
90
130
|
return t(locale, 'flowFailed', { error: e instanceof Error ? e.message : String(e) });
|
|
91
131
|
}
|
|
92
132
|
}
|
package/lib/gh-client.d.ts
CHANGED
|
@@ -54,6 +54,8 @@ export declare class GhClient {
|
|
|
54
54
|
protected apiHost?: string;
|
|
55
55
|
protected executionSignal?: AbortSignal;
|
|
56
56
|
protected bindExecutionSignal(signal: AbortSignal): void;
|
|
57
|
+
/** Aborted when the owning endpoint stops; long-running background work should observe it. */
|
|
58
|
+
get lifecycleSignal(): AbortSignal | undefined;
|
|
57
59
|
private token?;
|
|
58
60
|
private _appAuth?;
|
|
59
61
|
private _resolvedKey?;
|
|
@@ -310,12 +312,30 @@ export declare class GhClient {
|
|
|
310
312
|
interval: number;
|
|
311
313
|
}>;
|
|
312
314
|
/**
|
|
313
|
-
*
|
|
314
|
-
*
|
|
315
|
+
* 第二步:轮询等待用户授权。
|
|
316
|
+
* 可通过 `signal` 取消;单次请求有超时;网络/JSON 等瞬时错误会重试到设备码过期为止。
|
|
315
317
|
*/
|
|
316
|
-
static deviceFlowPollToken(clientId
|
|
317
|
-
access_token: string;
|
|
318
|
-
token_type: string;
|
|
319
|
-
scope: string;
|
|
320
|
-
} | null>;
|
|
318
|
+
static deviceFlowPollToken({ clientId, deviceCode, interval, expiresIn, host, signal, requestTimeoutMs, }: GhDeviceFlowPollRequest): Promise<GhDeviceFlowPollResult>;
|
|
321
319
|
}
|
|
320
|
+
export interface GhDeviceFlowPollRequest {
|
|
321
|
+
readonly clientId: string;
|
|
322
|
+
readonly deviceCode: string;
|
|
323
|
+
/** Seconds between polls (GitHub default 5). */
|
|
324
|
+
readonly interval?: number;
|
|
325
|
+
/** Seconds until the device code expires (GitHub default 900). */
|
|
326
|
+
readonly expiresIn?: number;
|
|
327
|
+
readonly host?: string;
|
|
328
|
+
readonly signal?: AbortSignal;
|
|
329
|
+
readonly requestTimeoutMs?: number;
|
|
330
|
+
}
|
|
331
|
+
export type GhDeviceFlowPollResult = {
|
|
332
|
+
readonly status: 'authorized';
|
|
333
|
+
readonly access_token: string;
|
|
334
|
+
readonly token_type: string;
|
|
335
|
+
readonly scope: string;
|
|
336
|
+
} | {
|
|
337
|
+
readonly status: 'expired' | 'denied' | 'aborted';
|
|
338
|
+
} | {
|
|
339
|
+
readonly status: 'error';
|
|
340
|
+
readonly error: string;
|
|
341
|
+
};
|
package/lib/gh-client.js
CHANGED
|
@@ -17,6 +17,8 @@ export class GhClient {
|
|
|
17
17
|
apiHost;
|
|
18
18
|
executionSignal;
|
|
19
19
|
bindExecutionSignal(signal) { this.executionSignal = signal; }
|
|
20
|
+
/** Aborted when the owning endpoint stops; long-running background work should observe it. */
|
|
21
|
+
get lifecycleSignal() { return this.executionSignal; }
|
|
20
22
|
token;
|
|
21
23
|
_appAuth;
|
|
22
24
|
_resolvedKey;
|
|
@@ -582,40 +584,84 @@ export class GhClient {
|
|
|
582
584
|
return res.json();
|
|
583
585
|
}
|
|
584
586
|
/**
|
|
585
|
-
*
|
|
586
|
-
*
|
|
587
|
+
* 第二步:轮询等待用户授权。
|
|
588
|
+
* 可通过 `signal` 取消;单次请求有超时;网络/JSON 等瞬时错误会重试到设备码过期为止。
|
|
587
589
|
*/
|
|
588
|
-
static async deviceFlowPollToken(clientId, deviceCode, interval = 5, expiresIn = 900, host) {
|
|
590
|
+
static async deviceFlowPollToken({ clientId, deviceCode, interval = 5, expiresIn = 900, host, signal, requestTimeoutMs = 15_000, }) {
|
|
589
591
|
const baseUrl = host ? `https://${host}` : 'https://github.com';
|
|
590
592
|
const deadline = Date.now() + expiresIn * 1000;
|
|
591
593
|
let pollInterval = interval * 1000;
|
|
592
|
-
while (
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
}
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
594
|
+
while (true) {
|
|
595
|
+
if (signal?.aborted)
|
|
596
|
+
return { status: 'aborted' };
|
|
597
|
+
const remaining = deadline - Date.now();
|
|
598
|
+
if (remaining <= 0)
|
|
599
|
+
return { status: 'expired' };
|
|
600
|
+
if (!await abortableDelay(Math.min(pollInterval, remaining), signal))
|
|
601
|
+
return { status: 'aborted' };
|
|
602
|
+
if (Date.now() >= deadline)
|
|
603
|
+
return { status: 'expired' };
|
|
604
|
+
let data;
|
|
605
|
+
try {
|
|
606
|
+
const timeout = AbortSignal.timeout(requestTimeoutMs);
|
|
607
|
+
const res = await fetch(`${baseUrl}/login/oauth/access_token`, {
|
|
608
|
+
method: 'POST',
|
|
609
|
+
headers: {
|
|
610
|
+
'Content-Type': 'application/json',
|
|
611
|
+
'Accept': 'application/json',
|
|
612
|
+
},
|
|
613
|
+
body: JSON.stringify({
|
|
614
|
+
client_id: clientId,
|
|
615
|
+
device_code: deviceCode,
|
|
616
|
+
grant_type: 'urn:ietf:params:oauth:grant-type:device_code',
|
|
617
|
+
}),
|
|
618
|
+
signal: signal ? AbortSignal.any([signal, timeout]) : timeout,
|
|
619
|
+
});
|
|
620
|
+
data = await res.json();
|
|
609
621
|
}
|
|
610
|
-
|
|
611
|
-
|
|
622
|
+
catch {
|
|
623
|
+
if (signal?.aborted)
|
|
624
|
+
return { status: 'aborted' };
|
|
625
|
+
// Transient network / timeout / non-JSON response: keep polling until the deadline.
|
|
612
626
|
continue;
|
|
613
627
|
}
|
|
628
|
+
if (!data || typeof data !== 'object')
|
|
629
|
+
continue;
|
|
630
|
+
if (typeof data.access_token === 'string' && data.access_token) {
|
|
631
|
+
return {
|
|
632
|
+
status: 'authorized',
|
|
633
|
+
access_token: data.access_token,
|
|
634
|
+
token_type: typeof data.token_type === 'string' ? data.token_type : 'bearer',
|
|
635
|
+
scope: typeof data.scope === 'string' ? data.scope : '',
|
|
636
|
+
};
|
|
637
|
+
}
|
|
614
638
|
if (data.error === 'authorization_pending')
|
|
615
639
|
continue;
|
|
616
|
-
|
|
617
|
-
|
|
640
|
+
if (data.error === 'slow_down') {
|
|
641
|
+
const next = Number(data.interval);
|
|
642
|
+
pollInterval = (Number.isFinite(next) && next > 0 ? next : pollInterval / 1000 + 5) * 1000;
|
|
643
|
+
continue;
|
|
644
|
+
}
|
|
645
|
+
if (data.error === 'expired_token')
|
|
646
|
+
return { status: 'expired' };
|
|
647
|
+
if (data.error === 'access_denied')
|
|
648
|
+
return { status: 'denied' };
|
|
649
|
+
if (typeof data.error === 'string') {
|
|
650
|
+
return { status: 'error', error: typeof data.error_description === 'string' ? data.error_description : data.error };
|
|
651
|
+
}
|
|
652
|
+
// No token and no error (e.g. a 5xx JSON body): treat as transient.
|
|
618
653
|
}
|
|
619
|
-
return null;
|
|
620
654
|
}
|
|
621
655
|
}
|
|
656
|
+
/** Resolves true after `ms`, or false as soon as `signal` aborts. */
|
|
657
|
+
function abortableDelay(ms, signal) {
|
|
658
|
+
return new Promise((resolve) => {
|
|
659
|
+
if (signal?.aborted) {
|
|
660
|
+
resolve(false);
|
|
661
|
+
return;
|
|
662
|
+
}
|
|
663
|
+
const onAbort = () => { clearTimeout(timer); resolve(false); };
|
|
664
|
+
const timer = setTimeout(() => { signal?.removeEventListener('abort', onAbort); resolve(true); }, ms);
|
|
665
|
+
signal?.addEventListener('abort', onAbort, { once: true });
|
|
666
|
+
});
|
|
667
|
+
}
|
package/lib/protocol.d.ts
CHANGED
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
import type { EndpointProfileConfiguration } from 'zhin.js/adapter';
|
|
2
|
-
import type { IncomingMessage } from 'node:http';
|
|
3
2
|
import { type ConversationRef } from '@zhin.js/im-contract';
|
|
4
3
|
import { type GenericWebhookPayload, type IssueCommentPayload, type PRReviewCommentPayload, type PRReviewPayload } from './types.js';
|
|
5
4
|
export type { EventType, GenericWebhookPayload, GitHubComment, GitHubIssue, GitHubOAuthUser, GitHubPR, GitHubRepo, GitHubUser, IssueCommentPayload, ParsedChannel, PRReviewCommentPayload, PRReviewPayload, Subscription, } from './types.js';
|
|
@@ -57,13 +56,8 @@ export declare function formatInboundContent(body: string): string;
|
|
|
57
56
|
/** Build markdown body for Issue/PR comment send. */
|
|
58
57
|
export declare function formatOutboundBody(payload: unknown): string;
|
|
59
58
|
export declare function verifyWebhookSignature(secret: string, rawBody: string, signatureHeader: string | undefined): boolean;
|
|
60
|
-
export declare function headerValue(headers:
|
|
61
|
-
export declare function readTextBody(request: IncomingMessage, options?: {
|
|
62
|
-
readonly limit?: number;
|
|
63
|
-
}): Promise<string>;
|
|
59
|
+
export declare function headerValue(headers: Headers, name: string): string;
|
|
64
60
|
export declare function parseIssueCommentInbound(payload: IssueCommentPayload): GithubInboundComment | null;
|
|
65
61
|
export declare function parsePrReviewCommentInbound(payload: PRReviewCommentPayload): GithubInboundComment | null;
|
|
66
62
|
export declare function parsePrReviewInbound(payload: PRReviewPayload): GithubInboundComment | null;
|
|
67
63
|
export declare function formatNotification(event: string, p: GenericWebhookPayload): string;
|
|
68
|
-
/** Parse @mentions in markdown into a flat display string (gateway text path). */
|
|
69
|
-
export declare function parseMarkdownMentions(md: string): string;
|
package/lib/protocol.js
CHANGED
|
@@ -158,25 +158,7 @@ export function verifyWebhookSignature(secret, rawBody, signatureHeader) {
|
|
|
158
158
|
}
|
|
159
159
|
}
|
|
160
160
|
export function headerValue(headers, name) {
|
|
161
|
-
|
|
162
|
-
if (Array.isArray(raw))
|
|
163
|
-
return raw[0] ?? '';
|
|
164
|
-
return raw ?? '';
|
|
165
|
-
}
|
|
166
|
-
export async function readTextBody(request, options = {}) {
|
|
167
|
-
const limit = options.limit ?? 1_048_576;
|
|
168
|
-
const chunks = [];
|
|
169
|
-
let size = 0;
|
|
170
|
-
for await (const chunk of request) {
|
|
171
|
-
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
|
172
|
-
size += buffer.length;
|
|
173
|
-
if (size > limit) {
|
|
174
|
-
request.destroy();
|
|
175
|
-
throw new Error(`Request body exceeds ${limit} bytes`);
|
|
176
|
-
}
|
|
177
|
-
chunks.push(buffer);
|
|
178
|
-
}
|
|
179
|
-
return Buffer.concat(chunks).toString('utf8');
|
|
161
|
+
return headers.get(name) ?? '';
|
|
180
162
|
}
|
|
181
163
|
export function parseIssueCommentInbound(payload) {
|
|
182
164
|
if (payload.action !== 'created' || !payload.comment)
|
|
@@ -275,7 +257,3 @@ export function formatNotification(event, p) {
|
|
|
275
257
|
return `📬 ${repo}\n📡 ${event}${p.action ? ` (${p.action})` : ''} by ${sender}\n🔗 ${repoUrl}`;
|
|
276
258
|
}
|
|
277
259
|
}
|
|
278
|
-
/** Parse @mentions in markdown into a flat display string (gateway text path). */
|
|
279
|
-
export function parseMarkdownMentions(md) {
|
|
280
|
-
return md;
|
|
281
|
-
}
|
package/lib/webhook.d.ts
CHANGED
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* GitHub webhook HTTP: verify → parse → admit inbound comments.
|
|
3
3
|
*/
|
|
4
|
-
import type { IncomingMessage, ServerResponse } from 'node:http';
|
|
5
4
|
import type { HttpHost, HttpRouteRegistration } from '@zhin.js/host-http';
|
|
6
5
|
import { type GithubInboundComment, type ResolvedGithubConfig } from './protocol.js';
|
|
7
6
|
export interface GithubWebhookHandler {
|
|
@@ -10,5 +9,5 @@ export interface GithubWebhookHandler {
|
|
|
10
9
|
admit(comment: GithubInboundComment, nativeEvent?: unknown): void;
|
|
11
10
|
}
|
|
12
11
|
export declare function registerGithubWebhookRoutes(http: HttpHost, handler: GithubWebhookHandler): HttpRouteRegistration[];
|
|
13
|
-
export declare function handleGithubWebhookRequest(request:
|
|
12
|
+
export declare function handleGithubWebhookRequest(request: Request, handler: GithubWebhookHandler): Promise<Response>;
|
|
14
13
|
export declare function dispatchGithubWebhookPayload(event: string, payload: unknown, handler: GithubWebhookHandler): Promise<void>;
|
package/lib/webhook.js
CHANGED
|
@@ -1,28 +1,23 @@
|
|
|
1
|
+
import { readTextBody } from '@zhin.js/host-http-contract';
|
|
1
2
|
import { formatCompact, getLogger } from '@zhin.js/logger';
|
|
2
|
-
import { headerValue, parseIssueCommentInbound, parsePrReviewCommentInbound, parsePrReviewInbound,
|
|
3
|
+
import { headerValue, parseIssueCommentInbound, parsePrReviewCommentInbound, parsePrReviewInbound, verifyWebhookSignature, } from './protocol.js';
|
|
3
4
|
const logger = getLogger('github');
|
|
4
5
|
export function registerGithubWebhookRoutes(http, handler) {
|
|
5
6
|
const routePath = handler.config.webhookPath;
|
|
6
7
|
return [
|
|
7
|
-
http.route('POST', routePath,
|
|
8
|
-
await handleGithubWebhookRequest(request, response, handler);
|
|
9
|
-
}, { summary: 'GitHub webhook', tags: ['github'] }),
|
|
8
|
+
http.route('POST', routePath, (request) => handleGithubWebhookRequest(request, handler), { summary: 'GitHub webhook', tags: ['github'] }),
|
|
10
9
|
];
|
|
11
10
|
}
|
|
12
|
-
export async function handleGithubWebhookRequest(request,
|
|
11
|
+
export async function handleGithubWebhookRequest(request, handler) {
|
|
13
12
|
const secret = handler.config.webhookSecret;
|
|
14
13
|
if (!secret) {
|
|
15
|
-
|
|
16
|
-
response.end(JSON.stringify({ error: 'Webhook not configured' }));
|
|
17
|
-
return;
|
|
14
|
+
return new Response(JSON.stringify({ error: 'Webhook not configured' }), { status: 503, headers: { 'Content-Type': 'application/json' } });
|
|
18
15
|
}
|
|
19
16
|
const signature = headerValue(request.headers, 'x-hub-signature-256');
|
|
20
17
|
const event = headerValue(request.headers, 'x-github-event');
|
|
21
18
|
const deliveryId = headerValue(request.headers, 'x-github-delivery');
|
|
22
19
|
if (!signature || !event) {
|
|
23
|
-
|
|
24
|
-
response.end(JSON.stringify({ error: 'Missing signature or event header' }));
|
|
25
|
-
return;
|
|
20
|
+
return new Response(JSON.stringify({ error: 'Missing signature or event header' }), { status: 400, headers: { 'Content-Type': 'application/json' } });
|
|
26
21
|
}
|
|
27
22
|
try {
|
|
28
23
|
const rawBody = await readTextBody(request);
|
|
@@ -33,31 +28,23 @@ export async function handleGithubWebhookRequest(request, response, handler) {
|
|
|
33
28
|
error: 'invalid signature',
|
|
34
29
|
delivery: deliveryId,
|
|
35
30
|
}));
|
|
36
|
-
|
|
37
|
-
response.end(JSON.stringify({ error: 'Invalid signature' }));
|
|
38
|
-
return;
|
|
31
|
+
return new Response(JSON.stringify({ error: 'Invalid signature' }), { status: 401, headers: { 'Content-Type': 'application/json' } });
|
|
39
32
|
}
|
|
40
33
|
let payload;
|
|
41
34
|
try {
|
|
42
35
|
payload = rawBody ? JSON.parse(rawBody) : {};
|
|
43
36
|
}
|
|
44
37
|
catch {
|
|
45
|
-
|
|
46
|
-
response.end(JSON.stringify({ error: 'Invalid JSON' }));
|
|
47
|
-
return;
|
|
38
|
+
return new Response(JSON.stringify({ error: 'Invalid JSON' }), { status: 400, headers: { 'Content-Type': 'application/json' } });
|
|
48
39
|
}
|
|
49
|
-
response.writeHead(200, { 'Content-Type': 'application/json' });
|
|
50
|
-
response.end(JSON.stringify({ ok: true }));
|
|
51
40
|
void dispatchGithubWebhookPayload(event, payload, handler).catch((e) => {
|
|
52
41
|
logger.error(`Webhook 事件处理失败 (${event}):`, e);
|
|
53
42
|
});
|
|
43
|
+
return new Response(JSON.stringify({ ok: true }), { status: 200, headers: { 'Content-Type': 'application/json' } });
|
|
54
44
|
}
|
|
55
45
|
catch (error) {
|
|
56
46
|
logger.error('Webhook error:', error);
|
|
57
|
-
|
|
58
|
-
response.writeHead(500, { 'Content-Type': 'application/json' });
|
|
59
|
-
response.end(JSON.stringify({ error: 'Internal Server Error' }));
|
|
60
|
-
}
|
|
47
|
+
return new Response(JSON.stringify({ error: 'Internal Server Error' }), { status: 500, headers: { 'Content-Type': 'application/json' } });
|
|
61
48
|
}
|
|
62
49
|
}
|
|
63
50
|
export async function dispatchGithubWebhookPayload(event, payload, handler) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zhin.js/adapter-github",
|
|
3
|
-
"version": "1.1.
|
|
3
|
+
"version": "1.1.27",
|
|
4
4
|
"description": "Zhin.js GitHub adapter for Plugin Runtime (App auth + webhook)",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./lib/index.js",
|
|
@@ -39,27 +39,28 @@
|
|
|
39
39
|
"directory": "plugins/adapters/github"
|
|
40
40
|
},
|
|
41
41
|
"dependencies": {
|
|
42
|
-
"
|
|
43
|
-
"@zhin.js/
|
|
44
|
-
"@zhin.js/
|
|
45
|
-
"@zhin.js/
|
|
46
|
-
"@zhin.js/
|
|
47
|
-
"@zhin.js/
|
|
48
|
-
"@zhin.js/
|
|
49
|
-
"@zhin.js/
|
|
50
|
-
"@zhin.js/
|
|
51
|
-
"@zhin.js/
|
|
52
|
-
"@zhin.js/
|
|
42
|
+
"zod": "^4.6.7",
|
|
43
|
+
"@zhin.js/adapter": "1.1.33",
|
|
44
|
+
"@zhin.js/agent-feature": "1.1.17",
|
|
45
|
+
"@zhin.js/console-contract": "1.1.6",
|
|
46
|
+
"@zhin.js/core": "1.2.0",
|
|
47
|
+
"@zhin.js/feature-kit": "1.1.16",
|
|
48
|
+
"@zhin.js/host-http": "1.2.0",
|
|
49
|
+
"@zhin.js/host-http-contract": "1.2.0",
|
|
50
|
+
"@zhin.js/im-contract": "1.1.14",
|
|
51
|
+
"@zhin.js/logger": "1.1.3",
|
|
52
|
+
"@zhin.js/page": "1.1.18",
|
|
53
|
+
"@zhin.js/skill": "1.1.17",
|
|
54
|
+
"@zhin.js/tool": "1.1.19"
|
|
53
55
|
},
|
|
54
56
|
"peerDependencies": {
|
|
55
|
-
"
|
|
56
|
-
"@zhin.js/
|
|
57
|
-
"@zhin.js/
|
|
58
|
-
"@zhin.js/
|
|
59
|
-
"@zhin.js/
|
|
60
|
-
"@zhin.js/
|
|
61
|
-
"
|
|
62
|
-
"zhin.js": "^1.1.25"
|
|
57
|
+
"@zhin.js/adapter": "^1.1.33",
|
|
58
|
+
"@zhin.js/agent": "^1.2.0",
|
|
59
|
+
"@zhin.js/command": "^1.2.0",
|
|
60
|
+
"@zhin.js/core": "^1.2.0",
|
|
61
|
+
"@zhin.js/host-http": "^1.2.0",
|
|
62
|
+
"@zhin.js/prompt-section": "^1.1.16",
|
|
63
|
+
"zhin.js": "^1.1.26"
|
|
63
64
|
},
|
|
64
65
|
"peerDependenciesMeta": {
|
|
65
66
|
"@zhin.js/agent": {
|
|
@@ -73,19 +74,15 @@
|
|
|
73
74
|
},
|
|
74
75
|
"zhin.js": {
|
|
75
76
|
"optional": true
|
|
76
|
-
},
|
|
77
|
-
"zod": {
|
|
78
|
-
"optional": true
|
|
79
77
|
}
|
|
80
78
|
},
|
|
81
79
|
"devDependencies": {
|
|
82
80
|
"@types/node": "^26.1.2",
|
|
83
81
|
"typescript": "^6.0.3",
|
|
84
82
|
"vitest": "^4.1.10",
|
|
85
|
-
"
|
|
86
|
-
"@zhin.js/
|
|
87
|
-
"
|
|
88
|
-
"zhin.js": "1.1.25"
|
|
83
|
+
"@zhin.js/agent": "1.2.0",
|
|
84
|
+
"@zhin.js/prompt-section": "1.1.16",
|
|
85
|
+
"zhin.js": "1.1.26"
|
|
89
86
|
},
|
|
90
87
|
"files": [
|
|
91
88
|
"adapters",
|
|
@@ -137,6 +134,11 @@
|
|
|
137
134
|
{
|
|
138
135
|
"package": "@zhin.js/page",
|
|
139
136
|
"api": "^1.0.0"
|
|
137
|
+
},
|
|
138
|
+
{
|
|
139
|
+
"package": "@zhin.js/prompt-section",
|
|
140
|
+
"api": "^1.0.0",
|
|
141
|
+
"optional": true
|
|
140
142
|
}
|
|
141
143
|
],
|
|
142
144
|
"plugins": []
|
|
@@ -144,6 +146,6 @@
|
|
|
144
146
|
"scripts": {
|
|
145
147
|
"build": "tsc",
|
|
146
148
|
"clean": "rimraf lib",
|
|
147
|
-
"test": "
|
|
149
|
+
"test": "vitest run --root ../../.. plugins/adapters/github/tests"
|
|
148
150
|
}
|
|
149
151
|
}
|
package/schema.json
CHANGED
|
@@ -26,34 +26,6 @@
|
|
|
26
26
|
"type": "string",
|
|
27
27
|
"description": "Managed git workspace root"
|
|
28
28
|
},
|
|
29
|
-
"master": {
|
|
30
|
-
"type": [
|
|
31
|
-
"string",
|
|
32
|
-
"number"
|
|
33
|
-
],
|
|
34
|
-
"description": "框架 master(GitHub user login or id;AI/工具权限、endpoint 管理)。endpoints[i].master 可逐项覆盖",
|
|
35
|
-
"x-i18n": {
|
|
36
|
-
"en": {
|
|
37
|
-
"description": "Framework masters (GitHub user login or id; AI/tool permissions and endpoint management). endpoints[i].master can override this value."
|
|
38
|
-
}
|
|
39
|
-
}
|
|
40
|
-
},
|
|
41
|
-
"trusted": {
|
|
42
|
-
"type": "array",
|
|
43
|
-
"items": {
|
|
44
|
-
"type": [
|
|
45
|
-
"string",
|
|
46
|
-
"number"
|
|
47
|
-
],
|
|
48
|
-
"description": "Trusted GitHub user login or id"
|
|
49
|
-
},
|
|
50
|
-
"description": "框架 trusted 用户列表(弱于 master)。endpoints[i].trusted 可逐项追加",
|
|
51
|
-
"x-i18n": {
|
|
52
|
-
"en": {
|
|
53
|
-
"description": "Framework trusted users, below master authority. endpoints[i].trusted can add users."
|
|
54
|
-
}
|
|
55
|
-
}
|
|
56
|
-
},
|
|
57
29
|
"endpoints": {
|
|
58
30
|
"type": "array",
|
|
59
31
|
"minItems": 1,
|
|
@@ -62,34 +34,6 @@
|
|
|
62
34
|
"type": "object",
|
|
63
35
|
"additionalProperties": false,
|
|
64
36
|
"properties": {
|
|
65
|
-
"master": {
|
|
66
|
-
"type": [
|
|
67
|
-
"string",
|
|
68
|
-
"number"
|
|
69
|
-
],
|
|
70
|
-
"description": "本 endpoint 的框架 master(GitHub user login or id);覆盖顶层 master",
|
|
71
|
-
"x-i18n": {
|
|
72
|
-
"en": {
|
|
73
|
-
"description": "Framework masters for this endpoint (GitHub user login or id), overriding the top-level master list."
|
|
74
|
-
}
|
|
75
|
-
}
|
|
76
|
-
},
|
|
77
|
-
"trusted": {
|
|
78
|
-
"type": "array",
|
|
79
|
-
"items": {
|
|
80
|
-
"type": [
|
|
81
|
-
"string",
|
|
82
|
-
"number"
|
|
83
|
-
],
|
|
84
|
-
"description": "Trusted GitHub user login or id"
|
|
85
|
-
},
|
|
86
|
-
"description": "本 endpoint 的 trusted 列表",
|
|
87
|
-
"x-i18n": {
|
|
88
|
-
"en": {
|
|
89
|
-
"description": "Trusted users for this endpoint."
|
|
90
|
-
}
|
|
91
|
-
}
|
|
92
|
-
},
|
|
93
37
|
"app_id": {
|
|
94
38
|
"type": [
|
|
95
39
|
"string",
|
package/src/gh-client.ts
CHANGED
|
@@ -67,6 +67,8 @@ export class GhClient {
|
|
|
67
67
|
protected apiHost?: string;
|
|
68
68
|
protected executionSignal?: AbortSignal;
|
|
69
69
|
protected bindExecutionSignal(signal: AbortSignal): void { this.executionSignal = signal; }
|
|
70
|
+
/** Aborted when the owning endpoint stops; long-running background work should observe it. */
|
|
71
|
+
get lifecycleSignal(): AbortSignal | undefined { return this.executionSignal; }
|
|
70
72
|
private token?: string;
|
|
71
73
|
private _appAuth?: AppAuth;
|
|
72
74
|
private _resolvedKey?: string;
|
|
@@ -691,46 +693,99 @@ export class GhClient {
|
|
|
691
693
|
}
|
|
692
694
|
|
|
693
695
|
/**
|
|
694
|
-
*
|
|
695
|
-
*
|
|
696
|
+
* 第二步:轮询等待用户授权。
|
|
697
|
+
* 可通过 `signal` 取消;单次请求有超时;网络/JSON 等瞬时错误会重试到设备码过期为止。
|
|
696
698
|
*/
|
|
697
|
-
static async deviceFlowPollToken(
|
|
698
|
-
clientId
|
|
699
|
-
deviceCode
|
|
700
|
-
interval
|
|
701
|
-
expiresIn
|
|
702
|
-
host
|
|
703
|
-
|
|
699
|
+
static async deviceFlowPollToken({
|
|
700
|
+
clientId,
|
|
701
|
+
deviceCode,
|
|
702
|
+
interval = 5,
|
|
703
|
+
expiresIn = 900,
|
|
704
|
+
host,
|
|
705
|
+
signal,
|
|
706
|
+
requestTimeoutMs = 15_000,
|
|
707
|
+
}: GhDeviceFlowPollRequest): Promise<GhDeviceFlowPollResult> {
|
|
704
708
|
const baseUrl = host ? `https://${host}` : 'https://github.com';
|
|
705
709
|
const deadline = Date.now() + expiresIn * 1000;
|
|
706
710
|
let pollInterval = interval * 1000;
|
|
707
711
|
|
|
708
|
-
while (
|
|
709
|
-
|
|
710
|
-
const
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
712
|
+
while (true) {
|
|
713
|
+
if (signal?.aborted) return { status: 'aborted' };
|
|
714
|
+
const remaining = deadline - Date.now();
|
|
715
|
+
if (remaining <= 0) return { status: 'expired' };
|
|
716
|
+
if (!await abortableDelay(Math.min(pollInterval, remaining), signal)) return { status: 'aborted' };
|
|
717
|
+
if (Date.now() >= deadline) return { status: 'expired' };
|
|
718
|
+
|
|
719
|
+
let data: Record<string, unknown>;
|
|
720
|
+
try {
|
|
721
|
+
const timeout = AbortSignal.timeout(requestTimeoutMs);
|
|
722
|
+
const res = await fetch(`${baseUrl}/login/oauth/access_token`, {
|
|
723
|
+
method: 'POST',
|
|
724
|
+
headers: {
|
|
725
|
+
'Content-Type': 'application/json',
|
|
726
|
+
'Accept': 'application/json',
|
|
727
|
+
},
|
|
728
|
+
body: JSON.stringify({
|
|
729
|
+
client_id: clientId,
|
|
730
|
+
device_code: deviceCode,
|
|
731
|
+
grant_type: 'urn:ietf:params:oauth:grant-type:device_code',
|
|
732
|
+
}),
|
|
733
|
+
signal: signal ? AbortSignal.any([signal, timeout]) : timeout,
|
|
734
|
+
});
|
|
735
|
+
data = await res.json() as Record<string, unknown>;
|
|
736
|
+
} catch {
|
|
737
|
+
if (signal?.aborted) return { status: 'aborted' };
|
|
738
|
+
// Transient network / timeout / non-JSON response: keep polling until the deadline.
|
|
739
|
+
continue;
|
|
725
740
|
}
|
|
741
|
+
if (!data || typeof data !== 'object') continue;
|
|
742
|
+
if (typeof data.access_token === 'string' && data.access_token) {
|
|
743
|
+
return {
|
|
744
|
+
status: 'authorized',
|
|
745
|
+
access_token: data.access_token,
|
|
746
|
+
token_type: typeof data.token_type === 'string' ? data.token_type : 'bearer',
|
|
747
|
+
scope: typeof data.scope === 'string' ? data.scope : '',
|
|
748
|
+
};
|
|
749
|
+
}
|
|
750
|
+
if (data.error === 'authorization_pending') continue;
|
|
726
751
|
if (data.error === 'slow_down') {
|
|
727
|
-
|
|
752
|
+
const next = Number(data.interval);
|
|
753
|
+
pollInterval = (Number.isFinite(next) && next > 0 ? next : pollInterval / 1000 + 5) * 1000;
|
|
728
754
|
continue;
|
|
729
755
|
}
|
|
730
|
-
if (data.error === '
|
|
731
|
-
|
|
732
|
-
|
|
756
|
+
if (data.error === 'expired_token') return { status: 'expired' };
|
|
757
|
+
if (data.error === 'access_denied') return { status: 'denied' };
|
|
758
|
+
if (typeof data.error === 'string') {
|
|
759
|
+
return { status: 'error', error: typeof data.error_description === 'string' ? data.error_description : data.error };
|
|
760
|
+
}
|
|
761
|
+
// No token and no error (e.g. a 5xx JSON body): treat as transient.
|
|
733
762
|
}
|
|
734
|
-
return null;
|
|
735
763
|
}
|
|
736
764
|
}
|
|
765
|
+
|
|
766
|
+
export interface GhDeviceFlowPollRequest {
|
|
767
|
+
readonly clientId: string;
|
|
768
|
+
readonly deviceCode: string;
|
|
769
|
+
/** Seconds between polls (GitHub default 5). */
|
|
770
|
+
readonly interval?: number;
|
|
771
|
+
/** Seconds until the device code expires (GitHub default 900). */
|
|
772
|
+
readonly expiresIn?: number;
|
|
773
|
+
readonly host?: string;
|
|
774
|
+
readonly signal?: AbortSignal;
|
|
775
|
+
readonly requestTimeoutMs?: number;
|
|
776
|
+
}
|
|
777
|
+
|
|
778
|
+
export type GhDeviceFlowPollResult =
|
|
779
|
+
| { readonly status: 'authorized'; readonly access_token: string; readonly token_type: string; readonly scope: string }
|
|
780
|
+
| { readonly status: 'expired' | 'denied' | 'aborted' }
|
|
781
|
+
| { readonly status: 'error'; readonly error: string };
|
|
782
|
+
|
|
783
|
+
/** Resolves true after `ms`, or false as soon as `signal` aborts. */
|
|
784
|
+
function abortableDelay(ms: number, signal?: AbortSignal): Promise<boolean> {
|
|
785
|
+
return new Promise((resolve) => {
|
|
786
|
+
if (signal?.aborted) { resolve(false); return; }
|
|
787
|
+
const onAbort = () => { clearTimeout(timer); resolve(false); };
|
|
788
|
+
const timer = setTimeout(() => { signal?.removeEventListener('abort', onAbort); resolve(true); }, ms);
|
|
789
|
+
signal?.addEventListener('abort', onAbort, { once: true });
|
|
790
|
+
});
|
|
791
|
+
}
|
package/src/protocol.ts
CHANGED
|
@@ -5,7 +5,6 @@ import type { EndpointProfileConfiguration } from 'zhin.js/adapter';
|
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
7
|
import { createHmac, timingSafeEqual } from 'node:crypto';
|
|
8
|
-
import type { IncomingMessage } from 'node:http';
|
|
9
8
|
import { isMediaRef, type ConversationRef } from '@zhin.js/im-contract';
|
|
10
9
|
import { formatCompact, getLogger } from '@zhin.js/logger';
|
|
11
10
|
|
|
@@ -242,32 +241,12 @@ export function verifyWebhookSignature(
|
|
|
242
241
|
}
|
|
243
242
|
|
|
244
243
|
export function headerValue(
|
|
245
|
-
headers:
|
|
244
|
+
headers: Headers,
|
|
246
245
|
name: string,
|
|
247
246
|
): string {
|
|
248
|
-
|
|
249
|
-
if (Array.isArray(raw)) return raw[0] ?? '';
|
|
250
|
-
return raw ?? '';
|
|
247
|
+
return headers.get(name) ?? '';
|
|
251
248
|
}
|
|
252
249
|
|
|
253
|
-
export async function readTextBody(
|
|
254
|
-
request: IncomingMessage,
|
|
255
|
-
options: { readonly limit?: number } = {},
|
|
256
|
-
): Promise<string> {
|
|
257
|
-
const limit = options.limit ?? 1_048_576;
|
|
258
|
-
const chunks: Buffer[] = [];
|
|
259
|
-
let size = 0;
|
|
260
|
-
for await (const chunk of request) {
|
|
261
|
-
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
|
262
|
-
size += buffer.length;
|
|
263
|
-
if (size > limit) {
|
|
264
|
-
request.destroy();
|
|
265
|
-
throw new Error(`Request body exceeds ${limit} bytes`);
|
|
266
|
-
}
|
|
267
|
-
chunks.push(buffer);
|
|
268
|
-
}
|
|
269
|
-
return Buffer.concat(chunks).toString('utf8');
|
|
270
|
-
}
|
|
271
250
|
|
|
272
251
|
export function parseIssueCommentInbound(payload: IssueCommentPayload): GithubInboundComment | null {
|
|
273
252
|
if (payload.action !== 'created' || !payload.comment) return null;
|
|
@@ -366,8 +345,3 @@ export function formatNotification(event: string, p: GenericWebhookPayload): str
|
|
|
366
345
|
return `📬 ${repo}\n📡 ${event}${p.action ? ` (${p.action})` : ''} by ${sender}\n🔗 ${repoUrl}`;
|
|
367
346
|
}
|
|
368
347
|
}
|
|
369
|
-
|
|
370
|
-
/** Parse @mentions in markdown into a flat display string (gateway text path). */
|
|
371
|
-
export function parseMarkdownMentions(md: string): string {
|
|
372
|
-
return md;
|
|
373
|
-
}
|
package/src/webhook.ts
CHANGED
|
@@ -1,15 +1,14 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* GitHub webhook HTTP: verify → parse → admit inbound comments.
|
|
3
3
|
*/
|
|
4
|
-
import type { IncomingMessage, ServerResponse } from 'node:http';
|
|
5
4
|
import type { HttpHost, HttpRouteRegistration } from '@zhin.js/host-http';
|
|
5
|
+
import { readTextBody } from '@zhin.js/host-http-contract';
|
|
6
6
|
import { formatCompact, getLogger } from '@zhin.js/logger';
|
|
7
7
|
import {
|
|
8
8
|
headerValue,
|
|
9
9
|
parseIssueCommentInbound,
|
|
10
10
|
parsePrReviewCommentInbound,
|
|
11
11
|
parsePrReviewInbound,
|
|
12
|
-
readTextBody,
|
|
13
12
|
verifyWebhookSignature,
|
|
14
13
|
type GithubInboundComment,
|
|
15
14
|
type IssueCommentPayload,
|
|
@@ -32,22 +31,17 @@ export function registerGithubWebhookRoutes(
|
|
|
32
31
|
): HttpRouteRegistration[] {
|
|
33
32
|
const routePath = handler.config.webhookPath;
|
|
34
33
|
return [
|
|
35
|
-
http.route('POST', routePath,
|
|
36
|
-
await handleGithubWebhookRequest(request, response, handler);
|
|
37
|
-
}, { summary: 'GitHub webhook', tags: ['github'] }),
|
|
34
|
+
http.route('POST', routePath, (request) => handleGithubWebhookRequest(request, handler), { summary: 'GitHub webhook', tags: ['github'] }),
|
|
38
35
|
];
|
|
39
36
|
}
|
|
40
37
|
|
|
41
38
|
export async function handleGithubWebhookRequest(
|
|
42
|
-
request:
|
|
43
|
-
response: ServerResponse,
|
|
39
|
+
request: Request,
|
|
44
40
|
handler: GithubWebhookHandler,
|
|
45
|
-
): Promise<
|
|
41
|
+
): Promise<Response> {
|
|
46
42
|
const secret = handler.config.webhookSecret;
|
|
47
43
|
if (!secret) {
|
|
48
|
-
|
|
49
|
-
response.end(JSON.stringify({ error: 'Webhook not configured' }));
|
|
50
|
-
return;
|
|
44
|
+
return new Response(JSON.stringify({ error: 'Webhook not configured' }), { status: 503, headers: { 'Content-Type': 'application/json' } });
|
|
51
45
|
}
|
|
52
46
|
|
|
53
47
|
const signature = headerValue(request.headers, 'x-hub-signature-256');
|
|
@@ -55,9 +49,7 @@ export async function handleGithubWebhookRequest(
|
|
|
55
49
|
const deliveryId = headerValue(request.headers, 'x-github-delivery');
|
|
56
50
|
|
|
57
51
|
if (!signature || !event) {
|
|
58
|
-
|
|
59
|
-
response.end(JSON.stringify({ error: 'Missing signature or event header' }));
|
|
60
|
-
return;
|
|
52
|
+
return new Response(JSON.stringify({ error: 'Missing signature or event header' }), { status: 400, headers: { 'Content-Type': 'application/json' } });
|
|
61
53
|
}
|
|
62
54
|
|
|
63
55
|
try {
|
|
@@ -69,32 +61,23 @@ export async function handleGithubWebhookRequest(
|
|
|
69
61
|
error: 'invalid signature',
|
|
70
62
|
delivery: deliveryId,
|
|
71
63
|
}));
|
|
72
|
-
|
|
73
|
-
response.end(JSON.stringify({ error: 'Invalid signature' }));
|
|
74
|
-
return;
|
|
64
|
+
return new Response(JSON.stringify({ error: 'Invalid signature' }), { status: 401, headers: { 'Content-Type': 'application/json' } });
|
|
75
65
|
}
|
|
76
66
|
|
|
77
67
|
let payload: unknown;
|
|
78
68
|
try {
|
|
79
69
|
payload = rawBody ? JSON.parse(rawBody) : {};
|
|
80
70
|
} catch {
|
|
81
|
-
|
|
82
|
-
response.end(JSON.stringify({ error: 'Invalid JSON' }));
|
|
83
|
-
return;
|
|
71
|
+
return new Response(JSON.stringify({ error: 'Invalid JSON' }), { status: 400, headers: { 'Content-Type': 'application/json' } });
|
|
84
72
|
}
|
|
85
73
|
|
|
86
|
-
response.writeHead(200, { 'Content-Type': 'application/json' });
|
|
87
|
-
response.end(JSON.stringify({ ok: true }));
|
|
88
|
-
|
|
89
74
|
void dispatchGithubWebhookPayload(event, payload, handler).catch((e) => {
|
|
90
75
|
logger.error(`Webhook 事件处理失败 (${event}):`, e);
|
|
91
76
|
});
|
|
77
|
+
return new Response(JSON.stringify({ ok: true }), { status: 200, headers: { 'Content-Type': 'application/json' } });
|
|
92
78
|
} catch (error) {
|
|
93
79
|
logger.error('Webhook error:', error);
|
|
94
|
-
|
|
95
|
-
response.writeHead(500, { 'Content-Type': 'application/json' });
|
|
96
|
-
response.end(JSON.stringify({ error: 'Internal Server Error' }));
|
|
97
|
-
}
|
|
80
|
+
return new Response(JSON.stringify({ error: 'Internal Server Error' }), { status: 500, headers: { 'Content-Type': 'application/json' } });
|
|
98
81
|
}
|
|
99
82
|
}
|
|
100
83
|
|