koishi-plugin-githubsth 1.0.6 → 1.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.
package/README.en.md ADDED
@@ -0,0 +1,68 @@
1
+ # koishi-plugin-githubsth
2
+
3
+ [![npm](https://img.shields.io/npm/v/koishi-plugin-githubsth?style=flat-square)](https://www.npmjs.com/package/koishi-plugin-githubsth)
4
+
5
+ A Koishi plugin that delivers GitHub event notifications with repository-level subscription management.
6
+
7
+ ## Features
8
+
9
+ - GitHub event notifications: `push`, `issues`, `issue_comment`, `pull_request`, `pull_request_review`, `release`, `star`, `fork`, `discussion`, `workflow_run`
10
+ - Trusted repository allowlist (admin-only)
11
+ - Channel-level subscription persistence (via Koishi `database`)
12
+ - Text/image/auto rendering mode
13
+ - Theme and style controls (global and per-subscription)
14
+ - Digest aggregation mode
15
+ - Built-in i18n (`zh-CN`, `en-US`)
16
+
17
+ ## Installation
18
+
19
+ ```bash
20
+ npm i koishi-plugin-githubsth
21
+ ```
22
+
23
+ ## Requirements
24
+
25
+ - `koishi` `^4.18.0`
26
+ - `koishi-plugin-adapter-github` `^1.0.0`
27
+ - Koishi `database` service (required)
28
+ - `puppeteer` service (optional, required for image rendering)
29
+
30
+ ## Configuration
31
+
32
+ Main config fields:
33
+
34
+ - `defaultOwner`, `defaultRepo`
35
+ - `defaultEvents`
36
+ - `debug`, `logUnhandledEvents`
37
+ - `formatterLocale`
38
+ - `renderMode`, `renderFallback`
39
+ - `renderTheme`, `renderStyle`
40
+ - `renderWidth`, `renderTimeoutMs`
41
+ - `digestEnabled`, `digestWindowSec`, `digestMaxItems`
42
+
43
+ ## Commands
44
+
45
+ User commands:
46
+
47
+ - `githubsth.subscribe <owner/repo> [events]`
48
+ - `githubsth.unsubscribe <owner/repo>`
49
+ - `githubsth.list`
50
+ - `githubsth.repo [owner/repo]`
51
+
52
+ Admin commands (authority `3`):
53
+
54
+ - `githubsth.trust.add <owner/repo>`
55
+ - `githubsth.trust.remove <owner/repo>`
56
+ - `githubsth.trust.list`
57
+ - `githubsth.trust.enable <owner/repo>`
58
+ - `githubsth.trust.disable <owner/repo>`
59
+ - `githubsth.render.*` (render mode/theme/style/preview/digest controls)
60
+
61
+ ## Notes
62
+
63
+ - `githubsth.subscribe` only works for repositories in trusted list.
64
+ - If `renderMode=auto` and image rendering is unavailable, fallback behavior follows `renderFallback`.
65
+
66
+ ## License
67
+
68
+ MIT
package/README.md CHANGED
@@ -1,69 +1,180 @@
1
1
  # koishi-plugin-githubsth
2
2
 
3
- [![npm](https://img.shields.io/npm/v/koishi-plugin-githubsth?style=flat-square)](https://www.npmjs.com/package/koishi-plugin-githubsth)
3
+ [![npm](https://img.shields.io/npm/v/koishi-plugin-githubsth)](https://www.npmjs.com/package/koishi-plugin-githubsth)
4
4
 
5
- Koishi GitHub 集成插件,提供强大的事件通知服务和仓库管理命令。支持数据库存储订阅关系,并提供信任仓库管理功能。
6
-
7
- ## 功能特性
8
-
9
- - **事件通知**: 实时接收 GitHub 事件通知 (Push, Issue, PR, Release, Star, Fork, Discussion, Workflow)。
10
- - **数据库订阅**: 订阅关系持久化存储在 Koishi 数据库中,支持动态管理。
11
- - **信任仓库管理**: 管理员可配置允许订阅的仓库白名单,确保安全。
12
- - **灵活的规则**: 支持按仓库、目标频道和事件类型进行订阅。
13
- - **丰富格式**: 消息格式美观,包含关键信息。
14
- - **调试模式**: 支持开启详细日志输出,方便排查问题。
5
+ > GitHub 订阅通知插件 — 监控仓库事件,推送到你的聊天频道 ✨
15
6
 
16
7
  ## 安装
17
8
 
18
9
  ```bash
19
10
  npm install koishi-plugin-githubsth
11
+ # 或者
12
+ yarn add koishi-plugin-githubsth
13
+ # 或
14
+ pipx install ... # 开玩笑的,Koishi 用 npm 啦!
20
15
  ```
21
16
 
22
- ## 配置
17
+ 然后在 Koishi 配置中添加插件:
23
18
 
24
- 本插件需要 `database` 服务和 `koishi-plugin-adapter-github` 才能正常工作。
19
+ ```yaml
20
+ plugins:
21
+ githubsth:
22
+ # 见下方配置
23
+ ```
25
24
 
26
- ### 插件配置
25
+ ## 前置依赖
27
26
 
28
- Koishi 控制台中配置插件:
27
+ | 依赖 | 版本要求 | 说明 |
28
+ |:----|:-------:|:----|
29
+ | koishi | ^4.18.0 | 嗯,就是 Koishi 本体 |
30
+ | koishi-plugin-adapter-github | ^1.0.0 | GitHub 适配器,提供事件源 |
31
+ | database | — | 任意 Koishi 数据库(内置 memory 也行) |
32
+ | puppeteer | *可选* | 需要图片渲染时加这个 |
29
33
 
30
- - **defaultOwner**: 默认仓库拥有者 (可选)。
31
- - **defaultRepo**: 默认仓库名称 (可选)。
32
- - **debug**: 启用调试模式,输出详细日志 (默认 false)。
34
+ > 💡 如果不需要图片渲染(纯文本模式),puppeteer 可以不装。
33
35
 
34
- ### 订阅管理
36
+ ## 快速开始
35
37
 
36
- 插件使用数据库管理订阅。
38
+ ### 1. 添加可信仓库
37
39
 
38
- #### 管理员命令 (权限等级 3)
40
+ 只有添加到可信列表的仓库才能被订阅:
39
41
 
40
- - `githubsth.trust.add <repo>`: 添加信任仓库 (owner/repo)。
41
- - `githubsth.trust.remove <repo>`: 移除信任仓库。
42
- - `githubsth.trust.list`: 列出所有信任仓库。
43
- - `githubsth.trust.enable <repo>`: 启用信任仓库。
44
- - `githubsth.trust.disable <repo>`: 禁用信任仓库。
42
+ ```
43
+ gh.trust add owner/repo
44
+ ```
45
45
 
46
- #### 用户命令
46
+ ### 2. 订阅仓库事件
47
47
 
48
- - `githubsth.subscribe <repo> [events]`: 订阅仓库事件。
49
- - `repo`: 仓库全名 (owner/repo)。
50
- - `events`: (可选) 订阅的事件列表,逗号分隔。默认为 `push, issues, pull_request`。
51
- - 注意:仅能订阅已添加到信任列表的仓库。
52
- - `githubsth.unsubscribe <repo>`: 取消订阅仓库。
53
- - `githubsth.list`: 查看当前频道的订阅列表。
54
- - `githubsth.repo [name]`: 获取仓库信息。
48
+ ```
49
+ gh.sub owner/repo
50
+ gh.sub owner/repo push,issues,star
51
+ ```
52
+
53
+ ### 3. 查看效果
55
54
 
56
- ### 支持的事件
55
+ 当仓库有 push、issue、PR 等事件时,插件会自动推送到当前频道~
57
56
 
58
- - `push`: 代码推送
59
- - `issues`: Issue 创建、关闭等
60
- - `pull_request`: PR 创建、合并等
61
- - `release`: 新版本发布
62
- - `star`: 仓库标星
63
- - `fork`: 仓库复刻
64
- - `discussion`: 讨论区动态
65
- - `workflow_run`: CI/CD 工作流状态
57
+ ## 命令列表
58
+
59
+ ### 订阅管理
66
60
 
67
- ## 许可证
61
+ | 命令 | 别名 | 说明 |
62
+ |:----|:----|:----|
63
+ | `githubsth.subscribe <repo> [events]` | `gh.sub` | 订阅仓库事件 |
64
+ | `githubsth.unsubscribe <repo>` | `gh.unsub` | 取消订阅 |
65
+ | `githubsth.list` | `gh.list` | 查看当前频道的订阅列表 |
66
+
67
+ ### 可信仓库管理
68
+
69
+ | 命令 | 别名 | 说明 |
70
+ |:----|:----|:----|
71
+ | `githubsth.trust add <repo>` | `gh.trust add` | 添加可信仓库 |
72
+ | `githubsth.trust remove <repo>` | `gh.trust remove` | 移除可信仓库 |
73
+ | `githubsth.trust list` | `gh.trust list` | 查看可信仓库列表 |
74
+
75
+ ### 渲染设置
76
+
77
+ | 命令 | 别名 | 说明 |
78
+ |:----|:----|:----|
79
+ | `githubsth.render.mode <mode>` | `gh.render mode` | 设置渲染模式(text/image/auto) |
80
+ | `githubsth.render.theme <theme>` | `gh.render theme` | 切换主题 |
81
+ | `githubsth.render.style <style>` | `gh.render style` | 切换排版风格 |
82
+ | `githubsth.render.preview [event]` | `gh.render preview` | 预览渲染效果 |
83
+ | `githubsth.render.status` | `gh.render status` | 查看当前渲染配置 |
84
+
85
+ ### 管理命令
86
+
87
+ | 命令 | 说明 |
88
+ |:----|:----|
89
+ | `githubsth.admin.subscribe <channel> <repo>` | 强制给指定频道订阅 |
90
+ | `githubsth.admin.unsubscribe <channel> <repo>` | 强制取消订阅 |
91
+ | `githubsth.admin.cleanup` | 清理失效的频道订阅 |
92
+
93
+ ## 配置项
94
+
95
+ | 配置项 | 类型 | 默认值 | 说明 |
96
+ |:------|:---:|:-----:|:----|
97
+ | `defaultEvents` | `string[]` | push, issues, pull_request 等 | 订阅时默认监听的事件 |
98
+ | `renderMode` | `'auto' \| 'image' \| 'text'` | `'auto'` | 渲染模式 |
99
+ | `renderTheme` | `string` | `'github-dark'` | 默认主题 |
100
+ | `renderStyle` | `string` | `'auto'` | 默认排版风格 |
101
+ | `renderWidth` | `number` | `860` | 图片宽度(px) |
102
+ | `renderTimeoutMs` | `number` | `12000` | 图片渲染超时 |
103
+ | `renderFallback` | `'text' \| 'drop'` | `'text'` | 图片渲染失败时的策略 |
104
+ | `digestEnabled` | `boolean` | `false` | 启用聚合推送 |
105
+ | `digestWindowSec` | `number` | `60` | 聚合窗口(秒) |
106
+ | `digestMaxItems` | `number` | `12` | 每条聚合最大事件数 |
107
+ | `formatterLocale` | `'zh-CN' \| 'en-US'` | `'zh-CN'` | 通知语言 |
108
+ | `debug` | `boolean` | `false` | 调试日志 |
109
+ | `logUnhandledEvents` | `boolean` | `false` | 记录未处理的事件 |
110
+ | `enableSessionFallback` | `boolean` | `true` | 启用消息会话兜底解析 |
111
+ | `dedupRetentionHours` | `number` | `72` | 去重记录保留时间 |
112
+ | `sendRetryCount` | `number` | `2` | 发送失败重试次数 |
113
+ | `sendRetryBaseDelayMs` | `number` | `800` | 重试基准延迟 |
114
+
115
+ ## 主题与排版
116
+
117
+ ### 内置主题
118
+
119
+ | 主题 | 说明 |
120
+ |:----|:----|
121
+ | `github-light` | GitHub 亮色 ✨ |
122
+ | `github-dark` | GitHub 暗色 🌙 |
123
+ | `aurora` | 极光主题 🌌 |
124
+ | `sunset` | 落日主题 🌅 |
125
+ | `matrix` | 矩阵主题 💚 |
126
+ | `compact` | 紧凑主题 |
127
+ | `card` | 卡片主题 |
128
+ | `terminal` | 终端主题 💻 |
129
+
130
+ ### 排版风格
131
+
132
+ | 风格 | 说明 |
133
+ |:----|:----|
134
+ | `auto` | 跟随主题默认排版 |
135
+ | `github` | GitHub 风格 |
136
+ | `glass` | 玻璃风格 |
137
+ | `neon` | 霓虹风格 |
138
+ | `compact` | 紧凑风格 |
139
+ | `card` | 卡片风格 |
140
+ | `terminal` | 终端风格 |
141
+
142
+ ## 支持的事件类型
143
+
144
+ | 事件 | 说明 |
145
+ |:----|:----|
146
+ | `push` | 代码推送 🚀 |
147
+ | `issues` | Issue 相关 |
148
+ | `issue_comment` | Issue 评论 💬 |
149
+ | `pull_request` | PR 相关 |
150
+ | `pull_request_review` | PR 审核 👀 |
151
+ | `star` | Star ⭐ |
152
+ | `fork` | Fork 🍴 |
153
+ | `release` | 版本发布 🏷️ |
154
+ | `discussion` | Discussion 💭 |
155
+ | `workflow_run` | Actions 工作流 ⚙️ |
156
+
157
+ ## 常见问题
158
+
159
+ ### Q: 事件收不到?
160
+
161
+ 1. 检查是否添加了可信仓库:`gh.trust list`
162
+ 2. 检查是否订阅了:`gh.list`
163
+ 3. 确认 GitHub 适配器配置正确,Webhook 或 Pull 模式已启用
164
+ 4. 看日志有没有报错:开启 `debug: true`
165
+
166
+ ### Q: 图片渲染不出来?
167
+
168
+ 1. 确认装了 puppeteer 插件
169
+ 2. 检查 `renderTimeoutMs` 是否太短
170
+ 3. 插件会自动回退到文本模式(如果 `renderFallback` 是 `'text'`)
171
+
172
+ ### Q: 同一个事件收到了多次?
173
+
174
+ 内置去重机制:
175
+ - 内存去重:5 秒内相同事件自动过滤
176
+ - 数据库去重:按 `dedupRetentionHours` 保留记录
177
+
178
+ ## License
68
179
 
69
180
  MIT
@@ -0,0 +1,68 @@
1
+ # koishi-plugin-githubsth
2
+
3
+ [![npm](https://img.shields.io/npm/v/koishi-plugin-githubsth?style=flat-square)](https://www.npmjs.com/package/koishi-plugin-githubsth)
4
+
5
+ 一个用于 Koishi 的 GitHub 订阅通知插件,支持仓库级订阅管理与多样化渲染。
6
+
7
+ ## 功能特性
8
+
9
+ - GitHub 事件通知:`push`、`issues`、`issue_comment`、`pull_request`、`pull_request_review`、`release`、`star`、`fork`、`discussion`、`workflow_run`
10
+ - 可信仓库白名单(管理员)
11
+ - 基于 Koishi `database` 的频道订阅持久化
12
+ - 文本/图片/自动渲染模式
13
+ - 全局与订阅级主题/样式控制
14
+ - Digest 聚合推送
15
+ - 内置中英文文案(`zh-CN`、`en-US`)
16
+
17
+ ## 安装
18
+
19
+ ```bash
20
+ npm i koishi-plugin-githubsth
21
+ ```
22
+
23
+ ## 依赖要求
24
+
25
+ - `koishi` `^4.18.0`
26
+ - `koishi-plugin-adapter-github` `^1.0.0`
27
+ - Koishi `database` 服务(必需)
28
+ - `puppeteer` 服务(可选,图片渲染需要)
29
+
30
+ ## 配置项
31
+
32
+ 主要配置包括:
33
+
34
+ - `defaultOwner`、`defaultRepo`
35
+ - `defaultEvents`
36
+ - `debug`、`logUnhandledEvents`
37
+ - `formatterLocale`
38
+ - `renderMode`、`renderFallback`
39
+ - `renderTheme`、`renderStyle`
40
+ - `renderWidth`、`renderTimeoutMs`
41
+ - `digestEnabled`、`digestWindowSec`、`digestMaxItems`
42
+
43
+ ## 指令
44
+
45
+ 用户指令:
46
+
47
+ - `githubsth.subscribe <owner/repo> [events]`
48
+ - `githubsth.unsubscribe <owner/repo>`
49
+ - `githubsth.list`
50
+ - `githubsth.repo [owner/repo]`
51
+
52
+ 管理员指令(权限 `3`):
53
+
54
+ - `githubsth.trust.add <owner/repo>`
55
+ - `githubsth.trust.remove <owner/repo>`
56
+ - `githubsth.trust.list`
57
+ - `githubsth.trust.enable <owner/repo>`
58
+ - `githubsth.trust.disable <owner/repo>`
59
+ - `githubsth.render.*`(渲染模式/主题/样式/预览/digest 控制)
60
+
61
+ ## 使用说明
62
+
63
+ - `githubsth.subscribe` 仅允许订阅已加入可信列表的仓库。
64
+ - 当 `renderMode=auto` 且图片渲染不可用时,将按 `renderFallback` 回退。
65
+
66
+ ## 许可证
67
+
68
+ MIT
@@ -13,7 +13,12 @@ export declare class Notifier extends Service {
13
13
  private dedupWriteCounter;
14
14
  private runtimeRenderMode;
15
15
  private readonly digestBuckets;
16
+ private healthCheckTimer;
16
17
  constructor(ctx: Context, config: Config);
18
+ /** 启动健康检查:定期检查数据库连接和订阅状态 */
19
+ private startHealthCheck;
20
+ /** 执行健康检查 */
21
+ private performHealthCheck;
17
22
  listThemes(): RenderTheme[];
18
23
  normalizeTheme(theme?: string | null): RenderTheme | null;
19
24
  listStyles(): RenderStyle[];
@@ -36,6 +41,11 @@ export declare class Notifier extends Service {
36
41
  renderPreview(event?: string, theme?: RenderTheme | null): Promise<any>;
37
42
  private registerListeners;
38
43
  private handleEvent;
44
+ /**
45
+ * 将新适配器的结构化事件数据转换为 formatter 能用的扁平格式
46
+ * 兼容旧格式 (repository.full_name, sender.login 等)
47
+ */
48
+ private buildFlatPayload;
39
49
  private resolveRuleTheme;
40
50
  private resolveRuleStyle;
41
51
  private formatByEvent;
@@ -45,12 +55,10 @@ export declare class Notifier extends Service {
45
55
  private renderTextAsImage;
46
56
  private normalizeRenderedImage;
47
57
  private extractRepoName;
48
- private patchPayloadForEvent;
49
58
  private buildEventDedupKey;
50
59
  private shouldProcessEvent;
51
60
  private cleanupDedupTable;
52
61
  private sendMessage;
53
62
  private sendWithRetry;
54
- private sleep;
55
63
  private getPreviewPayload;
56
64
  }
@@ -14,8 +14,37 @@ class Notifier extends koishi_1.Service {
14
14
  this.dedupWriteCounter = 0;
15
15
  this.runtimeRenderMode = null;
16
16
  this.digestBuckets = new Map();
17
+ this.healthCheckTimer = null;
17
18
  this.ctx.logger('githubsth').info('Notifier service initialized');
18
19
  this.registerListeners();
20
+ this.startHealthCheck();
21
+ }
22
+ /** 启动健康检查:定期检查数据库连接和订阅状态 */
23
+ startHealthCheck() {
24
+ const interval = 5 * 60 * 1000; // 每 5 分钟检查一次
25
+ this.healthCheckTimer = setInterval(() => {
26
+ void this.performHealthCheck();
27
+ }, interval);
28
+ // 启动后马上检查一次
29
+ void this.performHealthCheck();
30
+ }
31
+ /** 执行健康检查 */
32
+ async performHealthCheck() {
33
+ try {
34
+ // 检查数据库连通性
35
+ const count = await this.ctx.database.get('github_subscription', {});
36
+ if (this.config.debug) {
37
+ this.ctx.logger('githubsth').debug(`Health check OK — ${count.length} active subscriptions`);
38
+ }
39
+ }
40
+ catch (error) {
41
+ this.ctx.logger('githubsth').error('Health check failed — database may be unavailable:', error);
42
+ // 数据库不可用时,5 秒后重试一次,然后等下一轮
43
+ setTimeout(() => {
44
+ this.ctx.logger('githubsth').info('Retrying health check...');
45
+ void this.performHealthCheck();
46
+ }, 5000);
47
+ }
19
48
  }
20
49
  listThemes() {
21
50
  return (0, render_card_1.listRenderThemes)();
@@ -68,17 +97,13 @@ class Notifier extends koishi_1.Service {
68
97
  bind('github/issue', 'issues');
69
98
  bind('github/issue-comment', 'issue_comment');
70
99
  bind('github/pull-request', 'pull_request');
71
- bind('github/pull-request-review', 'pull_request_review');
100
+ bind('github/pull-request-review-comment', 'pull_request_review');
72
101
  bind('github/workflow-run', 'workflow_run');
73
102
  bind('github/push', 'push');
74
103
  bind('github/star', 'star');
75
104
  bind('github/fork', 'fork');
76
105
  bind('github/release', 'release');
77
106
  bind('github/discussion', 'discussion');
78
- bind('github/issues', 'issues');
79
- bind('github/pull_request', 'pull_request');
80
- bind('github/workflow_run', 'workflow_run');
81
- bind('github/issue_comment', 'issue_comment');
82
107
  if (this.config.enableSessionFallback !== false) {
83
108
  this.ctx.on('message-created', (session) => {
84
109
  if (session.platform !== 'github')
@@ -86,28 +111,30 @@ class Notifier extends koishi_1.Service {
86
111
  const payload = session.payload || session.extra || session.data;
87
112
  if (!payload)
88
113
  return;
114
+ // 新适配器的 session fallback:payload 已经是结构化事件数据
115
+ // 包含 owner, repo, repoKey, actor, payload(原始), type, action
89
116
  const realPayload = payload.payload || payload;
90
117
  let eventType = 'unknown';
91
118
  if (realPayload.issue && realPayload.comment)
92
119
  eventType = 'issue_comment';
93
120
  else if (realPayload.issue)
94
121
  eventType = 'issues';
95
- else if (realPayload.pull_request && realPayload.review)
122
+ else if (realPayload.pullRequest && realPayload.comment)
96
123
  eventType = 'pull_request_review';
97
- else if (realPayload.pull_request)
124
+ else if (realPayload.pullRequest)
98
125
  eventType = 'pull_request';
99
126
  else if (realPayload.commits)
100
127
  eventType = 'push';
101
- else if (realPayload.starred_at !== undefined || realPayload.action === 'started')
102
- eventType = 'star';
103
128
  else if (realPayload.forkee)
104
129
  eventType = 'fork';
105
130
  else if (realPayload.release)
106
131
  eventType = 'release';
107
132
  else if (realPayload.discussion)
108
133
  eventType = 'discussion';
109
- else if (realPayload.workflow_run)
134
+ else if (realPayload.workflowRun)
110
135
  eventType = 'workflow_run';
136
+ else if (realPayload.starred_at !== undefined || payload.action === 'started')
137
+ eventType = 'star';
111
138
  else if (realPayload.repository && (realPayload.action === 'created' || realPayload.action === 'started'))
112
139
  eventType = 'star';
113
140
  if (eventType !== 'unknown')
@@ -116,36 +143,21 @@ class Notifier extends koishi_1.Service {
116
143
  }
117
144
  }
118
145
  async handleEvent(event, payload) {
146
+ // 新适配器 v1.1.2 的 payload 是结构化事件数据
147
+ // 包含 owner, repo, repoKey, actor, action, timestamp
148
+ // 以及对应的 issue, comment, pullRequest, discussion 等
149
+ // realPayload 是原始 GitHub webhook payload(如果存在)
119
150
  const realPayload = payload.payload || payload;
120
- if (payload.actor && !realPayload.sender) {
121
- const actorLogin = payload.actor.login || payload.actor.name || 'GitHub';
122
- realPayload.sender = { ...payload.actor, login: actorLogin };
123
- }
124
- if (payload.repository && !realPayload.repository) {
125
- realPayload.repository = payload.repository;
126
- }
127
- let repoName = this.extractRepoName(payload, realPayload, event);
128
- if (!realPayload.repository)
129
- realPayload.repository = { full_name: repoName || 'Unknown/Repo' };
130
- else if (!realPayload.repository.full_name)
131
- realPayload.repository.full_name = repoName || 'Unknown/Repo';
132
- if (!realPayload.sender) {
133
- if (realPayload.issue?.user)
134
- realPayload.sender = realPayload.issue.user;
135
- else if (realPayload.pull_request?.user)
136
- realPayload.sender = realPayload.pull_request.user;
137
- else if (realPayload.discussion?.user)
138
- realPayload.sender = realPayload.discussion.user;
139
- else if (realPayload.pusher)
140
- realPayload.sender = { login: realPayload.pusher.name || 'Pusher' };
141
- else
142
- realPayload.sender = { login: 'GitHub' };
143
- }
144
- if (!(await this.shouldProcessEvent(event, payload, realPayload, repoName)))
145
- return;
146
- this.patchPayloadForEvent(event, realPayload, repoName || 'Unknown/Repo');
147
- repoName = repoName || realPayload.repository?.full_name;
148
- if (!repoName)
151
+ // 从结构化数据中提取 repo 信息
152
+ const repoName = payload.repoKey
153
+ || (payload.owner && payload.repo ? `${payload.owner}/${payload.repo}` : null)
154
+ || this.extractRepoName(payload, realPayload, event)
155
+ || realPayload.repository?.full_name
156
+ || 'Unknown/Repo';
157
+ // 确保 formatter 能读取到数据
158
+ // 构造一个兼容新旧格式的扁平 payload formatter 用
159
+ const flatPayload = this.buildFlatPayload(payload, realPayload, event, repoName);
160
+ if (!(await this.shouldProcessEvent(event, payload, flatPayload, repoName)))
149
161
  return;
150
162
  const repoNames = [repoName];
151
163
  if (repoName !== repoName.toLowerCase())
@@ -160,22 +172,76 @@ class Notifier extends koishi_1.Service {
160
172
  if (!matchedRules.length)
161
173
  return;
162
174
  const uniqueRules = Array.from(new Map(matchedRules.map((rule) => [`${repoName}|${rule.channelId}`, rule])).values());
163
- const textMessage = this.formatByEvent(event, realPayload);
175
+ const textMessage = this.formatByEvent(event, flatPayload);
164
176
  if (!textMessage)
165
177
  return;
166
178
  for (const rule of uniqueRules) {
167
179
  const theme = this.resolveRuleTheme(rule);
168
180
  const style = this.resolveRuleStyle(rule);
169
181
  if (this.config.digestEnabled) {
170
- this.enqueueDigest({ event, repo: repoName, text: textMessage, payload: realPayload, theme, style, rule });
182
+ this.enqueueDigest({ event, repo: repoName, text: textMessage, payload: flatPayload, theme, style, rule });
171
183
  continue;
172
184
  }
173
- const outbound = await this.prepareOutboundMessage(textMessage, event, realPayload, theme, style);
185
+ const outbound = await this.prepareOutboundMessage(textMessage, event, flatPayload, theme, style);
174
186
  if (!outbound)
175
187
  continue;
176
188
  await this.sendMessage(rule, outbound);
177
189
  }
178
190
  }
191
+ /**
192
+ * 将新适配器的结构化事件数据转换为 formatter 能用的扁平格式
193
+ * 兼容旧格式 (repository.full_name, sender.login 等)
194
+ */
195
+ buildFlatPayload(payload, realPayload, event, repoName) {
196
+ // 如果已经是旧格式(有 repository.full_name),直接返回
197
+ if (realPayload.repository?.full_name)
198
+ return realPayload;
199
+ const flat = { ...realPayload };
200
+ const actor = payload.actor || realPayload.actor || {};
201
+ const actorLogin = actor.login || actor.name || 'GitHub';
202
+ // 构造 repository 对象
203
+ if (!flat.repository) {
204
+ flat.repository = {
205
+ full_name: repoName,
206
+ stargazers_count: realPayload.repository?.stargazers_count
207
+ || payload.repository?.stargazers_count
208
+ || 0,
209
+ html_url: `https://github.com/${repoName}`,
210
+ };
211
+ }
212
+ // 构造 sender
213
+ if (!flat.sender) {
214
+ flat.sender = {
215
+ login: actorLogin,
216
+ id: actor.id || 0,
217
+ avatar_url: actor.avatar_url || '',
218
+ };
219
+ }
220
+ // 统一字段名:新适配器用 camelCase,旧格式用 snake_case
221
+ // issue -> 已有,不变
222
+ // pullRequest -> 需要映射到 pull_request
223
+ if (payload.pullRequest && !flat.pull_request) {
224
+ flat.pull_request = payload.pullRequest;
225
+ }
226
+ if (payload.workflowRun && !flat.workflow_run) {
227
+ flat.workflow_run = payload.workflowRun;
228
+ }
229
+ if (payload.workflow && !flat.workflow) {
230
+ flat.workflow = payload.workflow;
231
+ }
232
+ if (payload.headCommit && !flat.head_commit) {
233
+ flat.head_commit = payload.headCommit;
234
+ }
235
+ // action
236
+ if (payload.action && !flat.action) {
237
+ flat.action = payload.action;
238
+ }
239
+ // 补全其他字段
240
+ if (!flat.pusher && payload.actor) {
241
+ flat.pusher = { name: actorLogin };
242
+ }
243
+ return flat;
244
+ }
179
245
  resolveRuleTheme(rule) {
180
246
  return this.normalizeTheme(rule.renderTheme) || this.normalizeTheme(this.config.renderTheme) || 'github-dark';
181
247
  }
@@ -308,6 +374,8 @@ class Notifier extends koishi_1.Service {
308
374
  }
309
375
  if (!repoName && realPayload.pull_request?.base?.repo?.full_name)
310
376
  repoName = realPayload.pull_request.base.repo.full_name;
377
+ if (!repoName && realPayload.pullRequest?.base?.repo?.full_name)
378
+ repoName = realPayload.pullRequest.base.repo.full_name;
311
379
  if (!repoName && typeof payload.repoKey === 'string' && payload.repoKey.includes('/'))
312
380
  repoName = payload.repoKey;
313
381
  if (!repoName && typeof payload.owner === 'string' && typeof payload.repo === 'string')
@@ -321,109 +389,18 @@ class Notifier extends koishi_1.Service {
321
389
  }
322
390
  return repoName;
323
391
  }
324
- patchPayloadForEvent(event, payload, repoName) {
325
- const defaultUser = { login: 'GitHub', id: 0, avatar_url: '' };
326
- if (!payload.sender)
327
- payload.sender = defaultUser;
328
- const defaultRepo = { full_name: repoName, stargazers_count: 0, html_url: `https://github.com/${repoName}` };
329
- if (!payload.repository)
330
- payload.repository = defaultRepo;
331
- switch (event) {
332
- case 'push':
333
- if (!payload.pusher)
334
- payload.pusher = { name: payload.sender.login };
335
- if (!payload.commits)
336
- payload.commits = [];
337
- if (!payload.ref)
338
- payload.ref = 'refs/heads/unknown';
339
- if (!payload.compare)
340
- payload.compare = '';
341
- for (const commit of payload.commits) {
342
- if (!commit.author)
343
- commit.author = { name: 'Unknown' };
344
- if (!commit.id)
345
- commit.id = '0000000';
346
- if (!commit.message)
347
- commit.message = 'No message';
348
- }
349
- break;
350
- case 'issues':
351
- if (!payload.action)
352
- payload.action = 'updated';
353
- if (!payload.issue)
354
- payload.issue = { number: 0, title: 'Unknown Issue', html_url: '', user: payload.sender };
355
- if (!payload.issue.user)
356
- payload.issue.user = payload.sender;
357
- break;
358
- case 'pull_request':
359
- if (!payload.action)
360
- payload.action = 'updated';
361
- if (!payload.pull_request)
362
- payload.pull_request = { number: 0, title: 'Unknown PR', state: 'unknown', html_url: '', user: payload.sender };
363
- if (!payload.pull_request.user)
364
- payload.pull_request.user = payload.sender;
365
- break;
366
- case 'star':
367
- if (!payload.action || payload.action === 'started')
368
- payload.action = 'created';
369
- if (payload.repository?.stargazers_count === undefined)
370
- payload.repository.stargazers_count = '?';
371
- break;
372
- case 'fork':
373
- if (!payload.forkee)
374
- payload.forkee = { full_name: 'unknown/fork' };
375
- break;
376
- case 'release':
377
- if (!payload.action)
378
- payload.action = 'published';
379
- if (!payload.release)
380
- payload.release = { tag_name: 'unknown', name: 'Unknown Release', html_url: '' };
381
- break;
382
- case 'discussion':
383
- if (!payload.action)
384
- payload.action = 'updated';
385
- if (!payload.discussion)
386
- payload.discussion = { number: 0, title: 'Unknown Discussion', html_url: '', user: payload.sender };
387
- if (!payload.discussion.user)
388
- payload.discussion.user = payload.sender;
389
- break;
390
- case 'workflow_run':
391
- if (!payload.action)
392
- payload.action = 'completed';
393
- if (!payload.workflow_run)
394
- payload.workflow_run = { conclusion: 'unknown', name: 'Unknown Workflow', head_branch: 'unknown', html_url: '' };
395
- break;
396
- case 'issue_comment':
397
- if (!payload.action)
398
- payload.action = 'created';
399
- if (!payload.issue)
400
- payload.issue = { number: 0, title: 'Unknown Issue', html_url: '', user: payload.sender };
401
- if (!payload.comment)
402
- payload.comment = { body: '', html_url: '' };
403
- if (!payload.issue.user)
404
- payload.issue.user = payload.sender;
405
- break;
406
- case 'pull_request_review':
407
- if (!payload.action)
408
- payload.action = 'submitted';
409
- if (!payload.pull_request)
410
- payload.pull_request = { number: 0, title: 'Unknown PR', html_url: '', user: payload.sender };
411
- if (!payload.review)
412
- payload.review = { state: 'unknown', html_url: '' };
413
- if (!payload.pull_request.user)
414
- payload.pull_request.user = payload.sender;
415
- break;
416
- }
417
- }
418
392
  buildEventDedupKey(event, payload, realPayload, repoName) {
419
393
  const keyRepo = repoName || payload.repoKey || `${payload.owner || ''}/${payload.repo || ''}` || realPayload.repository?.full_name || 'unknown/repo';
420
394
  const action = realPayload.action || payload.action || '';
421
- const commentId = realPayload.comment?.id || '';
395
+ const commentId = realPayload.comment?.id || payload.comment?.id || '';
422
396
  const issueId = realPayload.issue?.id || realPayload.issue?.number || '';
423
- const prId = realPayload.pull_request?.id || realPayload.pull_request?.number || '';
397
+ const prId = realPayload.pull_request?.id || realPayload.pull_request?.number
398
+ || realPayload.pullRequest?.id || realPayload.pullRequest?.number || '';
424
399
  const releaseId = realPayload.release?.id || realPayload.release?.tag_name || '';
425
- const workflowId = realPayload.workflow_run?.id || realPayload.workflow_run?.run_id || '';
426
- const headCommit = realPayload.head_commit?.id || realPayload.after || realPayload.commits?.[0]?.id || '';
400
+ const workflowId = realPayload.workflow_run?.id || realPayload.workflow_run?.run_id
401
+ || payload.workflowRun?.id || '';
402
+ const headCommit = realPayload.head_commit?.id || realPayload.after || payload.after
403
+ || realPayload.commits?.[0]?.id || '';
427
404
  const explicitId = payload.id || realPayload.id || payload.timestamp || '';
428
405
  return [event, keyRepo, action, commentId, issueId, prId, releaseId, workflowId, headCommit, explicitId].join('|');
429
406
  }
@@ -455,6 +432,7 @@ class Notifier extends koishi_1.Service {
455
432
  catch (error) {
456
433
  if (error?.code === 'SQLITE_CONSTRAINT')
457
434
  return false;
435
+ this.ctx.logger('githubsth').warn('Dedup write error:', error?.message || error);
458
436
  }
459
437
  return true;
460
438
  }
@@ -463,7 +441,9 @@ class Notifier extends koishi_1.Service {
463
441
  try {
464
442
  await this.ctx.database.remove('github_event_dedup', { createdAt: { $lt: cutoff } });
465
443
  }
466
- catch { }
444
+ catch (error) {
445
+ this.ctx.logger('githubsth').warn('Dedup cleanup error:', error);
446
+ }
467
447
  }
468
448
  async sendMessage(rule, outbound) {
469
449
  const bots = this.ctx.bots.filter((bot) => !rule.platform || bot.platform === rule.platform);
@@ -476,14 +456,19 @@ class Notifier extends koishi_1.Service {
476
456
  this.ctx.logger('notifier').info(`Sent message to ${rule.channelId} via ${bot.platform}:${bot.selfId}`);
477
457
  return;
478
458
  }
479
- catch {
459
+ catch (sendError) {
480
460
  if (outbound.isImage && this.config.renderFallback === 'text') {
481
461
  try {
482
462
  await this.sendWithRetry(bot, rule.channelId, outbound.text);
483
463
  this.ctx.logger('notifier').warn(`Image failed on ${bot.platform}:${bot.selfId}, fallback to text succeeded.`);
484
464
  return;
485
465
  }
486
- catch { }
466
+ catch (fallbackError) {
467
+ this.ctx.logger('notifier').error(`Image + text fallback both failed on ${bot.platform}:${bot.selfId}:`, fallbackError);
468
+ }
469
+ }
470
+ else {
471
+ this.ctx.logger('notifier').warn(`Send failed on ${bot.platform}:${bot.selfId}:`, sendError);
487
472
  }
488
473
  }
489
474
  }
@@ -500,56 +485,94 @@ class Notifier extends koishi_1.Service {
500
485
  }
501
486
  catch (error) {
502
487
  lastError = error;
503
- if (attempt >= retryCount)
504
- break;
505
- await this.sleep(baseDelay * Math.pow(2, attempt));
488
+ if (attempt < retryCount) {
489
+ const delay = baseDelay * Math.pow(2, attempt);
490
+ await new Promise((resolve) => setTimeout(resolve, delay));
491
+ }
506
492
  }
507
493
  }
508
494
  throw lastError;
509
495
  }
510
- sleep(ms) {
511
- return new Promise((resolve) => setTimeout(resolve, ms));
512
- }
513
496
  getPreviewPayload(event) {
514
- const payload = {
515
- action: 'created',
516
- repository: { full_name: 'acmuhan/JackalClientDocs', stargazers_count: 128, forks_count: 12, open_issues_count: 3 },
517
- sender: { login: 'vercel[bot]' },
518
- issue: {
519
- number: 29,
520
- title: 'Delete demobot',
521
- html_url: 'https://github.com/acmuhan/JackalClientDocs/issues/29',
522
- pull_request: { html_url: 'https://github.com/acmuhan/JackalClientDocs/pull/29' },
523
- },
524
- comment: { body: '[vc]: digest payload hidden' },
525
- pull_request: {
526
- number: 29,
527
- title: 'Delete demobot',
528
- state: 'open',
529
- html_url: 'https://github.com/acmuhan/JackalClientDocs/pull/29',
530
- },
531
- workflow_run: {
532
- name: 'Deploy',
533
- conclusion: 'success',
534
- head_branch: 'main',
535
- html_url: 'https://github.com/acmuhan/JackalClientDocs/actions/runs/1',
536
- },
537
- commits: [{ id: 'ea5eaddca38f25ce013ee50d70addb49c8d28844', message: 'feat: delete demobot', author: { name: 'MuHan' } }],
538
- ref: 'refs/heads/main',
539
- compare: 'https://github.com/acmuhan/JackalClientDocs/compare/old...new',
540
- pusher: { name: 'acmuhan' },
541
- release: { tag_name: 'v1.2.0', name: 'Spring Patch', html_url: 'https://github.com/acmuhan/JackalClientDocs/releases/tag/v1.2.0' },
542
- forkee: { full_name: 'acmuhan/JackalClientDocs-fork' },
543
- discussion: { number: 7, title: 'Roadmap', html_url: 'https://github.com/acmuhan/JackalClientDocs/discussions/7' },
544
- review: { state: 'approved', html_url: 'https://github.com/acmuhan/JackalClientDocs/pull/29#pullrequestreview-1' },
545
- };
546
- if (event === 'workflow_run')
547
- payload.action = 'completed';
548
- if (event === 'pull_request_review')
549
- payload.action = 'submitted';
550
- if (event === 'release')
551
- payload.action = 'published';
552
- return payload;
497
+ const mockRepo = 'owner/repo';
498
+ switch (event) {
499
+ case 'push':
500
+ return {
501
+ repository: { full_name: mockRepo, html_url: `https://github.com/${mockRepo}` },
502
+ sender: { login: 'octocat' },
503
+ ref: 'refs/heads/main',
504
+ commits: [
505
+ { id: 'abc123', message: 'feat: add new feature', author: { name: 'octocat' } },
506
+ ],
507
+ compare: `https://github.com/${mockRepo}/compare/old..new`,
508
+ pusher: { name: 'octocat' },
509
+ };
510
+ case 'issues':
511
+ return {
512
+ repository: { full_name: mockRepo, html_url: `https://github.com/${mockRepo}` },
513
+ sender: { login: 'octocat' },
514
+ action: 'opened',
515
+ issue: { number: 1, title: 'Example Issue', html_url: `https://github.com/${mockRepo}/issues/1`, user: { login: 'octocat' } },
516
+ };
517
+ case 'issue_comment':
518
+ return {
519
+ repository: { full_name: mockRepo, html_url: `https://github.com/${mockRepo}` },
520
+ sender: { login: 'octocat' },
521
+ action: 'created',
522
+ issue: { number: 1, title: 'Example Issue', html_url: `https://github.com/${mockRepo}/issues/1`, user: { login: 'octocat' } },
523
+ comment: { body: 'This is a sample comment', html_url: `https://github.com/${mockRepo}/issues/1#issuecomment-1` },
524
+ };
525
+ case 'pull_request':
526
+ return {
527
+ repository: { full_name: mockRepo, html_url: `https://github.com/${mockRepo}` },
528
+ sender: { login: 'octocat' },
529
+ action: 'opened',
530
+ pull_request: { number: 1, title: 'Example PR', html_url: `https://github.com/${mockRepo}/pull/1`, user: { login: 'octocat' } },
531
+ };
532
+ case 'pull_request_review':
533
+ return {
534
+ repository: { full_name: mockRepo, html_url: `https://github.com/${mockRepo}` },
535
+ sender: { login: 'octocat' },
536
+ action: 'submitted',
537
+ pull_request: { number: 1, title: 'Example PR', html_url: `https://github.com/${mockRepo}/pull/1`, user: { login: 'octocat' } },
538
+ review: { state: 'approved', html_url: `https://github.com/${mockRepo}/pull/1#pullrequestreview-1` },
539
+ };
540
+ case 'star':
541
+ return {
542
+ repository: { full_name: mockRepo, stargazers_count: 42, html_url: `https://github.com/${mockRepo}` },
543
+ sender: { login: 'octocat' },
544
+ action: 'created',
545
+ };
546
+ case 'fork':
547
+ return {
548
+ repository: { full_name: mockRepo, html_url: `https://github.com/${mockRepo}` },
549
+ sender: { login: 'octocat' },
550
+ forkee: { full_name: 'octocat/repo', html_url: `https://github.com/octocat/repo` },
551
+ };
552
+ case 'release':
553
+ return {
554
+ repository: { full_name: mockRepo, html_url: `https://github.com/${mockRepo}` },
555
+ sender: { login: 'octocat' },
556
+ action: 'published',
557
+ release: { tag_name: 'v1.0.0', name: 'Initial Release', html_url: `https://github.com/${mockRepo}/releases/v1.0.0` },
558
+ };
559
+ case 'discussion':
560
+ return {
561
+ repository: { full_name: mockRepo, html_url: `https://github.com/${mockRepo}` },
562
+ sender: { login: 'octocat' },
563
+ action: 'created',
564
+ discussion: { number: 1, title: 'Example Discussion', html_url: `https://github.com/${mockRepo}/discussions/1`, user: { login: 'octocat' } },
565
+ };
566
+ case 'workflow_run':
567
+ return {
568
+ repository: { full_name: mockRepo, html_url: `https://github.com/${mockRepo}` },
569
+ sender: { login: 'octocat' },
570
+ action: 'completed',
571
+ workflow_run: { name: 'CI', conclusion: 'success', head_branch: 'main', html_url: `https://github.com/${mockRepo}/actions/runs/1` },
572
+ };
573
+ default:
574
+ return null;
575
+ }
553
576
  }
554
577
  }
555
578
  exports.Notifier = Notifier;
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "koishi-plugin-githubsth",
3
- "version": "1.0.6",
4
- "description": "Github Subscriptions Notifications, push notifications for GitHub subscriptions For koishi",
3
+ "version": "1.1.0",
4
+ "description": "Koishi plugin for GitHub subscription notifications, trusted repositories, and rich rendering.",
5
5
  "main": "lib/index.js",
6
6
  "typings": "lib/index.d.ts",
7
7
  "files": [
@@ -34,8 +34,8 @@
34
34
  },
35
35
  "koishi": {
36
36
  "description": {
37
- "en": "Github Subscriptions Notifications, push notifications for GitHub subscriptions",
38
- "zh": "GitHub 订阅推送通知,依赖 GitHub 适配器"
37
+ "en": "GitHub subscription notifications with trusted repository controls and render customization.",
38
+ "zh": "支持可信仓库控制与渲染自定义的 GitHub 订阅通知插件。"
39
39
  },
40
40
  "service": {
41
41
  "required": [
@@ -47,5 +47,3 @@
47
47
  }
48
48
  }
49
49
  }
50
-
51
-