@zhin.js/adapter-github 0.1.35 → 0.1.37
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/adapter.d.ts +53 -9
- package/lib/adapter.d.ts.map +1 -1
- package/lib/adapter.js +254 -189
- package/lib/adapter.js.map +1 -1
- package/lib/bot.d.ts +5 -2
- package/lib/bot.d.ts.map +1 -1
- package/lib/bot.js +19 -26
- package/lib/bot.js.map +1 -1
- package/lib/{api.d.ts → gh-client.d.ts} +113 -42
- package/lib/gh-client.d.ts.map +1 -0
- package/lib/gh-client.js +555 -0
- package/lib/gh-client.js.map +1 -0
- package/lib/index.d.ts +4 -3
- package/lib/index.d.ts.map +1 -1
- package/lib/index.js +212 -126
- package/lib/index.js.map +1 -1
- package/lib/types.d.ts +23 -29
- package/lib/types.d.ts.map +1 -1
- package/lib/types.js +2 -2
- package/lib/types.js.map +1 -1
- package/package.json +5 -5
- package/plugin.yml +1 -1
- package/skills/github/SKILL.md +17 -8
- package/src/adapter.ts +266 -188
- package/src/bot.ts +19 -25
- package/src/gh-client.ts +640 -0
- package/src/index.ts +219 -123
- package/src/types.ts +29 -34
- package/lib/api.d.ts.map +0 -1
- package/lib/api.js +0 -300
- package/lib/api.js.map +0 -1
- package/src/api.ts +0 -353
package/lib/adapter.d.ts
CHANGED
|
@@ -1,19 +1,63 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* GitHub 适配器(基于 gh CLI + App 认证 + Webhook/轮询混合)
|
|
3
|
+
*/
|
|
1
4
|
import { Adapter, Plugin } from 'zhin.js';
|
|
2
5
|
import { GitHubBot } from './bot.js';
|
|
6
|
+
import type { Router } from '@zhin.js/http';
|
|
3
7
|
import type { GitHubBotConfig, GenericWebhookPayload } from './types.js';
|
|
4
|
-
import {
|
|
8
|
+
import type { GhClient } from './gh-client.js';
|
|
5
9
|
export declare class GitHubAdapter extends Adapter<GitHubBot> {
|
|
6
|
-
|
|
10
|
+
/** 轮询定时器 */
|
|
11
|
+
private _pollTimer;
|
|
12
|
+
/** 每个 repo 的 ETag 缓存 */
|
|
13
|
+
private _etags;
|
|
14
|
+
/** 每个 repo 最后处理的事件 ID(防重复) */
|
|
15
|
+
private _lastEventIds;
|
|
16
|
+
/** Webhook 是否已激活 */
|
|
17
|
+
private _webhookActive;
|
|
7
18
|
constructor(plugin: Plugin);
|
|
8
19
|
createBot(config: GitHubBotConfig): GitHubBot;
|
|
9
20
|
start(): Promise<void>;
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
21
|
+
stop(): Promise<void>;
|
|
22
|
+
/** 获取第一个可用 bot 的 GhClient (工具用) */
|
|
23
|
+
getAPI(): GhClient | null;
|
|
24
|
+
/** 获取指定用户绑定的 GhClient;未绑定则返回 null */
|
|
25
|
+
getUserAPI(platform: string, platformUid: string): Promise<GhClient | null>;
|
|
26
|
+
/** 获取用户 API,若未绑定则降级为 bot 默认的 API */
|
|
27
|
+
getUserOrDefaultAPI(platform?: string, platformUid?: string): Promise<GhClient | null>;
|
|
28
|
+
/** 获取第一个 bot 的 client_id(App 认证时从 /app 自动获取) */
|
|
29
|
+
getClientId(): string | null;
|
|
30
|
+
/** 获取第一个 bot 的 host 配置 */
|
|
31
|
+
getHost(): string | undefined;
|
|
32
|
+
/** 获取 App slug(用于生成安装链接) */
|
|
33
|
+
getAppSlug(): string | null;
|
|
34
|
+
/** 获取所有已发现的安装信息 */
|
|
35
|
+
getInstallations(): {
|
|
36
|
+
id: number;
|
|
37
|
+
account: {
|
|
38
|
+
login: string;
|
|
39
|
+
type: string;
|
|
40
|
+
};
|
|
41
|
+
target_type: string;
|
|
42
|
+
}[];
|
|
43
|
+
/** 第一个 bot 是否配置了 Webhook */
|
|
44
|
+
get hasWebhookConfig(): boolean;
|
|
45
|
+
/** Webhook 是否已激活 */
|
|
46
|
+
get webhookActive(): boolean;
|
|
47
|
+
/** 在 router 上挂载 Webhook 路由(生产环境推荐) */
|
|
48
|
+
setupWebhook(router: Router): void;
|
|
49
|
+
/** 处理 Webhook 推送的事件 */
|
|
50
|
+
handleWebhookPayload(event: string, payload: any): Promise<void>;
|
|
51
|
+
/** 启动事件轮询 */
|
|
52
|
+
startPolling(): void;
|
|
53
|
+
/** 停止事件轮询 */
|
|
54
|
+
stopPolling(): void;
|
|
55
|
+
/** 轮询所有已订阅仓库的事件 */
|
|
56
|
+
private pollAllRepos;
|
|
57
|
+
/** 轮询单个仓库的事件 */
|
|
58
|
+
private pollRepoEvents;
|
|
59
|
+
/** Events API type → webhook event name */
|
|
60
|
+
private mapEventType;
|
|
17
61
|
dispatchNotification(eventName: string, payload: GenericWebhookPayload): Promise<void>;
|
|
18
62
|
}
|
|
19
63
|
//# sourceMappingURL=adapter.d.ts.map
|
package/lib/adapter.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"adapter.d.ts","sourceRoot":"","sources":["../src/adapter.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"adapter.d.ts","sourceRoot":"","sources":["../src/adapter.ts"],"names":[],"mappings":"AAAA;;GAEG;AACH,OAAO,EACL,OAAO,EACP,MAAM,EAEP,MAAM,SAAS,CAAC;AAEjB,OAAO,EAAE,SAAS,EAAE,MAAM,UAAU,CAAC;AACrC,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,eAAe,CAAC;AAC5C,OAAO,KAAK,EAAE,eAAe,EAAa,qBAAqB,EAAgB,MAAM,YAAY,CAAC;AAClG,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,gBAAgB,CAAC;AA+D/C,qBAAa,aAAc,SAAQ,OAAO,CAAC,SAAS,CAAC;IACnD,YAAY;IACZ,OAAO,CAAC,UAAU,CAA+C;IACjE,wBAAwB;IACxB,OAAO,CAAC,MAAM,CAA6B;IAC3C,8BAA8B;IAC9B,OAAO,CAAC,aAAa,CAA6B;IAClD,oBAAoB;IACpB,OAAO,CAAC,cAAc,CAAS;gBAEnB,MAAM,EAAE,MAAM;IAI1B,SAAS,CAAC,MAAM,EAAE,eAAe,GAAG,SAAS;IAIvC,KAAK,IAAI,OAAO,CAAC,IAAI,CAAC;IAItB,IAAI,IAAI,OAAO,CAAC,IAAI,CAAC;IAK3B,mCAAmC;IACnC,MAAM,IAAI,QAAQ,GAAG,IAAI;IAKzB,qCAAqC;IAC/B,UAAU,CAAC,QAAQ,EAAE,MAAM,EAAE,WAAW,EAAE,MAAM,GAAG,OAAO,CAAC,QAAQ,GAAG,IAAI,CAAC;IAWjF,oCAAoC;IAC9B,mBAAmB,CAAC,QAAQ,CAAC,EAAE,MAAM,EAAE,WAAW,CAAC,EAAE,MAAM,GAAG,OAAO,CAAC,QAAQ,GAAG,IAAI,CAAC;IAQ5F,gDAAgD;IAChD,WAAW,IAAI,MAAM,GAAG,IAAI;IAK5B,0BAA0B;IAC1B,OAAO,IAAI,MAAM,GAAG,SAAS;IAK7B,4BAA4B;IAC5B,UAAU,IAAI,MAAM,GAAG,IAAI;IAK3B,mBAAmB;IACnB,gBAAgB;;;;;;;;IAKhB,4BAA4B;IAC5B,IAAI,gBAAgB,IAAI,OAAO,CAG9B;IAED,oBAAoB;IACpB,IAAI,aAAa,IAAI,OAAO,CAE3B;IAID,sCAAsC;IACtC,YAAY,CAAC,MAAM,EAAE,MAAM,GAAG,IAAI;IA4ClC,uBAAuB;IACjB,oBAAoB,CAAC,KAAK,EAAE,MAAM,EAAE,OAAO,EAAE,GAAG,GAAG,OAAO,CAAC,IAAI,CAAC;IA4DtE,aAAa;IACb,YAAY,IAAI,IAAI;IAcpB,aAAa;IACb,WAAW,IAAI,IAAI;IAQnB,mBAAmB;YACL,YAAY;IAsB1B,gBAAgB;YACF,cAAc;IAiE5B,2CAA2C;IAC3C,OAAO,CAAC,YAAY;IAgBd,oBAAoB,CAAC,SAAS,EAAE,MAAM,EAAE,OAAO,EAAE,qBAAqB,GAAG,OAAO,CAAC,IAAI,CAAC;CA+C7F"}
|
package/lib/adapter.js
CHANGED
|
@@ -1,10 +1,9 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* GitHub
|
|
2
|
+
* GitHub 适配器(基于 gh CLI + App 认证 + Webhook/轮询混合)
|
|
3
3
|
*/
|
|
4
|
-
import crypto from 'node:crypto';
|
|
5
4
|
import { Adapter, } from 'zhin.js';
|
|
5
|
+
import crypto from 'node:crypto';
|
|
6
6
|
import { GitHubBot } from './bot.js';
|
|
7
|
-
import { GitHubOAuthClient, exchangeOAuthCode } from './api.js';
|
|
8
7
|
const VALID_EVENTS = ['push', 'issue', 'star', 'fork', 'unstar', 'pull_request'];
|
|
9
8
|
function safeParseEvents(raw) {
|
|
10
9
|
if (Array.isArray(raw))
|
|
@@ -19,8 +18,6 @@ function safeParseEvents(raw) {
|
|
|
19
18
|
}
|
|
20
19
|
return [];
|
|
21
20
|
}
|
|
22
|
-
const oauthStates = new Map();
|
|
23
|
-
const OAUTH_STATE_TTL = 5 * 60 * 1000;
|
|
24
21
|
function formatNotification(event, p) {
|
|
25
22
|
const repo = p.repository.full_name;
|
|
26
23
|
const sender = p.sender.login;
|
|
@@ -71,10 +68,14 @@ function formatNotification(event, p) {
|
|
|
71
68
|
}
|
|
72
69
|
}
|
|
73
70
|
export class GitHubAdapter extends Adapter {
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
71
|
+
/** 轮询定时器 */
|
|
72
|
+
_pollTimer = null;
|
|
73
|
+
/** 每个 repo 的 ETag 缓存 */
|
|
74
|
+
_etags = new Map();
|
|
75
|
+
/** 每个 repo 最后处理的事件 ID(防重复) */
|
|
76
|
+
_lastEventIds = new Map();
|
|
77
|
+
/** Webhook 是否已激活 */
|
|
78
|
+
_webhookActive = false;
|
|
78
79
|
constructor(plugin) {
|
|
79
80
|
super(plugin, 'github', []);
|
|
80
81
|
}
|
|
@@ -84,210 +85,274 @@ export class GitHubAdapter extends Adapter {
|
|
|
84
85
|
async start() {
|
|
85
86
|
await super.start();
|
|
86
87
|
}
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
return bot?.api || null;
|
|
88
|
+
async stop() {
|
|
89
|
+
this.stopPolling();
|
|
90
|
+
await super.stop();
|
|
91
91
|
}
|
|
92
|
-
/**
|
|
93
|
-
|
|
92
|
+
/** 获取第一个可用 bot 的 GhClient (工具用) */
|
|
93
|
+
getAPI() {
|
|
94
94
|
const bot = this.bots.values().next().value;
|
|
95
|
-
|
|
96
|
-
if (!clientId)
|
|
97
|
-
return null;
|
|
98
|
-
const state = crypto.randomUUID();
|
|
99
|
-
oauthStates.set(state, { platform, platformUid, expires: Date.now() + OAUTH_STATE_TTL });
|
|
100
|
-
const base = this.publicUrl || '';
|
|
101
|
-
return `${base}/pub/github/oauth?state=${state}`;
|
|
95
|
+
return bot?.gh || null;
|
|
102
96
|
}
|
|
103
|
-
|
|
104
|
-
async
|
|
97
|
+
/** 获取指定用户绑定的 GhClient;未绑定则返回 null */
|
|
98
|
+
async getUserAPI(platform, platformUid) {
|
|
105
99
|
const db = this.plugin.root?.inject('database');
|
|
106
100
|
const model = db?.models?.get('github_oauth_users');
|
|
107
101
|
if (!model)
|
|
108
102
|
return null;
|
|
109
|
-
const [
|
|
110
|
-
if (!
|
|
103
|
+
const [record] = await model.select().where({ platform, platform_uid: platformUid });
|
|
104
|
+
if (!record?.access_token)
|
|
111
105
|
return null;
|
|
112
|
-
|
|
106
|
+
const base = this.getAPI();
|
|
107
|
+
if (!base)
|
|
108
|
+
return null;
|
|
109
|
+
return base.withToken(record.access_token);
|
|
110
|
+
}
|
|
111
|
+
/** 获取用户 API,若未绑定则降级为 bot 默认的 API */
|
|
112
|
+
async getUserOrDefaultAPI(platform, platformUid) {
|
|
113
|
+
if (platform && platformUid) {
|
|
114
|
+
const userApi = await this.getUserAPI(platform, platformUid);
|
|
115
|
+
if (userApi)
|
|
116
|
+
return userApi;
|
|
117
|
+
}
|
|
118
|
+
return this.getAPI();
|
|
119
|
+
}
|
|
120
|
+
/** 获取第一个 bot 的 client_id(App 认证时从 /app 自动获取) */
|
|
121
|
+
getClientId() {
|
|
122
|
+
const bot = this.bots.values().next().value;
|
|
123
|
+
return bot?.gh.clientId || null;
|
|
124
|
+
}
|
|
125
|
+
/** 获取第一个 bot 的 host 配置 */
|
|
126
|
+
getHost() {
|
|
127
|
+
const bot = this.bots.values().next().value;
|
|
128
|
+
return bot?.$config.host;
|
|
113
129
|
}
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
const
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
130
|
+
/** 获取 App slug(用于生成安装链接) */
|
|
131
|
+
getAppSlug() {
|
|
132
|
+
const bot = this.bots.values().next().value;
|
|
133
|
+
return bot?.gh.appSlug || null;
|
|
134
|
+
}
|
|
135
|
+
/** 获取所有已发现的安装信息 */
|
|
136
|
+
getInstallations() {
|
|
137
|
+
const bot = this.bots.values().next().value;
|
|
138
|
+
return bot?.gh.installations || [];
|
|
139
|
+
}
|
|
140
|
+
/** 第一个 bot 是否配置了 Webhook */
|
|
141
|
+
get hasWebhookConfig() {
|
|
142
|
+
const bot = this.bots.values().next().value;
|
|
143
|
+
return !!bot?.$config.webhook_secret;
|
|
144
|
+
}
|
|
145
|
+
/** Webhook 是否已激活 */
|
|
146
|
+
get webhookActive() {
|
|
147
|
+
return this._webhookActive;
|
|
148
|
+
}
|
|
149
|
+
// ── Webhook ──────────────────────────────────────────────────────
|
|
150
|
+
/** 在 router 上挂载 Webhook 路由(生产环境推荐) */
|
|
151
|
+
setupWebhook(router) {
|
|
152
|
+
const bot = this.bots.values().next().value;
|
|
153
|
+
if (!bot?.$config.webhook_secret) {
|
|
154
|
+
this.plugin.logger.warn('Webhook 配置缺失 webhook_secret,跳过注册');
|
|
155
|
+
return;
|
|
156
|
+
}
|
|
157
|
+
const secret = bot.$config.webhook_secret;
|
|
158
|
+
const path = bot.$config.webhook_path || '/github/webhook';
|
|
159
|
+
router.post(path, async (ctx) => {
|
|
160
|
+
const signature = ctx.get('x-hub-signature-256');
|
|
161
|
+
const event = ctx.get('x-github-event');
|
|
162
|
+
const deliveryId = ctx.get('x-github-delivery');
|
|
163
|
+
if (!signature || !event) {
|
|
120
164
|
ctx.status = 400;
|
|
121
|
-
ctx.body =
|
|
165
|
+
ctx.body = { error: 'Missing signature or event header' };
|
|
122
166
|
return;
|
|
123
167
|
}
|
|
124
|
-
|
|
125
|
-
const
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
168
|
+
// HMAC-SHA256 签名验证
|
|
169
|
+
const body = ctx.request.rawBody || JSON.stringify(ctx.request.body);
|
|
170
|
+
const expected = 'sha256=' + crypto.createHmac('sha256', secret).update(body).digest('hex');
|
|
171
|
+
if (!crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expected))) {
|
|
172
|
+
this.plugin.logger.warn(`Webhook 签名验证失败 (delivery: ${deliveryId})`);
|
|
173
|
+
ctx.status = 401;
|
|
174
|
+
ctx.body = { error: 'Invalid signature' };
|
|
129
175
|
return;
|
|
130
176
|
}
|
|
131
|
-
const
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
177
|
+
const payload = ctx.request.body;
|
|
178
|
+
ctx.status = 200;
|
|
179
|
+
ctx.body = { ok: true };
|
|
180
|
+
// 异步处理事件,不阻塞响应
|
|
181
|
+
this.handleWebhookPayload(event, payload).catch(e => this.plugin.logger.error(`Webhook 事件处理失败 (${event}):`, e));
|
|
135
182
|
});
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
183
|
+
this._webhookActive = true;
|
|
184
|
+
this.plugin.logger.info(`GitHub Webhook 已注册: POST ${path}`);
|
|
185
|
+
}
|
|
186
|
+
/** 处理 Webhook 推送的事件 */
|
|
187
|
+
async handleWebhookPayload(event, payload) {
|
|
188
|
+
const bot = this.bots.values().next().value;
|
|
189
|
+
const repo = payload.repository?.full_name;
|
|
190
|
+
this.plugin.logger.debug(`Webhook: ${event}${payload.action ? `.${payload.action}` : ''} ${repo || ''}`);
|
|
191
|
+
// 记录事件到数据库
|
|
192
|
+
if (repo) {
|
|
193
|
+
const db = this.plugin.root?.inject('database');
|
|
194
|
+
const eventsModel = db?.models?.get('github_events');
|
|
195
|
+
if (eventsModel) {
|
|
196
|
+
await eventsModel.insert({ id: Date.now(), repo, event_type: event, payload }).catch(() => { });
|
|
142
197
|
}
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
198
|
+
}
|
|
199
|
+
// 处理消息类事件(Issue/PR 评论)
|
|
200
|
+
if (bot && event === 'issue_comment' && payload.action === 'created' && payload.comment) {
|
|
201
|
+
const message = bot.$formatMessage(payload);
|
|
202
|
+
const botUser = bot.gh.authenticatedUser;
|
|
203
|
+
if (!(botUser && message.$sender.id === botUser)) {
|
|
204
|
+
this.emit('message.receive', message);
|
|
149
205
|
}
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
const
|
|
153
|
-
const
|
|
154
|
-
if (!
|
|
155
|
-
|
|
156
|
-
ctx.body = 'OAuth not configured.';
|
|
157
|
-
return;
|
|
206
|
+
}
|
|
207
|
+
if (bot && event === 'pull_request_review_comment' && payload.action === 'created' && payload.comment) {
|
|
208
|
+
const message = bot.formatPRReviewComment(payload);
|
|
209
|
+
const botUser = bot.gh.authenticatedUser;
|
|
210
|
+
if (!(botUser && message.$sender.id === botUser)) {
|
|
211
|
+
this.emit('message.receive', message);
|
|
158
212
|
}
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
return;
|
|
167
|
-
}
|
|
168
|
-
const ghUser = userRes.data;
|
|
169
|
-
const db = this.plugin.root?.inject('database');
|
|
170
|
-
const model = db?.models?.get('github_oauth_users');
|
|
171
|
-
if (!model) {
|
|
172
|
-
ctx.status = 500;
|
|
173
|
-
ctx.body = 'Database not ready.';
|
|
174
|
-
return;
|
|
175
|
-
}
|
|
176
|
-
const [existing] = await model.select().where({ platform: pending.platform, platform_uid: pending.platformUid });
|
|
177
|
-
if (existing) {
|
|
178
|
-
await model.update({
|
|
179
|
-
github_login: ghUser.login,
|
|
180
|
-
github_id: ghUser.id,
|
|
181
|
-
access_token: tokenData.access_token,
|
|
182
|
-
scope: tokenData.scope || '',
|
|
183
|
-
updated_at: new Date(),
|
|
184
|
-
}).where({ id: existing.id });
|
|
185
|
-
}
|
|
186
|
-
else {
|
|
187
|
-
await model.insert({
|
|
188
|
-
id: Date.now(),
|
|
189
|
-
platform: pending.platform,
|
|
190
|
-
platform_uid: pending.platformUid,
|
|
191
|
-
github_login: ghUser.login,
|
|
192
|
-
github_id: ghUser.id,
|
|
193
|
-
access_token: tokenData.access_token,
|
|
194
|
-
scope: tokenData.scope || '',
|
|
195
|
-
created_at: new Date(),
|
|
196
|
-
updated_at: new Date(),
|
|
197
|
-
});
|
|
213
|
+
}
|
|
214
|
+
if (bot && event === 'pull_request_review' && payload.action === 'submitted') {
|
|
215
|
+
const message = bot.formatPRReview(payload);
|
|
216
|
+
if (message) {
|
|
217
|
+
const botUser = bot.gh.authenticatedUser;
|
|
218
|
+
if (!(botUser && message.$sender.id === botUser)) {
|
|
219
|
+
this.emit('message.receive', message);
|
|
198
220
|
}
|
|
199
|
-
this.plugin.logger.info(`OAuth 绑定成功: ${pending.platform}:${pending.platformUid} → ${ghUser.login}`);
|
|
200
|
-
ctx.type = 'text/html';
|
|
201
|
-
ctx.body = `<!DOCTYPE html><html><head><meta charset="utf-8"><title>绑定成功</title><style>body{font-family:system-ui;display:flex;align-items:center;justify-content:center;min-height:100vh;margin:0;background:#f5f5f5}div{text-align:center;background:#fff;padding:3rem;border-radius:12px;box-shadow:0 2px 12px rgba(0,0,0,.1)}h1{color:#28a745;margin-bottom:.5rem}p{color:#666}</style></head><body><div><h1>GitHub 账号绑定成功</h1><p>已绑定 GitHub 用户: <strong>${ghUser.login}</strong></p><p>你现在可以关闭这个页面,回到聊天中使用 GitHub 功能了。</p></div></body></html>`;
|
|
202
|
-
}
|
|
203
|
-
catch (err) {
|
|
204
|
-
this.plugin.logger.error('OAuth callback 失败:', err);
|
|
205
|
-
ctx.status = 500;
|
|
206
|
-
ctx.body = `OAuth failed: ${err.message}`;
|
|
207
221
|
}
|
|
208
|
-
}
|
|
209
|
-
|
|
222
|
+
}
|
|
223
|
+
// 通知订阅者
|
|
224
|
+
if (repo) {
|
|
225
|
+
const genericPayload = {
|
|
226
|
+
action: payload.action,
|
|
227
|
+
repository: payload.repository,
|
|
228
|
+
sender: payload.sender,
|
|
229
|
+
ref: payload.ref,
|
|
230
|
+
commits: payload.commits,
|
|
231
|
+
issue: payload.issue,
|
|
232
|
+
pull_request: payload.pull_request,
|
|
233
|
+
forkee: payload.forkee,
|
|
234
|
+
};
|
|
235
|
+
await this.dispatchNotification(event, genericPayload);
|
|
236
|
+
}
|
|
210
237
|
}
|
|
211
|
-
// ──
|
|
212
|
-
|
|
213
|
-
|
|
238
|
+
// ── 事件轮询 ────────────────────────────────────────────────────
|
|
239
|
+
/** 启动事件轮询 */
|
|
240
|
+
startPolling() {
|
|
241
|
+
if (this._pollTimer)
|
|
242
|
+
return;
|
|
243
|
+
const bot = this.bots.values().next().value;
|
|
244
|
+
const interval = (bot?.$config.poll_interval || 60) * 1000;
|
|
245
|
+
this.plugin.logger.info(`GitHub 事件轮询已启动 (间隔 ${interval / 1000}s)`);
|
|
246
|
+
// 立即执行一次
|
|
247
|
+
this.pollAllRepos().catch(e => this.plugin.logger.error('轮询失败:', e));
|
|
248
|
+
this._pollTimer = setInterval(() => {
|
|
249
|
+
this.pollAllRepos().catch(e => this.plugin.logger.error('轮询失败:', e));
|
|
250
|
+
}, interval);
|
|
251
|
+
}
|
|
252
|
+
/** 停止事件轮询 */
|
|
253
|
+
stopPolling() {
|
|
254
|
+
if (this._pollTimer) {
|
|
255
|
+
clearInterval(this._pollTimer);
|
|
256
|
+
this._pollTimer = null;
|
|
257
|
+
this.plugin.logger.debug('GitHub 事件轮询已停止');
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
/** 轮询所有已订阅仓库的事件 */
|
|
261
|
+
async pollAllRepos() {
|
|
262
|
+
const db = this.plugin.root?.inject('database');
|
|
263
|
+
const model = db?.models?.get('github_subscriptions');
|
|
264
|
+
if (!model)
|
|
265
|
+
return;
|
|
266
|
+
// 获取所有不重复的订阅仓库
|
|
267
|
+
const allSubs = await model.select();
|
|
268
|
+
const repos = [...new Set((allSubs || []).map((s) => s.repo))];
|
|
269
|
+
if (!repos.length)
|
|
270
|
+
return;
|
|
271
|
+
const gh = this.getAPI();
|
|
272
|
+
if (!gh)
|
|
273
|
+
return;
|
|
274
|
+
for (const repo of repos) {
|
|
214
275
|
try {
|
|
215
|
-
|
|
216
|
-
const signature = ctx.request.headers['x-hub-signature-256'];
|
|
217
|
-
const payload = ctx.request.body;
|
|
218
|
-
this.plugin.logger.info(`GitHub Webhook: ${eventName} - ${payload?.repository?.full_name || '(no repo)'}`);
|
|
219
|
-
if (eventName === 'ping') {
|
|
220
|
-
this.plugin.logger.info(`GitHub Webhook ping OK — hook_id: ${payload?.hook_id}, zen: ${payload?.zen}`);
|
|
221
|
-
ctx.status = 200;
|
|
222
|
-
ctx.body = { message: 'pong' };
|
|
223
|
-
return;
|
|
224
|
-
}
|
|
225
|
-
if (signature) {
|
|
226
|
-
let verified = false;
|
|
227
|
-
const rawBody = JSON.stringify(payload);
|
|
228
|
-
for (const bot of this.bots.values()) {
|
|
229
|
-
const secret = bot.$config.webhook_secret;
|
|
230
|
-
if (!secret)
|
|
231
|
-
continue;
|
|
232
|
-
const expected = `sha256=${crypto.createHmac('sha256', secret).update(rawBody).digest('hex')}`;
|
|
233
|
-
if (signature === expected) {
|
|
234
|
-
verified = true;
|
|
235
|
-
break;
|
|
236
|
-
}
|
|
237
|
-
}
|
|
238
|
-
if (!verified) {
|
|
239
|
-
const hasSecret = Array.from(this.bots.values()).some(b => b.$config.webhook_secret);
|
|
240
|
-
if (hasSecret) {
|
|
241
|
-
this.plugin.logger.warn('GitHub Webhook 签名验证失败');
|
|
242
|
-
ctx.status = 401;
|
|
243
|
-
ctx.body = { error: 'Invalid signature' };
|
|
244
|
-
return;
|
|
245
|
-
}
|
|
246
|
-
}
|
|
247
|
-
}
|
|
248
|
-
if (!payload?.repository) {
|
|
249
|
-
ctx.status = 400;
|
|
250
|
-
ctx.body = { error: 'Invalid payload' };
|
|
251
|
-
return;
|
|
252
|
-
}
|
|
253
|
-
const db = this.plugin.root?.inject('database');
|
|
254
|
-
const eventsModel = db?.models?.get('github_events');
|
|
255
|
-
if (eventsModel) {
|
|
256
|
-
await eventsModel.insert({ id: Date.now(), repo: payload.repository.full_name, event_type: eventName, payload });
|
|
257
|
-
}
|
|
258
|
-
const bot = this.bots.values().next().value;
|
|
259
|
-
if (bot) {
|
|
260
|
-
let message = null;
|
|
261
|
-
if (eventName === 'issue_comment' && payload.action === 'created') {
|
|
262
|
-
message = bot.$formatMessage(payload);
|
|
263
|
-
}
|
|
264
|
-
else if (eventName === 'pull_request_review_comment' && payload.action === 'created') {
|
|
265
|
-
message = bot.formatPRReviewComment(payload);
|
|
266
|
-
}
|
|
267
|
-
else if (eventName === 'pull_request_review' && payload.action === 'submitted') {
|
|
268
|
-
message = bot.formatPRReview(payload);
|
|
269
|
-
}
|
|
270
|
-
if (message) {
|
|
271
|
-
const botUser = bot.api.authenticatedUser;
|
|
272
|
-
if (botUser && message.$sender.id === botUser) {
|
|
273
|
-
this.plugin.logger.debug(`忽略 bot 自身评论: ${message.$sender.id}`);
|
|
274
|
-
}
|
|
275
|
-
else {
|
|
276
|
-
this.emit('message.receive', message);
|
|
277
|
-
}
|
|
278
|
-
}
|
|
279
|
-
}
|
|
280
|
-
await this.dispatchNotification(eventName, payload);
|
|
281
|
-
ctx.status = 200;
|
|
282
|
-
ctx.body = { message: 'OK' };
|
|
276
|
+
await this.pollRepoEvents(repo, gh);
|
|
283
277
|
}
|
|
284
|
-
catch (
|
|
285
|
-
this.plugin.logger.
|
|
286
|
-
ctx.status = 500;
|
|
287
|
-
ctx.body = { error: 'Internal server error' };
|
|
278
|
+
catch (e) {
|
|
279
|
+
this.plugin.logger.warn(`轮询 ${repo} 事件失败:`, e);
|
|
288
280
|
}
|
|
289
|
-
}
|
|
290
|
-
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
/** 轮询单个仓库的事件 */
|
|
284
|
+
async pollRepoEvents(repo, gh) {
|
|
285
|
+
const etag = this._etags.get(repo);
|
|
286
|
+
const { events, etag: newEtag } = await gh.listRepoEvents(repo, etag);
|
|
287
|
+
if (newEtag)
|
|
288
|
+
this._etags.set(repo, newEtag);
|
|
289
|
+
if (!events.length)
|
|
290
|
+
return;
|
|
291
|
+
const lastId = this._lastEventIds.get(repo);
|
|
292
|
+
const newEvents = [];
|
|
293
|
+
for (const ev of events) {
|
|
294
|
+
if (ev.id === lastId)
|
|
295
|
+
break;
|
|
296
|
+
newEvents.push(ev);
|
|
297
|
+
}
|
|
298
|
+
if (!newEvents.length)
|
|
299
|
+
return;
|
|
300
|
+
// 记录最新事件 ID
|
|
301
|
+
this._lastEventIds.set(repo, events[0].id);
|
|
302
|
+
// 首次轮询只记录位置,不触发通知(避免启动时大量回溯)
|
|
303
|
+
if (!lastId) {
|
|
304
|
+
this.plugin.logger.debug(`${repo}: 首次轮询,记录位置 (${events[0].id}),跳过 ${events.length} 条历史事件`);
|
|
305
|
+
return;
|
|
306
|
+
}
|
|
307
|
+
this.plugin.logger.debug(`${repo}: ${newEvents.length} 条新事件`);
|
|
308
|
+
const bot = this.bots.values().next().value;
|
|
309
|
+
// 按时间正序处理(API 返回倒序)
|
|
310
|
+
for (const ev of newEvents.reverse()) {
|
|
311
|
+
const eventName = this.mapEventType(ev.type);
|
|
312
|
+
if (!eventName)
|
|
313
|
+
continue;
|
|
314
|
+
// 构造与 webhook payload 兼容的结构
|
|
315
|
+
const payload = {
|
|
316
|
+
action: ev.payload?.action,
|
|
317
|
+
repository: ev.repo ? { full_name: ev.repo.name, html_url: `https://github.com/${ev.repo.name}`, description: '' } : ev.payload?.repository,
|
|
318
|
+
sender: { login: ev.actor?.login || '?', id: ev.actor?.id || 0, html_url: `https://github.com/${ev.actor?.login}` },
|
|
319
|
+
ref: ev.payload?.ref,
|
|
320
|
+
commits: ev.payload?.commits,
|
|
321
|
+
issue: ev.payload?.issue,
|
|
322
|
+
pull_request: ev.payload?.pull_request,
|
|
323
|
+
forkee: ev.payload?.forkee,
|
|
324
|
+
};
|
|
325
|
+
// 记录事件到数据库
|
|
326
|
+
const db = this.plugin.root?.inject('database');
|
|
327
|
+
const eventsModel = db?.models?.get('github_events');
|
|
328
|
+
if (eventsModel) {
|
|
329
|
+
await eventsModel.insert({ id: Date.now(), repo, event_type: eventName, payload: ev.payload }).catch(() => { });
|
|
330
|
+
}
|
|
331
|
+
// 处理消息类事件(Issue/PR 评论)
|
|
332
|
+
if (bot && eventName === 'issue_comment' && ev.payload?.action === 'created' && ev.payload?.comment) {
|
|
333
|
+
const message = bot.$formatMessage(ev.payload);
|
|
334
|
+
const botUser = bot.gh.authenticatedUser;
|
|
335
|
+
if (!(botUser && message.$sender.id === botUser)) {
|
|
336
|
+
this.emit('message.receive', message);
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
// 通知订阅者
|
|
340
|
+
await this.dispatchNotification(eventName, payload);
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
/** Events API type → webhook event name */
|
|
344
|
+
mapEventType(type) {
|
|
345
|
+
const map = {
|
|
346
|
+
PushEvent: 'push',
|
|
347
|
+
IssuesEvent: 'issues',
|
|
348
|
+
WatchEvent: 'star',
|
|
349
|
+
ForkEvent: 'fork',
|
|
350
|
+
PullRequestEvent: 'pull_request',
|
|
351
|
+
IssueCommentEvent: 'issue_comment',
|
|
352
|
+
PullRequestReviewEvent: 'pull_request_review',
|
|
353
|
+
PullRequestReviewCommentEvent: 'pull_request_review_comment',
|
|
354
|
+
};
|
|
355
|
+
return map[type] || null;
|
|
291
356
|
}
|
|
292
357
|
// ── 通知推送 ───────────────────────────────────────────────────────
|
|
293
358
|
async dispatchNotification(eventName, payload) {
|