koishi-plugin-github-webhook-pusher 0.0.7 → 0.0.8
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/lib/commands/index.js +12 -0
- package/lib/commands/subscription.js +214 -0
- package/lib/commands/trust.js +119 -0
- package/lib/commands/utils.js +182 -0
- package/lib/config.js +30 -0
- package/lib/database.js +39 -0
- package/lib/index.js +53 -0
- package/lib/message.js +250 -0
- package/lib/parser.js +304 -0
- package/lib/pusher.js +128 -0
- package/lib/repository/delivery.js +63 -0
- package/lib/repository/index.js +22 -0
- package/lib/repository/subscription.js +187 -0
- package/lib/repository/trust.js +133 -0
- package/lib/signature.js +38 -0
- package/lib/types.js +40 -0
- package/lib/webhook.js +153 -0
- package/package.json +1 -1
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* 投递记录数据访问层
|
|
4
|
+
* 需求: 1.6
|
|
5
|
+
*/
|
|
6
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
7
|
+
exports.recordDelivery = recordDelivery;
|
|
8
|
+
exports.isDelivered = isDelivered;
|
|
9
|
+
exports.getDelivery = getDelivery;
|
|
10
|
+
exports.cleanupDeliveries = cleanupDeliveries;
|
|
11
|
+
/**
|
|
12
|
+
* 记录投递
|
|
13
|
+
* 需求 1.6: 记录 Delivery ID 用于去重
|
|
14
|
+
* @param ctx Koishi 上下文
|
|
15
|
+
* @param deliveryId GitHub Delivery ID
|
|
16
|
+
* @param repo 仓库名 (owner/repo)
|
|
17
|
+
* @param event 事件类型
|
|
18
|
+
* @returns 创建的投递记录
|
|
19
|
+
*/
|
|
20
|
+
async function recordDelivery(ctx, deliveryId, repo, event) {
|
|
21
|
+
const now = new Date();
|
|
22
|
+
await ctx.database.create('github_deliveries', {
|
|
23
|
+
deliveryId,
|
|
24
|
+
repo,
|
|
25
|
+
event,
|
|
26
|
+
receivedAt: now,
|
|
27
|
+
});
|
|
28
|
+
const [created] = await ctx.database.get('github_deliveries', { deliveryId });
|
|
29
|
+
return created;
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* 检查是否已投递
|
|
33
|
+
* 需求 1.6: 检查 Delivery ID 是否已处理过,用于去重
|
|
34
|
+
* @param ctx Koishi 上下文
|
|
35
|
+
* @param deliveryId GitHub Delivery ID
|
|
36
|
+
* @returns 是否已投递
|
|
37
|
+
*/
|
|
38
|
+
async function isDelivered(ctx, deliveryId) {
|
|
39
|
+
const deliveries = await ctx.database.get('github_deliveries', { deliveryId });
|
|
40
|
+
return deliveries.length > 0;
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* 获取投递记录
|
|
44
|
+
* @param ctx Koishi 上下文
|
|
45
|
+
* @param deliveryId GitHub Delivery ID
|
|
46
|
+
* @returns 投递记录,不存在返回 null
|
|
47
|
+
*/
|
|
48
|
+
async function getDelivery(ctx, deliveryId) {
|
|
49
|
+
const deliveries = await ctx.database.get('github_deliveries', { deliveryId });
|
|
50
|
+
return deliveries.length > 0 ? deliveries[0] : null;
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* 清理旧的投递记录
|
|
54
|
+
* @param ctx Koishi 上下文
|
|
55
|
+
* @param beforeDate 清理此日期之前的记录
|
|
56
|
+
* @returns 清理的记录数
|
|
57
|
+
*/
|
|
58
|
+
async function cleanupDeliveries(ctx, beforeDate) {
|
|
59
|
+
const result = await ctx.database.remove('github_deliveries', {
|
|
60
|
+
receivedAt: { $lt: beforeDate },
|
|
61
|
+
});
|
|
62
|
+
return result.removed ?? 0;
|
|
63
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* 数据访问层导出
|
|
4
|
+
*/
|
|
5
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
6
|
+
if (k2 === undefined) k2 = k;
|
|
7
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
8
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
9
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
10
|
+
}
|
|
11
|
+
Object.defineProperty(o, k2, desc);
|
|
12
|
+
}) : (function(o, m, k, k2) {
|
|
13
|
+
if (k2 === undefined) k2 = k;
|
|
14
|
+
o[k2] = m[k];
|
|
15
|
+
}));
|
|
16
|
+
var __exportStar = (this && this.__exportStar) || function(m, exports) {
|
|
17
|
+
for (var p in m) if (p !== "default" && !Object.prototype.hasOwnProperty.call(exports, p)) __createBinding(exports, m, p);
|
|
18
|
+
};
|
|
19
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
20
|
+
__exportStar(require("./trust"), exports);
|
|
21
|
+
__exportStar(require("./subscription"), exports);
|
|
22
|
+
__exportStar(require("./delivery"), exports);
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* 订阅数据访问层
|
|
4
|
+
* 需求: 3.1-3.7, 5.1, 5.2
|
|
5
|
+
*/
|
|
6
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
7
|
+
exports.createSubscription = createSubscription;
|
|
8
|
+
exports.removeSubscription = removeSubscription;
|
|
9
|
+
exports.listSubscriptions = listSubscriptions;
|
|
10
|
+
exports.getSubscription = getSubscription;
|
|
11
|
+
exports.updateEvents = updateEvents;
|
|
12
|
+
exports.queryTargets = queryTargets;
|
|
13
|
+
exports.getRepoSubscriptions = getRepoSubscriptions;
|
|
14
|
+
exports.enableSubscription = enableSubscription;
|
|
15
|
+
exports.disableSubscription = disableSubscription;
|
|
16
|
+
/**
|
|
17
|
+
* 创建订阅
|
|
18
|
+
* 需求 3.1: 为当前会话创建订阅记录
|
|
19
|
+
* @param ctx Koishi 上下文
|
|
20
|
+
* @param session 会话标识
|
|
21
|
+
* @param repo 仓库名 (owner/repo)
|
|
22
|
+
* @param defaultEvents 默认事件列表
|
|
23
|
+
* @returns 创建的订阅记录
|
|
24
|
+
*/
|
|
25
|
+
async function createSubscription(ctx, session, repo, defaultEvents) {
|
|
26
|
+
const now = new Date();
|
|
27
|
+
// 检查是否已存在
|
|
28
|
+
const existing = await ctx.database.get('github_subscriptions', {
|
|
29
|
+
platform: session.platform,
|
|
30
|
+
channelId: session.channelId,
|
|
31
|
+
repo,
|
|
32
|
+
});
|
|
33
|
+
if (existing.length > 0) {
|
|
34
|
+
return existing[0];
|
|
35
|
+
}
|
|
36
|
+
await ctx.database.create('github_subscriptions', {
|
|
37
|
+
platform: session.platform,
|
|
38
|
+
channelId: session.channelId,
|
|
39
|
+
guildId: session.guildId || '',
|
|
40
|
+
userId: session.userId || '',
|
|
41
|
+
repo,
|
|
42
|
+
events: defaultEvents,
|
|
43
|
+
enabled: true,
|
|
44
|
+
createdAt: now,
|
|
45
|
+
updatedAt: now,
|
|
46
|
+
});
|
|
47
|
+
const [created] = await ctx.database.get('github_subscriptions', {
|
|
48
|
+
platform: session.platform,
|
|
49
|
+
channelId: session.channelId,
|
|
50
|
+
repo,
|
|
51
|
+
});
|
|
52
|
+
return created;
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* 移除订阅
|
|
56
|
+
* 需求 3.3: 移除当前会话对该仓库的订阅
|
|
57
|
+
* @param ctx Koishi 上下文
|
|
58
|
+
* @param session 会话标识
|
|
59
|
+
* @param repo 仓库名 (owner/repo)
|
|
60
|
+
* @returns 是否成功移除
|
|
61
|
+
*/
|
|
62
|
+
async function removeSubscription(ctx, session, repo) {
|
|
63
|
+
const result = await ctx.database.remove('github_subscriptions', {
|
|
64
|
+
platform: session.platform,
|
|
65
|
+
channelId: session.channelId,
|
|
66
|
+
repo,
|
|
67
|
+
});
|
|
68
|
+
return (result.removed ?? 0) > 0;
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* 列出会话的所有订阅
|
|
72
|
+
* 需求 3.4: 显示当前会话的所有订阅及其事件设置
|
|
73
|
+
* @param ctx Koishi 上下文
|
|
74
|
+
* @param session 会话标识
|
|
75
|
+
* @returns 订阅列表
|
|
76
|
+
*/
|
|
77
|
+
async function listSubscriptions(ctx, session) {
|
|
78
|
+
return ctx.database.get('github_subscriptions', {
|
|
79
|
+
platform: session.platform,
|
|
80
|
+
channelId: session.channelId,
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* 获取单个订阅
|
|
85
|
+
* 需求 3.5: 显示该仓库订阅的事件类型列表
|
|
86
|
+
* @param ctx Koishi 上下文
|
|
87
|
+
* @param session 会话标识
|
|
88
|
+
* @param repo 仓库名 (owner/repo)
|
|
89
|
+
* @returns 订阅记录,不存在返回 null
|
|
90
|
+
*/
|
|
91
|
+
async function getSubscription(ctx, session, repo) {
|
|
92
|
+
const subs = await ctx.database.get('github_subscriptions', {
|
|
93
|
+
platform: session.platform,
|
|
94
|
+
channelId: session.channelId,
|
|
95
|
+
repo,
|
|
96
|
+
});
|
|
97
|
+
return subs.length > 0 ? subs[0] : null;
|
|
98
|
+
}
|
|
99
|
+
/**
|
|
100
|
+
* 更新订阅的事件类型
|
|
101
|
+
* 需求 3.6: 根据 +/- 前缀启用或禁用对应事件类型
|
|
102
|
+
* @param ctx Koishi 上下文
|
|
103
|
+
* @param session 会话标识
|
|
104
|
+
* @param repo 仓库名 (owner/repo)
|
|
105
|
+
* @param events 新的事件列表
|
|
106
|
+
* @returns 是否成功更新
|
|
107
|
+
*/
|
|
108
|
+
async function updateEvents(ctx, session, repo, events) {
|
|
109
|
+
const result = await ctx.database.set('github_subscriptions', {
|
|
110
|
+
platform: session.platform,
|
|
111
|
+
channelId: session.channelId,
|
|
112
|
+
repo,
|
|
113
|
+
}, {
|
|
114
|
+
events,
|
|
115
|
+
updatedAt: new Date(),
|
|
116
|
+
});
|
|
117
|
+
return (result.modified ?? 0) > 0;
|
|
118
|
+
}
|
|
119
|
+
/**
|
|
120
|
+
* 查询订阅目标
|
|
121
|
+
* 需求 5.1, 5.2: 查询所有订阅该仓库的目标会话,并根据事件类型过滤
|
|
122
|
+
* @param ctx Koishi 上下文
|
|
123
|
+
* @param repo 仓库名 (owner/repo)
|
|
124
|
+
* @param eventType 事件类型
|
|
125
|
+
* @returns 推送目标列表
|
|
126
|
+
*/
|
|
127
|
+
async function queryTargets(ctx, repo, eventType) {
|
|
128
|
+
// 获取所有订阅该仓库且已启用的订阅
|
|
129
|
+
const subscriptions = await ctx.database.get('github_subscriptions', {
|
|
130
|
+
repo,
|
|
131
|
+
enabled: true,
|
|
132
|
+
});
|
|
133
|
+
// 过滤出订阅了该事件类型的目标
|
|
134
|
+
return subscriptions
|
|
135
|
+
.filter(sub => sub.events.includes(eventType))
|
|
136
|
+
.map(sub => ({
|
|
137
|
+
platform: sub.platform,
|
|
138
|
+
channelId: sub.channelId,
|
|
139
|
+
guildId: sub.guildId || undefined,
|
|
140
|
+
userId: sub.userId || undefined,
|
|
141
|
+
}));
|
|
142
|
+
}
|
|
143
|
+
/**
|
|
144
|
+
* 获取仓库的所有订阅
|
|
145
|
+
* @param ctx Koishi 上下文
|
|
146
|
+
* @param repo 仓库名 (owner/repo)
|
|
147
|
+
* @returns 订阅列表
|
|
148
|
+
*/
|
|
149
|
+
async function getRepoSubscriptions(ctx, repo) {
|
|
150
|
+
return ctx.database.get('github_subscriptions', { repo });
|
|
151
|
+
}
|
|
152
|
+
/**
|
|
153
|
+
* 启用订阅
|
|
154
|
+
* @param ctx Koishi 上下文
|
|
155
|
+
* @param session 会话标识
|
|
156
|
+
* @param repo 仓库名 (owner/repo)
|
|
157
|
+
* @returns 是否成功启用
|
|
158
|
+
*/
|
|
159
|
+
async function enableSubscription(ctx, session, repo) {
|
|
160
|
+
const result = await ctx.database.set('github_subscriptions', {
|
|
161
|
+
platform: session.platform,
|
|
162
|
+
channelId: session.channelId,
|
|
163
|
+
repo,
|
|
164
|
+
}, {
|
|
165
|
+
enabled: true,
|
|
166
|
+
updatedAt: new Date(),
|
|
167
|
+
});
|
|
168
|
+
return (result.modified ?? 0) > 0;
|
|
169
|
+
}
|
|
170
|
+
/**
|
|
171
|
+
* 禁用订阅
|
|
172
|
+
* @param ctx Koishi 上下文
|
|
173
|
+
* @param session 会话标识
|
|
174
|
+
* @param repo 仓库名 (owner/repo)
|
|
175
|
+
* @returns 是否成功禁用
|
|
176
|
+
*/
|
|
177
|
+
async function disableSubscription(ctx, session, repo) {
|
|
178
|
+
const result = await ctx.database.set('github_subscriptions', {
|
|
179
|
+
platform: session.platform,
|
|
180
|
+
channelId: session.channelId,
|
|
181
|
+
repo,
|
|
182
|
+
}, {
|
|
183
|
+
enabled: false,
|
|
184
|
+
updatedAt: new Date(),
|
|
185
|
+
});
|
|
186
|
+
return (result.modified ?? 0) > 0;
|
|
187
|
+
}
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* 信任仓库数据访问层
|
|
4
|
+
* 需求: 2.1-2.7
|
|
5
|
+
*/
|
|
6
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
7
|
+
exports.isValidRepoFormat = isValidRepoFormat;
|
|
8
|
+
exports.addTrustedRepo = addTrustedRepo;
|
|
9
|
+
exports.removeTrustedRepo = removeTrustedRepo;
|
|
10
|
+
exports.listTrustedRepos = listTrustedRepos;
|
|
11
|
+
exports.isTrusted = isTrusted;
|
|
12
|
+
exports.isInTrustList = isInTrustList;
|
|
13
|
+
exports.enableRepo = enableRepo;
|
|
14
|
+
exports.disableRepo = disableRepo;
|
|
15
|
+
exports.getTrustedRepo = getTrustedRepo;
|
|
16
|
+
/** 仓库名格式验证正则表达式 */
|
|
17
|
+
const REPO_NAME_REGEX = /^[a-zA-Z0-9_.-]+\/[a-zA-Z0-9_.-]+$/;
|
|
18
|
+
/**
|
|
19
|
+
* 验证仓库名格式是否符合 owner/repo 格式
|
|
20
|
+
* @param repo 仓库名
|
|
21
|
+
* @returns 是否有效
|
|
22
|
+
*/
|
|
23
|
+
function isValidRepoFormat(repo) {
|
|
24
|
+
if (!repo || typeof repo !== 'string') {
|
|
25
|
+
return false;
|
|
26
|
+
}
|
|
27
|
+
return REPO_NAME_REGEX.test(repo);
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* 添加信任仓库
|
|
31
|
+
* 需求 2.1: 将仓库添加到信任列表并持久化到数据库
|
|
32
|
+
* @param ctx Koishi 上下文
|
|
33
|
+
* @param repo 仓库名 (owner/repo)
|
|
34
|
+
* @returns 添加的仓库记录,如果格式错误返回 null
|
|
35
|
+
*/
|
|
36
|
+
async function addTrustedRepo(ctx, repo) {
|
|
37
|
+
if (!isValidRepoFormat(repo)) {
|
|
38
|
+
return null;
|
|
39
|
+
}
|
|
40
|
+
const now = new Date();
|
|
41
|
+
// 检查是否已存在
|
|
42
|
+
const existing = await ctx.database.get('github_trusted_repos', { repo });
|
|
43
|
+
if (existing.length > 0) {
|
|
44
|
+
return existing[0];
|
|
45
|
+
}
|
|
46
|
+
await ctx.database.create('github_trusted_repos', {
|
|
47
|
+
repo,
|
|
48
|
+
enabled: true,
|
|
49
|
+
createdAt: now,
|
|
50
|
+
updatedAt: now,
|
|
51
|
+
});
|
|
52
|
+
const [created] = await ctx.database.get('github_trusted_repos', { repo });
|
|
53
|
+
return created;
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* 移除信任仓库
|
|
57
|
+
* 需求 2.2: 从信任列表中移除该仓库
|
|
58
|
+
* @param ctx Koishi 上下文
|
|
59
|
+
* @param repo 仓库名 (owner/repo)
|
|
60
|
+
* @returns 是否成功移除
|
|
61
|
+
*/
|
|
62
|
+
async function removeTrustedRepo(ctx, repo) {
|
|
63
|
+
const result = await ctx.database.remove('github_trusted_repos', { repo });
|
|
64
|
+
return (result.removed ?? 0) > 0;
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* 列出所有信任仓库
|
|
68
|
+
* 需求 2.3: 显示所有信任仓库及其启用状态
|
|
69
|
+
* @param ctx Koishi 上下文
|
|
70
|
+
* @returns 信任仓库列表
|
|
71
|
+
*/
|
|
72
|
+
async function listTrustedRepos(ctx) {
|
|
73
|
+
return ctx.database.get('github_trusted_repos', {});
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* 检查仓库是否在信任列表中且已启用
|
|
77
|
+
* 需求 2.6: 验证仓库是否可以处理事件
|
|
78
|
+
* @param ctx Koishi 上下文
|
|
79
|
+
* @param repo 仓库名 (owner/repo)
|
|
80
|
+
* @returns 是否信任且启用
|
|
81
|
+
*/
|
|
82
|
+
async function isTrusted(ctx, repo) {
|
|
83
|
+
const repos = await ctx.database.get('github_trusted_repos', { repo, enabled: true });
|
|
84
|
+
return repos.length > 0;
|
|
85
|
+
}
|
|
86
|
+
/**
|
|
87
|
+
* 检查仓库是否在信任列表中(不考虑启用状态)
|
|
88
|
+
* @param ctx Koishi 上下文
|
|
89
|
+
* @param repo 仓库名 (owner/repo)
|
|
90
|
+
* @returns 是否在信任列表中
|
|
91
|
+
*/
|
|
92
|
+
async function isInTrustList(ctx, repo) {
|
|
93
|
+
const repos = await ctx.database.get('github_trusted_repos', { repo });
|
|
94
|
+
return repos.length > 0;
|
|
95
|
+
}
|
|
96
|
+
/**
|
|
97
|
+
* 启用信任仓库
|
|
98
|
+
* 需求 2.4: 启用该仓库的事件处理
|
|
99
|
+
* @param ctx Koishi 上下文
|
|
100
|
+
* @param repo 仓库名 (owner/repo)
|
|
101
|
+
* @returns 是否成功启用
|
|
102
|
+
*/
|
|
103
|
+
async function enableRepo(ctx, repo) {
|
|
104
|
+
const result = await ctx.database.set('github_trusted_repos', { repo }, {
|
|
105
|
+
enabled: true,
|
|
106
|
+
updatedAt: new Date(),
|
|
107
|
+
});
|
|
108
|
+
return (result.modified ?? 0) > 0;
|
|
109
|
+
}
|
|
110
|
+
/**
|
|
111
|
+
* 禁用信任仓库
|
|
112
|
+
* 需求 2.5: 禁用该仓库的事件处理
|
|
113
|
+
* @param ctx Koishi 上下文
|
|
114
|
+
* @param repo 仓库名 (owner/repo)
|
|
115
|
+
* @returns 是否成功禁用
|
|
116
|
+
*/
|
|
117
|
+
async function disableRepo(ctx, repo) {
|
|
118
|
+
const result = await ctx.database.set('github_trusted_repos', { repo }, {
|
|
119
|
+
enabled: false,
|
|
120
|
+
updatedAt: new Date(),
|
|
121
|
+
});
|
|
122
|
+
return (result.modified ?? 0) > 0;
|
|
123
|
+
}
|
|
124
|
+
/**
|
|
125
|
+
* 获取单个信任仓库信息
|
|
126
|
+
* @param ctx Koishi 上下文
|
|
127
|
+
* @param repo 仓库名 (owner/repo)
|
|
128
|
+
* @returns 仓库信息,不存在返回 null
|
|
129
|
+
*/
|
|
130
|
+
async function getTrustedRepo(ctx, repo) {
|
|
131
|
+
const repos = await ctx.database.get('github_trusted_repos', { repo });
|
|
132
|
+
return repos.length > 0 ? repos[0] : null;
|
|
133
|
+
}
|
package/lib/signature.js
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.createSignature = createSignature;
|
|
4
|
+
exports.verifySignature = verifySignature;
|
|
5
|
+
const crypto_1 = require("crypto");
|
|
6
|
+
/**
|
|
7
|
+
* 创建 GitHub Webhook 签名
|
|
8
|
+
* @param payload 原始请求体
|
|
9
|
+
* @param secret Webhook Secret
|
|
10
|
+
* @returns 签名字符串,格式为 sha256=<hex_digest>
|
|
11
|
+
*/
|
|
12
|
+
function createSignature(payload, secret) {
|
|
13
|
+
const hmac = (0, crypto_1.createHmac)('sha256', secret);
|
|
14
|
+
hmac.update(payload, 'utf8');
|
|
15
|
+
return `sha256=${hmac.digest('hex')}`;
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* 验证 GitHub Webhook 签名
|
|
19
|
+
* @param payload 原始请求体
|
|
20
|
+
* @param signature X-Hub-Signature-256 头的值
|
|
21
|
+
* @param secret 配置的 Webhook Secret
|
|
22
|
+
* @returns 签名是否有效
|
|
23
|
+
*/
|
|
24
|
+
function verifySignature(payload, signature, secret) {
|
|
25
|
+
// 检查签名格式
|
|
26
|
+
if (!signature || !signature.startsWith('sha256=')) {
|
|
27
|
+
return false;
|
|
28
|
+
}
|
|
29
|
+
const expected = createSignature(payload, secret);
|
|
30
|
+
// 使用常量时间比较防止时序攻击
|
|
31
|
+
const sigBuffer = Buffer.from(signature);
|
|
32
|
+
const expectedBuffer = Buffer.from(expected);
|
|
33
|
+
// 长度不同时也需要进行比较以保持常量时间
|
|
34
|
+
if (sigBuffer.length !== expectedBuffer.length) {
|
|
35
|
+
return false;
|
|
36
|
+
}
|
|
37
|
+
return (0, crypto_1.timingSafeEqual)(sigBuffer, expectedBuffer);
|
|
38
|
+
}
|
package/lib/types.js
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* 事件类型和接口定义
|
|
4
|
+
* 需求: 4.1-4.7
|
|
5
|
+
*/
|
|
6
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
7
|
+
exports.EVENT_DISPLAY_MAP = void 0;
|
|
8
|
+
exports.getDisplayType = getDisplayType;
|
|
9
|
+
exports.getEventEmoji = getEventEmoji;
|
|
10
|
+
/** 事件类型到显示名称和 emoji 的映射 */
|
|
11
|
+
exports.EVENT_DISPLAY_MAP = {
|
|
12
|
+
issues: { name: 'Issue', emoji: '📌' },
|
|
13
|
+
issue_comment: { name: 'Issue Comment', emoji: '💬' },
|
|
14
|
+
release: { name: 'Release', emoji: '🚀' },
|
|
15
|
+
push: { name: 'Commit', emoji: '⬆️' },
|
|
16
|
+
pull_request: { name: 'PR', emoji: '🔀' },
|
|
17
|
+
pull_request_review: { name: 'PR Review', emoji: '🧪' },
|
|
18
|
+
pull_request_review_comment: { name: 'PR Review Comment', emoji: '💬' },
|
|
19
|
+
star: { name: 'Star', emoji: '⭐' },
|
|
20
|
+
fork: { name: 'Fork', emoji: '🍴' },
|
|
21
|
+
create: { name: 'Create', emoji: '✨' },
|
|
22
|
+
delete: { name: 'Delete', emoji: '🗑️' },
|
|
23
|
+
workflow_run: { name: 'Workflow', emoji: '🧩' },
|
|
24
|
+
};
|
|
25
|
+
/**
|
|
26
|
+
* 获取事件类型的显示名称
|
|
27
|
+
* @param type 事件类型
|
|
28
|
+
* @returns 显示名称
|
|
29
|
+
*/
|
|
30
|
+
function getDisplayType(type) {
|
|
31
|
+
return exports.EVENT_DISPLAY_MAP[type].name;
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* 获取事件类型的 emoji
|
|
35
|
+
* @param type 事件类型
|
|
36
|
+
* @returns emoji 字符
|
|
37
|
+
*/
|
|
38
|
+
function getEventEmoji(type) {
|
|
39
|
+
return exports.EVENT_DISPLAY_MAP[type].emoji;
|
|
40
|
+
}
|
package/lib/webhook.js
ADDED
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Webhook 处理器
|
|
4
|
+
* 需求: 1.1-1.6, 2.6, 9.1-9.6
|
|
5
|
+
*/
|
|
6
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
7
|
+
exports.registerWebhook = registerWebhook;
|
|
8
|
+
const koishi_1 = require("koishi");
|
|
9
|
+
const signature_1 = require("./signature");
|
|
10
|
+
const parser_1 = require("./parser");
|
|
11
|
+
const pusher_1 = require("./pusher");
|
|
12
|
+
const repository_1 = require("./repository");
|
|
13
|
+
const logger = new koishi_1.Logger('github-webhook');
|
|
14
|
+
/**
|
|
15
|
+
* 注册 Webhook 处理器
|
|
16
|
+
* 需求 1.1: 在配置的路径上监听 HTTP POST 请求
|
|
17
|
+
* @param ctx Koishi 上下文
|
|
18
|
+
* @param config 插件配置
|
|
19
|
+
*/
|
|
20
|
+
function registerWebhook(ctx, config) {
|
|
21
|
+
const path = config.path || '/github/webhook';
|
|
22
|
+
logger.info(`注册 Webhook 处理器: ${path}`);
|
|
23
|
+
// 使用 ctx.server.post 注册 HTTP POST 路由
|
|
24
|
+
ctx.server.post(path, async (koaCtx) => {
|
|
25
|
+
const startTime = Date.now();
|
|
26
|
+
try {
|
|
27
|
+
// 获取请求信息
|
|
28
|
+
const signature = koaCtx.get('X-Hub-Signature-256');
|
|
29
|
+
const eventName = koaCtx.get('X-GitHub-Event');
|
|
30
|
+
const deliveryId = koaCtx.get('X-GitHub-Delivery');
|
|
31
|
+
// 获取原始请求体
|
|
32
|
+
const rawBody = await getRawBody(koaCtx);
|
|
33
|
+
// 需求 9.1: 记录请求来源、事件类型
|
|
34
|
+
if (config.debug) {
|
|
35
|
+
logger.debug(`收到 Webhook 请求: event=${eventName}, delivery=${deliveryId}`);
|
|
36
|
+
}
|
|
37
|
+
// 需求 1.2, 1.3, 1.4: 签名验证
|
|
38
|
+
if (config.secret) {
|
|
39
|
+
if (!signature) {
|
|
40
|
+
// 需求 1.4: 缺少签名头
|
|
41
|
+
logger.warn('请求缺少 X-Hub-Signature-256 头');
|
|
42
|
+
koaCtx.status = 401;
|
|
43
|
+
koaCtx.body = { error: 'Missing signature' };
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
if (!(0, signature_1.verifySignature)(rawBody, signature, config.secret)) {
|
|
47
|
+
// 需求 1.3, 9.2: 签名验证失败
|
|
48
|
+
logger.warn('签名验证失败');
|
|
49
|
+
koaCtx.status = 401;
|
|
50
|
+
koaCtx.body = {
|
|
51
|
+
error: 'Invalid signature. Ensure Content-Type is application/json, and the signed payload matches the raw request body. 签名无效:请确认 Content-Type 为 application/json,且签名内容与实际请求体完全一致。'
|
|
52
|
+
};
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
// 需求 1.5: 签名验证成功,继续处理
|
|
57
|
+
// 解析 JSON 负载
|
|
58
|
+
let payload;
|
|
59
|
+
try {
|
|
60
|
+
payload = JSON.parse(rawBody);
|
|
61
|
+
}
|
|
62
|
+
catch (e) {
|
|
63
|
+
logger.warn('无法解析 JSON 负载');
|
|
64
|
+
koaCtx.status = 400;
|
|
65
|
+
koaCtx.body = { error: 'Invalid JSON payload' };
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
// 获取仓库名
|
|
69
|
+
const repo = payload.repository?.full_name;
|
|
70
|
+
if (!repo) {
|
|
71
|
+
logger.warn('负载中缺少仓库信息');
|
|
72
|
+
koaCtx.status = 400;
|
|
73
|
+
koaCtx.body = { error: 'Missing repository information' };
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
// 需求 9.1: 记录仓库名
|
|
77
|
+
logger.info(`收到 Webhook: [${repo}] ${eventName} (${deliveryId})`);
|
|
78
|
+
// 需求 1.6: 去重检查
|
|
79
|
+
if (deliveryId) {
|
|
80
|
+
const isDuplicate = await (0, repository_1.isDelivered)(ctx, deliveryId);
|
|
81
|
+
if (isDuplicate) {
|
|
82
|
+
logger.info(`跳过重复请求: ${deliveryId}`);
|
|
83
|
+
koaCtx.status = 200;
|
|
84
|
+
koaCtx.body = { status: 'duplicate' };
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
// 需求 2.6: 信任仓库验证
|
|
89
|
+
if (!config.allowUntrusted) {
|
|
90
|
+
const trusted = await (0, repository_1.isTrusted)(ctx, repo);
|
|
91
|
+
if (!trusted) {
|
|
92
|
+
// 需求 9.3: 记录非信任仓库事件
|
|
93
|
+
logger.info(`忽略非信任仓库事件: ${repo}`);
|
|
94
|
+
koaCtx.status = 200;
|
|
95
|
+
koaCtx.body = { status: 'ignored', reason: 'untrusted' };
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
// 解析事件
|
|
100
|
+
const event = (0, parser_1.parseEvent)(eventName, payload);
|
|
101
|
+
if (!event) {
|
|
102
|
+
logger.info(`不支持的事件类型: ${eventName}`);
|
|
103
|
+
koaCtx.status = 200;
|
|
104
|
+
koaCtx.body = { status: 'ignored', reason: 'unsupported' };
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
// 需求 1.6: 记录投递(在处理前记录,防止重复处理)
|
|
108
|
+
if (deliveryId) {
|
|
109
|
+
await (0, repository_1.recordDelivery)(ctx, deliveryId, repo, eventName);
|
|
110
|
+
}
|
|
111
|
+
// 推送消息
|
|
112
|
+
const result = await (0, pusher_1.pushEvent)(ctx, event, config.concurrency);
|
|
113
|
+
// 需求 9.4: 记录推送结果
|
|
114
|
+
const elapsed = Date.now() - startTime;
|
|
115
|
+
logger.info(`处理完成: 推送 ${result.pushed} 成功, ${result.failed} 失败 (${elapsed}ms)`);
|
|
116
|
+
// 需求 1.5: 返回成功响应
|
|
117
|
+
koaCtx.status = 200;
|
|
118
|
+
koaCtx.body = { status: 'ok', pushed: result.pushed };
|
|
119
|
+
}
|
|
120
|
+
catch (error) {
|
|
121
|
+
// 需求 9.5: 记录错误
|
|
122
|
+
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
123
|
+
logger.error(`处理 Webhook 时发生错误: ${errorMessage}`);
|
|
124
|
+
if (config.debug && error instanceof Error) {
|
|
125
|
+
logger.error(error.stack || '');
|
|
126
|
+
}
|
|
127
|
+
koaCtx.status = 500;
|
|
128
|
+
koaCtx.body = { status: 'error', error: 'Internal server error' };
|
|
129
|
+
}
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
/**
|
|
133
|
+
* 获取原始请求体
|
|
134
|
+
* @param koaCtx Koa 上下文
|
|
135
|
+
* @returns 原始请求体字符串
|
|
136
|
+
*/
|
|
137
|
+
async function getRawBody(koaCtx) {
|
|
138
|
+
// 如果已经有解析好的 body,尝试重新序列化
|
|
139
|
+
if (koaCtx.request.body) {
|
|
140
|
+
return JSON.stringify(koaCtx.request.body);
|
|
141
|
+
}
|
|
142
|
+
// 否则从流中读取
|
|
143
|
+
return new Promise((resolve, reject) => {
|
|
144
|
+
let data = '';
|
|
145
|
+
koaCtx.req.on('data', (chunk) => {
|
|
146
|
+
data += chunk.toString();
|
|
147
|
+
});
|
|
148
|
+
koaCtx.req.on('end', () => {
|
|
149
|
+
resolve(data);
|
|
150
|
+
});
|
|
151
|
+
koaCtx.req.on('error', reject);
|
|
152
|
+
});
|
|
153
|
+
}
|