@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 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 { GitHubAPI, GitHubOAuthClient } from './api.js';
8
+ import type { GhClient } from './gh-client.js';
5
9
  export declare class GitHubAdapter extends Adapter<GitHubBot> {
6
- get publicUrl(): string | undefined;
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
- /** 获取第一个可用 bot 的 API (工具用) */
11
- getAPI(): GitHubAPI | null;
12
- /** 创建 OAuth 状态并返回授权 URL(供 github_bind 工具使用) */
13
- createOAuthState(platform: string, platformUid: string): string | null;
14
- getOAuthClient(platform: string, platformUid: string): Promise<GitHubOAuthClient | null>;
15
- setupOAuth(router: import('@zhin.js/http').Router): void;
16
- setupWebhook(router: import('@zhin.js/http').Router): void;
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
@@ -1 +1 @@
1
- {"version":3,"file":"adapter.d.ts","sourceRoot":"","sources":["../src/adapter.ts"],"names":[],"mappings":"AAIA,OAAO,EACL,OAAO,EACP,MAAM,EAEP,MAAM,SAAS,CAAC;AACjB,OAAO,EAAE,SAAS,EAAE,MAAM,UAAU,CAAC;AACrC,OAAO,KAAK,EAAE,eAAe,EAAa,qBAAqB,EAAgB,MAAM,YAAY,CAAC;AAClG,OAAO,EAAE,SAAS,EAAE,iBAAiB,EAAqB,MAAM,UAAU,CAAC;AAkE3E,qBAAa,aAAc,SAAQ,OAAO,CAAC,SAAS,CAAC;IACnD,IAAI,SAAS,IAAI,MAAM,GAAG,SAAS,CAGlC;gBAEW,MAAM,EAAE,MAAM;IAI1B,SAAS,CAAC,MAAM,EAAE,eAAe,GAAG,SAAS;IAIvC,KAAK,IAAI,OAAO,CAAC,IAAI,CAAC;IAI5B,8BAA8B;IAC9B,MAAM,IAAI,SAAS,GAAG,IAAI;IAK1B,+CAA+C;IAC/C,gBAAgB,CAAC,QAAQ,EAAE,MAAM,EAAE,WAAW,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI;IAchE,cAAc,CAAC,QAAQ,EAAE,MAAM,EAAE,WAAW,EAAE,MAAM,GAAG,OAAO,CAAC,iBAAiB,GAAG,IAAI,CAAC;IAW9F,UAAU,CAAC,MAAM,EAAE,OAAO,eAAe,EAAE,MAAM,GAAG,IAAI;IA6GxD,YAAY,CAAC,MAAM,EAAE,OAAO,eAAe,EAAE,MAAM,GAAG,IAAI;IAwFpD,oBAAoB,CAAC,SAAS,EAAE,MAAM,EAAE,OAAO,EAAE,qBAAqB,GAAG,OAAO,CAAC,IAAI,CAAC;CA+C7F"}
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
- get publicUrl() {
75
- const bot = this.bots.values().next().value;
76
- return bot?.$config.public_url?.replace(/\/+$/, '');
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
- /** 获取第一个可用 bot 的 API (工具用) */
88
- getAPI() {
89
- const bot = this.bots.values().next().value;
90
- return bot?.api || null;
88
+ async stop() {
89
+ this.stopPolling();
90
+ await super.stop();
91
91
  }
92
- /** 创建 OAuth 状态并返回授权 URL(供 github_bind 工具使用) */
93
- createOAuthState(platform, platformUid) {
92
+ /** 获取第一个可用 bot GhClient (工具用) */
93
+ getAPI() {
94
94
  const bot = this.bots.values().next().value;
95
- const clientId = bot?.$config.client_id;
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
- // ── OAuth 用户查询 ─────────────────────────────────────────────────
104
- async getOAuthClient(platform, platformUid) {
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 [row] = await model.select().where({ platform, platform_uid: platformUid });
110
- if (!row)
103
+ const [record] = await model.select().where({ platform, platform_uid: platformUid });
104
+ if (!record?.access_token)
111
105
  return null;
112
- return new GitHubOAuthClient(row.access_token);
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
- // ── OAuth 路由 (由 useContext('router') 注入) ─────────────────────
115
- setupOAuth(router) {
116
- const OAUTH_SCOPES = 'repo,user';
117
- router.get('/pub/github/oauth', async (ctx) => {
118
- const state = ctx.query.state;
119
- if (!state || !oauthStates.has(state)) {
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 = 'Invalid or expired state. Please use /github bind to generate a new link.';
165
+ ctx.body = { error: 'Missing signature or event header' };
122
166
  return;
123
167
  }
124
- const bot = this.bots.values().next().value;
125
- const clientId = bot?.$config.client_id;
126
- if (!clientId) {
127
- ctx.status = 500;
128
- ctx.body = 'GitHub App OAuth not configured (missing client_id).';
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 base = this.publicUrl || ctx.origin;
132
- const redirectUri = `${base}/pub/github/oauth/callback`;
133
- const url = `https://github.com/login/oauth/authorize?client_id=${clientId}&redirect_uri=${encodeURIComponent(redirectUri)}&scope=${OAUTH_SCOPES}&state=${state}`;
134
- ctx.redirect(url);
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
- router.get('/pub/github/oauth/callback', async (ctx) => {
137
- const { code, state } = ctx.query;
138
- if (!code || !state) {
139
- ctx.status = 400;
140
- ctx.body = 'Missing code or state parameter.';
141
- return;
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
- const pending = oauthStates.get(state);
144
- if (!pending || Date.now() > pending.expires) {
145
- oauthStates.delete(state);
146
- ctx.status = 400;
147
- ctx.body = 'State expired. Please use /github bind to try again.';
148
- return;
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
- oauthStates.delete(state);
151
- const bot = this.bots.values().next().value;
152
- const clientId = bot?.$config.client_id;
153
- const clientSecret = bot?.$config.client_secret;
154
- if (!clientId || !clientSecret) {
155
- ctx.status = 500;
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
- try {
160
- const tokenData = await exchangeOAuthCode(clientId, clientSecret, code);
161
- const oauthClient = new GitHubOAuthClient(tokenData.access_token);
162
- const userRes = await oauthClient.getUser();
163
- if (!userRes.ok) {
164
- ctx.status = 500;
165
- ctx.body = 'Failed to fetch GitHub user info.';
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
- this.plugin.logger.debug('GitHub OAuth: GET /pub/github/oauth, GET /pub/github/oauth/callback');
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
- // ── Webhook 路由 (由 useContext('router') 注入) ────────────────────
212
- setupWebhook(router) {
213
- router.post('/pub/github/webhook', async (ctx) => {
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
- const eventName = ctx.request.headers['x-github-event'];
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 (error) {
285
- this.plugin.logger.error('Webhook 处理失败:', error);
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
- this.plugin.logger.debug('GitHub Webhook: POST /pub/github/webhook');
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) {