@zhin.js/adapter-github 0.1.19 → 0.1.21

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.js ADDED
@@ -0,0 +1,991 @@
1
+ /**
2
+ * GitHub 适配器
3
+ */
4
+ import crypto from 'node:crypto';
5
+ import { Adapter, ZhinTool, } from 'zhin.js';
6
+ import { GitHubBot } from './bot.js';
7
+ import { GitHubOAuthClient, exchangeOAuthCode } from './api.js';
8
+ const VALID_EVENTS = ['push', 'issue', 'star', 'fork', 'unstar', 'pull_request'];
9
+ function safeParseEvents(raw) {
10
+ if (Array.isArray(raw))
11
+ return raw;
12
+ if (typeof raw === 'string') {
13
+ try {
14
+ const parsed = JSON.parse(raw);
15
+ if (Array.isArray(parsed))
16
+ return parsed;
17
+ }
18
+ catch { }
19
+ }
20
+ return [];
21
+ }
22
+ const oauthStates = new Map();
23
+ const OAUTH_STATE_TTL = 5 * 60 * 1000;
24
+ function formatNotification(event, p) {
25
+ const repo = p.repository.full_name;
26
+ const sender = p.sender.login;
27
+ switch (event) {
28
+ case 'push': {
29
+ const branch = p.ref?.replace('refs/heads/', '') || '?';
30
+ const commits = p.commits || [];
31
+ let msg = `📦 ${repo}\n🌿 ${sender} pushed to ${branch}\n\n`;
32
+ if (commits.length) {
33
+ msg += `📝 ${commits.length} commit(s):\n`;
34
+ msg += commits.slice(0, 3).map(c => ` • ${c.id.substring(0, 7)} ${c.message.split('\n')[0]}`).join('\n');
35
+ if (commits.length > 3)
36
+ msg += `\n ... +${commits.length - 3} more`;
37
+ }
38
+ return msg;
39
+ }
40
+ case 'issues': {
41
+ const i = p.issue;
42
+ const act = p.action === 'opened' ? 'opened' : p.action === 'closed' ? 'closed' : 'updated';
43
+ return `🐛 ${repo}\n👤 ${sender} ${act} issue #${i.number}\n📌 ${i.title}`;
44
+ }
45
+ case 'star': {
46
+ const starred = p.action !== 'deleted';
47
+ return `${starred ? '⭐' : '💔'} ${repo}\n👤 ${sender} ${starred ? 'starred' : 'unstarred'}`;
48
+ }
49
+ case 'fork':
50
+ return `🍴 ${repo}\n👤 ${sender} forked → ${p.forkee.full_name}`;
51
+ case 'pull_request': {
52
+ const pr = p.pull_request;
53
+ const act = p.action === 'opened' ? 'opened' : p.action === 'closed' ? 'closed' : 'updated';
54
+ return `🔀 ${repo}\n👤 ${sender} ${act} PR #${pr.number}\n📌 ${pr.title}`;
55
+ }
56
+ default:
57
+ return `📬 ${repo}\n${event} by ${sender}`;
58
+ }
59
+ }
60
+ export class GitHubAdapter extends Adapter {
61
+ get publicUrl() {
62
+ const bot = this.bots.values().next().value;
63
+ return bot?.$config.public_url?.replace(/\/+$/, '');
64
+ }
65
+ constructor(plugin) {
66
+ super(plugin, 'github', []);
67
+ }
68
+ createBot(config) {
69
+ return new GitHubBot(this, config);
70
+ }
71
+ async start() {
72
+ this.registerGitHubTools();
73
+ await super.start();
74
+ }
75
+ /** 获取第一个可用 bot 的 API (工具用) */
76
+ getAPI() {
77
+ const bot = this.bots.values().next().value;
78
+ return bot?.api || null;
79
+ }
80
+ // ── OAuth 用户查询 ─────────────────────────────────────────────────
81
+ async getOAuthClient(platform, platformUid) {
82
+ const db = this.plugin.root?.inject('database');
83
+ const model = db?.models?.get('github_oauth_users');
84
+ if (!model)
85
+ return null;
86
+ const [row] = await model.select().where({ platform, platform_uid: platformUid });
87
+ if (!row)
88
+ return null;
89
+ return new GitHubOAuthClient(row.access_token);
90
+ }
91
+ // ── OAuth 路由 (由 useContext('router') 注入) ─────────────────────
92
+ setupOAuth(router) {
93
+ const OAUTH_SCOPES = 'repo,user';
94
+ router.get('/pub/github/oauth', async (ctx) => {
95
+ const state = ctx.query.state;
96
+ if (!state || !oauthStates.has(state)) {
97
+ ctx.status = 400;
98
+ ctx.body = 'Invalid or expired state. Please use /github bind to generate a new link.';
99
+ return;
100
+ }
101
+ const bot = this.bots.values().next().value;
102
+ const clientId = bot?.$config.client_id;
103
+ if (!clientId) {
104
+ ctx.status = 500;
105
+ ctx.body = 'GitHub App OAuth not configured (missing client_id).';
106
+ return;
107
+ }
108
+ const base = this.publicUrl || ctx.origin;
109
+ const redirectUri = `${base}/pub/github/oauth/callback`;
110
+ const url = `https://github.com/login/oauth/authorize?client_id=${clientId}&redirect_uri=${encodeURIComponent(redirectUri)}&scope=${OAUTH_SCOPES}&state=${state}`;
111
+ ctx.redirect(url);
112
+ });
113
+ router.get('/pub/github/oauth/callback', async (ctx) => {
114
+ const { code, state } = ctx.query;
115
+ if (!code || !state) {
116
+ ctx.status = 400;
117
+ ctx.body = 'Missing code or state parameter.';
118
+ return;
119
+ }
120
+ const pending = oauthStates.get(state);
121
+ if (!pending || Date.now() > pending.expires) {
122
+ oauthStates.delete(state);
123
+ ctx.status = 400;
124
+ ctx.body = 'State expired. Please use /github bind to try again.';
125
+ return;
126
+ }
127
+ oauthStates.delete(state);
128
+ const bot = this.bots.values().next().value;
129
+ const clientId = bot?.$config.client_id;
130
+ const clientSecret = bot?.$config.client_secret;
131
+ if (!clientId || !clientSecret) {
132
+ ctx.status = 500;
133
+ ctx.body = 'OAuth not configured.';
134
+ return;
135
+ }
136
+ try {
137
+ const tokenData = await exchangeOAuthCode(clientId, clientSecret, code);
138
+ const oauthClient = new GitHubOAuthClient(tokenData.access_token);
139
+ const userRes = await oauthClient.getUser();
140
+ if (!userRes.ok) {
141
+ ctx.status = 500;
142
+ ctx.body = 'Failed to fetch GitHub user info.';
143
+ return;
144
+ }
145
+ const ghUser = userRes.data;
146
+ const db = this.plugin.root?.inject('database');
147
+ const model = db?.models?.get('github_oauth_users');
148
+ if (!model) {
149
+ ctx.status = 500;
150
+ ctx.body = 'Database not ready.';
151
+ return;
152
+ }
153
+ const [existing] = await model.select().where({ platform: pending.platform, platform_uid: pending.platformUid });
154
+ if (existing) {
155
+ await model.update({
156
+ github_login: ghUser.login,
157
+ github_id: ghUser.id,
158
+ access_token: tokenData.access_token,
159
+ scope: tokenData.scope || '',
160
+ updated_at: new Date(),
161
+ }).where({ id: existing.id });
162
+ }
163
+ else {
164
+ await model.insert({
165
+ id: Date.now(),
166
+ platform: pending.platform,
167
+ platform_uid: pending.platformUid,
168
+ github_login: ghUser.login,
169
+ github_id: ghUser.id,
170
+ access_token: tokenData.access_token,
171
+ scope: tokenData.scope || '',
172
+ created_at: new Date(),
173
+ updated_at: new Date(),
174
+ });
175
+ }
176
+ this.plugin.logger.info(`OAuth 绑定成功: ${pending.platform}:${pending.platformUid} → ${ghUser.login}`);
177
+ ctx.type = 'text/html';
178
+ 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>`;
179
+ }
180
+ catch (err) {
181
+ this.plugin.logger.error('OAuth callback 失败:', err);
182
+ ctx.status = 500;
183
+ ctx.body = `OAuth failed: ${err.message}`;
184
+ }
185
+ });
186
+ this.plugin.logger.debug('GitHub OAuth: GET /pub/github/oauth, GET /pub/github/oauth/callback');
187
+ }
188
+ // ── Webhook 路由 (由 useContext('router') 注入) ────────────────────
189
+ setupWebhook(router) {
190
+ router.post('/pub/github/webhook', async (ctx) => {
191
+ try {
192
+ const eventName = ctx.request.headers['x-github-event'];
193
+ const signature = ctx.request.headers['x-hub-signature-256'];
194
+ const payload = ctx.request.body;
195
+ this.plugin.logger.info(`GitHub Webhook: ${eventName} - ${payload?.repository?.full_name || '(no repo)'}`);
196
+ if (eventName === 'ping') {
197
+ this.plugin.logger.info(`GitHub Webhook ping OK — hook_id: ${payload?.hook_id}, zen: ${payload?.zen}`);
198
+ ctx.status = 200;
199
+ ctx.body = { message: 'pong' };
200
+ return;
201
+ }
202
+ if (signature) {
203
+ let verified = false;
204
+ const rawBody = JSON.stringify(payload);
205
+ for (const bot of this.bots.values()) {
206
+ const secret = bot.$config.webhook_secret;
207
+ if (!secret)
208
+ continue;
209
+ const expected = `sha256=${crypto.createHmac('sha256', secret).update(rawBody).digest('hex')}`;
210
+ if (signature === expected) {
211
+ verified = true;
212
+ break;
213
+ }
214
+ }
215
+ if (!verified) {
216
+ const hasSecret = Array.from(this.bots.values()).some(b => b.$config.webhook_secret);
217
+ if (hasSecret) {
218
+ this.plugin.logger.warn('GitHub Webhook 签名验证失败');
219
+ ctx.status = 401;
220
+ ctx.body = { error: 'Invalid signature' };
221
+ return;
222
+ }
223
+ }
224
+ }
225
+ if (!payload?.repository) {
226
+ ctx.status = 400;
227
+ ctx.body = { error: 'Invalid payload' };
228
+ return;
229
+ }
230
+ const db = this.plugin.root?.inject('database');
231
+ const eventsModel = db?.models?.get('github_events');
232
+ if (eventsModel) {
233
+ await eventsModel.insert({ id: Date.now(), repo: payload.repository.full_name, event_type: eventName, payload });
234
+ }
235
+ const bot = this.bots.values().next().value;
236
+ if (bot) {
237
+ let message = null;
238
+ if (eventName === 'issue_comment' && payload.action === 'created') {
239
+ message = bot.$formatMessage(payload);
240
+ }
241
+ else if (eventName === 'pull_request_review_comment' && payload.action === 'created') {
242
+ message = bot.formatPRReviewComment(payload);
243
+ }
244
+ else if (eventName === 'pull_request_review' && payload.action === 'submitted') {
245
+ message = bot.formatPRReview(payload);
246
+ }
247
+ if (message) {
248
+ const botUser = bot.api.authenticatedUser;
249
+ if (botUser && message.$sender.id === botUser) {
250
+ this.plugin.logger.debug(`忽略 bot 自身评论: ${message.$sender.id}`);
251
+ }
252
+ else {
253
+ this.emit('message.receive', message);
254
+ }
255
+ }
256
+ }
257
+ await this.dispatchNotification(eventName, payload);
258
+ ctx.status = 200;
259
+ ctx.body = { message: 'OK' };
260
+ }
261
+ catch (error) {
262
+ this.plugin.logger.error('Webhook 处理失败:', error);
263
+ ctx.status = 500;
264
+ ctx.body = { error: 'Internal server error' };
265
+ }
266
+ });
267
+ this.plugin.logger.debug('GitHub Webhook: POST /pub/github/webhook');
268
+ }
269
+ // ── GitHub 管理工具 ────────────────────────────────────────────────
270
+ registerGitHubTools() {
271
+ // --- PR ---
272
+ this.addTool(new ZhinTool('github_pr')
273
+ .desc('GitHub PR 操作:list/view/diff/merge/create/review/close')
274
+ .keyword('pr', 'pull request', '合并', 'merge', 'review', '审查', '拉取请求')
275
+ .tag('github', 'pr')
276
+ .param('action', { type: 'string', description: 'list|view|diff|merge|create|review|close', enum: ['list', 'view', 'diff', 'merge', 'create', 'review', 'close'] }, true)
277
+ .param('repo', { type: 'string', description: 'owner/repo (必填)' }, true)
278
+ .param('number', { type: 'number', description: 'PR 编号' })
279
+ .param('title', { type: 'string', description: 'PR 标题 (create)' })
280
+ .param('body', { type: 'string', description: 'PR 描述 / Review 评语' })
281
+ .param('head', { type: 'string', description: '源分支 (create)' })
282
+ .param('base', { type: 'string', description: '目标分支 (create,默认 main)' })
283
+ .param('state', { type: 'string', description: 'open/closed/all (list)' })
284
+ .param('approve', { type: 'boolean', description: 'review 时 approve' })
285
+ .param('method', { type: 'string', description: 'squash/merge/rebase (merge)' })
286
+ .execute(async (args) => {
287
+ const api = this.getAPI();
288
+ if (!api)
289
+ return '❌ 没有可用的 GitHub bot';
290
+ const action = args.action;
291
+ const repo = args.repo;
292
+ const num = args.number;
293
+ switch (action) {
294
+ case 'list': {
295
+ const r = await api.listPRs(repo, args.state || 'open');
296
+ if (!r.ok)
297
+ return `❌ ${JSON.stringify(r.data)}`;
298
+ if (!r.data.length)
299
+ return `📭 没有 ${args.state || 'open'} 状态的 PR`;
300
+ return r.data.map((p) => `#${p.number} ${p.draft ? '[Draft] ' : ''}${p.title}\n 👤 ${p.user.login} | 🌿 ${p.head.ref} → ${p.base.ref} | ${p.state}`).join('\n\n');
301
+ }
302
+ case 'view': {
303
+ if (!num)
304
+ return '❌ 请提供 PR 编号';
305
+ const r = await api.getPR(repo, num);
306
+ if (!r.ok)
307
+ return `❌ ${JSON.stringify(r.data)}`;
308
+ const p = r.data;
309
+ return [
310
+ `#${p.number} ${p.title}`,
311
+ `👤 ${p.user.login} | ${p.state} | 🌿 ${p.head.ref} → ${p.base.ref}`,
312
+ `📅 ${p.created_at?.split('T')[0]} | +${p.additions} -${p.deletions} (${p.changed_files} files)`,
313
+ p.body ? `\n${p.body.slice(0, 500)}${p.body.length > 500 ? '...' : ''}` : '',
314
+ `\n🔗 ${p.html_url}`,
315
+ ].filter(Boolean).join('\n');
316
+ }
317
+ case 'diff': {
318
+ if (!num)
319
+ return '❌ 请提供 PR 编号';
320
+ const r = await api.getPRDiff(repo, num);
321
+ if (!r.ok)
322
+ return `❌ 获取 diff 失败`;
323
+ const lines = r.data.split('\n');
324
+ return lines.length > 100 ? lines.slice(0, 100).join('\n') + `\n\n... (共 ${lines.length} 行)` : r.data;
325
+ }
326
+ case 'merge': {
327
+ if (!num)
328
+ return '❌ 请提供 PR 编号';
329
+ const r = await api.mergePR(repo, num, args.method || 'squash');
330
+ return r.ok ? `✅ PR #${num} 已合并` : `❌ ${r.data?.message || JSON.stringify(r.data)}`;
331
+ }
332
+ case 'create': {
333
+ if (!args.title)
334
+ return '❌ 请提供 PR 标题';
335
+ if (!args.head)
336
+ return '❌ 请提供源分支 (head)';
337
+ const r = await api.createPR(repo, args.title, args.body || '', args.head, args.base || 'main');
338
+ return r.ok ? `✅ PR 已创建: ${r.data.html_url}` : `❌ ${r.data?.message || JSON.stringify(r.data)}`;
339
+ }
340
+ case 'review': {
341
+ if (!num)
342
+ return '❌ 请提供 PR 编号';
343
+ const event = args.approve ? 'APPROVE' : 'COMMENT';
344
+ const r = await api.createPRReview(repo, num, event, args.body || undefined);
345
+ return r.ok ? `✅ PR #${num} ${args.approve ? '已批准' : '已评论'}` : `❌ ${r.data?.message}`;
346
+ }
347
+ case 'close': {
348
+ if (!num)
349
+ return '❌ 请提供 PR 编号';
350
+ const r = await api.closePR(repo, num);
351
+ return r.ok ? `✅ PR #${num} 已关闭` : `❌ ${r.data?.message}`;
352
+ }
353
+ default: return `❌ 未知操作: ${action}`;
354
+ }
355
+ }));
356
+ // --- Issue ---
357
+ this.addTool(new ZhinTool('github_issue')
358
+ .desc('GitHub Issue 操作:list/view/create/close/comment')
359
+ .keyword('issue', '问题', 'bug', '创建issue', '关闭issue')
360
+ .tag('github', 'issue')
361
+ .param('action', { type: 'string', description: 'list|view|create|close|comment', enum: ['list', 'view', 'create', 'close', 'comment'] }, true)
362
+ .param('repo', { type: 'string', description: 'owner/repo (必填)' }, true)
363
+ .param('number', { type: 'number', description: 'Issue 编号' })
364
+ .param('title', { type: 'string', description: 'Issue 标题 (create)' })
365
+ .param('body', { type: 'string', description: 'Issue 内容 / 评论内容' })
366
+ .param('labels', { type: 'string', description: '标签,逗号分隔 (create)' })
367
+ .param('state', { type: 'string', description: 'open/closed/all (list)' })
368
+ .execute(async (args) => {
369
+ const api = this.getAPI();
370
+ if (!api)
371
+ return '❌ 没有可用的 GitHub bot';
372
+ const action = args.action;
373
+ const repo = args.repo;
374
+ const num = args.number;
375
+ switch (action) {
376
+ case 'list': {
377
+ const r = await api.listIssues(repo, args.state || 'open');
378
+ if (!r.ok)
379
+ return `❌ ${JSON.stringify(r.data)}`;
380
+ // 过滤掉 PR (GitHub API 的 issues 接口也返回 PR)
381
+ const issues = r.data.filter((i) => !i.pull_request);
382
+ if (!issues.length)
383
+ return `📭 没有 ${args.state || 'open'} 状态的 Issue`;
384
+ return issues.map((i) => {
385
+ const labels = i.labels?.map((l) => l.name).join(', ') || '';
386
+ return `#${i.number} ${i.title}\n 👤 ${i.user.login}${labels ? ` | 🏷️ ${labels}` : ''} | ${i.state}`;
387
+ }).join('\n\n');
388
+ }
389
+ case 'view': {
390
+ if (!num)
391
+ return '❌ 请提供 Issue 编号';
392
+ const r = await api.getIssue(repo, num);
393
+ if (!r.ok)
394
+ return `❌ ${JSON.stringify(r.data)}`;
395
+ const i = r.data;
396
+ return [
397
+ `#${i.number} ${i.title}`,
398
+ `👤 ${i.user.login} | ${i.state} | 📅 ${i.created_at?.split('T')[0]}`,
399
+ i.labels?.length ? `🏷️ ${i.labels.map((l) => l.name).join(', ')}` : null,
400
+ i.body ? `\n${i.body.slice(0, 500)}${i.body.length > 500 ? '...' : ''}` : '',
401
+ `\n🔗 ${i.html_url}`,
402
+ ].filter(Boolean).join('\n');
403
+ }
404
+ case 'create': {
405
+ if (!args.title)
406
+ return '❌ 请提供 Issue 标题';
407
+ const labels = args.labels ? args.labels.split(',').map(s => s.trim()) : undefined;
408
+ const r = await api.createIssue(repo, args.title, args.body || undefined, labels);
409
+ return r.ok ? `✅ Issue 已创建: ${r.data.html_url}` : `❌ ${r.data?.message}`;
410
+ }
411
+ case 'close': {
412
+ if (!num)
413
+ return '❌ 请提供 Issue 编号';
414
+ const r = await api.closeIssue(repo, num);
415
+ return r.ok ? `✅ Issue #${num} 已关闭` : `❌ ${r.data?.message}`;
416
+ }
417
+ case 'comment': {
418
+ if (!num)
419
+ return '❌ 请提供 Issue 编号';
420
+ if (!args.body)
421
+ return '❌ 请提供评论内容';
422
+ const r = await api.createIssueComment(repo, num, args.body);
423
+ return r.ok ? `✅ 已评论 Issue #${num}` : `❌ ${JSON.stringify(r.data)}`;
424
+ }
425
+ default: return `❌ 未知操作: ${action}`;
426
+ }
427
+ }));
428
+ // --- Repo ---
429
+ this.addTool(new ZhinTool('github_repo')
430
+ .desc('GitHub 仓库查询:info/branches/releases/runs(CI)/stars')
431
+ .keyword('仓库', 'repo', 'star', '分支', 'branch', 'release', '发布', 'CI', 'workflow')
432
+ .tag('github', 'repo')
433
+ .param('action', { type: 'string', description: 'info|branches|releases|runs|stars', enum: ['info', 'branches', 'releases', 'runs', 'stars'] }, true)
434
+ .param('repo', { type: 'string', description: 'owner/repo (必填)' }, true)
435
+ .param('limit', { type: 'number', description: '返回数量,默认 10' })
436
+ .execute(async (args) => {
437
+ const api = this.getAPI();
438
+ if (!api)
439
+ return '❌ 没有可用的 GitHub bot';
440
+ const action = args.action;
441
+ const repo = args.repo;
442
+ const limit = args.limit || 10;
443
+ switch (action) {
444
+ case 'info': {
445
+ const r = await api.getRepo(repo);
446
+ if (!r.ok)
447
+ return `❌ ${JSON.stringify(r.data)}`;
448
+ const d = r.data;
449
+ return [
450
+ `📦 ${d.full_name}${d.private ? ' 🔒' : ''}`,
451
+ d.description ? `📝 ${d.description}` : null,
452
+ `⭐ ${d.stargazers_count} | 🍴 ${d.forks_count} | 👀 ${d.watchers_count}`,
453
+ `🌿 默认分支: ${d.default_branch}`,
454
+ d.license ? `📄 ${d.license.name}` : null,
455
+ d.homepage ? `🌐 ${d.homepage}` : null,
456
+ `📅 创建: ${d.created_at?.split('T')[0]} | 推送: ${d.pushed_at?.split('T')[0]}`,
457
+ ].filter(Boolean).join('\n');
458
+ }
459
+ case 'branches': {
460
+ const r = await api.listBranches(repo, limit);
461
+ if (!r.ok)
462
+ return `❌ ${JSON.stringify(r.data)}`;
463
+ return r.data.length
464
+ ? `🌿 分支 (${r.data.length}):\n${r.data.map((b) => ` • ${b.name}${b.protected ? ' 🔒' : ''}`).join('\n')}`
465
+ : '没有找到分支';
466
+ }
467
+ case 'releases': {
468
+ const r = await api.listReleases(repo, limit);
469
+ if (!r.ok)
470
+ return `❌ ${JSON.stringify(r.data)}`;
471
+ if (!r.data.length)
472
+ return '📭 暂无发布';
473
+ return r.data.map((rel) => `${rel.prerelease ? '🧪' : '📦'} ${rel.tag_name} — ${rel.name || '(no title)'}\n 📅 ${rel.published_at?.split('T')[0]} | 👤 ${rel.author?.login}`).join('\n\n');
474
+ }
475
+ case 'runs': {
476
+ const r = await api.listWorkflowRuns(repo, limit);
477
+ if (!r.ok)
478
+ return `❌ ${JSON.stringify(r.data)}`;
479
+ const runs = r.data.workflow_runs || [];
480
+ if (!runs.length)
481
+ return '📭 暂无 CI 记录';
482
+ return runs.map((run) => {
483
+ const icon = run.conclusion === 'success' ? '✅' : run.conclusion === 'failure' ? '❌' : run.status === 'in_progress' ? '🔄' : '⏳';
484
+ return `${icon} #${run.id} ${run.display_title}\n 🌿 ${run.head_branch} | ${run.status}${run.conclusion ? '/' + run.conclusion : ''}`;
485
+ }).join('\n\n');
486
+ }
487
+ case 'stars': {
488
+ const r = await api.getRepo(repo);
489
+ if (!r.ok)
490
+ return `❌ ${JSON.stringify(r.data)}`;
491
+ return `⭐ ${r.data.stargazers_count} stars | 🍴 ${r.data.forks_count} forks`;
492
+ }
493
+ default: return `❌ 未知查询: ${action}`;
494
+ }
495
+ }));
496
+ // --- Subscribe ---
497
+ this.addTool(new ZhinTool('github_subscribe')
498
+ .desc('订阅 GitHub 仓库 Webhook 事件通知(跨平台推送到当前聊天)')
499
+ .tag('github', 'subscription')
500
+ .param('repo', { type: 'string', description: 'owner/repo' }, true)
501
+ .param('events', { type: 'array', description: '事件: push, issue, star, fork, unstar, pr(留空=全部)' })
502
+ .execute(async ({ repo, events: evts = [] }, ctx) => {
503
+ if (!ctx?.message)
504
+ return '❌ 无法获取消息上下文';
505
+ const msg = ctx.message;
506
+ const repoStr = repo;
507
+ if (!repoStr.includes('/'))
508
+ return '❌ 格式应为 owner/repo';
509
+ const parsed = [];
510
+ for (const e of evts) {
511
+ const n = e.toLowerCase();
512
+ if (n === 'pr')
513
+ parsed.push('pull_request');
514
+ else if (VALID_EVENTS.includes(n))
515
+ parsed.push(n);
516
+ else
517
+ return `❌ 不支持: ${e}`;
518
+ }
519
+ const subEvents = parsed.length > 0 ? parsed : VALID_EVENTS;
520
+ const db = this.plugin.root?.inject('database');
521
+ const model = db?.models?.get('github_subscriptions');
522
+ if (!model)
523
+ return '❌ 数据库未就绪';
524
+ const where = { repo: repoStr, target_id: msg.$channel.id, target_type: msg.$channel.type, adapter: msg.$adapter, bot: msg.$bot };
525
+ const [existing] = await model.select().where(where);
526
+ const eventsJson = JSON.stringify(subEvents);
527
+ if (existing) {
528
+ await model.update({ events: eventsJson }).where({ id: existing.id });
529
+ return `✅ 已更新订阅 ${repoStr}\n📢 ${subEvents.join(', ')}`;
530
+ }
531
+ const { repo: _r, ...rest } = where;
532
+ await model.insert({ id: Date.now(), repo: repoStr, events: eventsJson, ...rest });
533
+ return `✅ 已订阅 ${repoStr}\n📢 ${subEvents.join(', ')}\n💡 记得在 GitHub App 或仓库 Settings → Webhooks 中配置 Webhook`;
534
+ }));
535
+ // --- Unsubscribe ---
536
+ this.addTool(new ZhinTool('github_unsubscribe')
537
+ .desc('取消订阅 GitHub 仓库事件通知')
538
+ .tag('github', 'subscription')
539
+ .param('repo', { type: 'string', description: 'owner/repo' }, true)
540
+ .execute(async ({ repo }, ctx) => {
541
+ if (!ctx?.message)
542
+ return '❌ 无法获取消息上下文';
543
+ const msg = ctx.message;
544
+ const db = this.plugin.root?.inject('database');
545
+ const model = db?.models?.get('github_subscriptions');
546
+ if (!model)
547
+ return '❌ 数据库未就绪';
548
+ const [sub] = await model.select().where({ repo: repo, target_id: msg.$channel.id, target_type: msg.$channel.type, adapter: msg.$adapter, bot: msg.$bot });
549
+ if (!sub)
550
+ return `❌ 未找到订阅: ${repo}`;
551
+ await model.delete({ id: sub.id });
552
+ return `✅ 已取消订阅 ${repo}`;
553
+ }));
554
+ // --- List subscriptions ---
555
+ this.addTool(new ZhinTool('github_subscriptions')
556
+ .desc('查看当前聊天的 GitHub 事件订阅列表')
557
+ .tag('github', 'subscription')
558
+ .execute(async (_args, ctx) => {
559
+ if (!ctx?.message)
560
+ return '❌ 无法获取消息上下文';
561
+ const msg = ctx.message;
562
+ const db = this.plugin.root?.inject('database');
563
+ const model = db?.models?.get('github_subscriptions');
564
+ if (!model)
565
+ return '❌ 数据库未就绪';
566
+ const subs = await model.select().where({ target_id: msg.$channel.id, target_type: msg.$channel.type, adapter: msg.$adapter, bot: msg.$bot });
567
+ if (!subs?.length)
568
+ return '📭 当前没有订阅';
569
+ return `📋 订阅 (${subs.length}):\n\n` + subs.map((s, i) => {
570
+ return `${i + 1}. ${s.repo}\n 📢 ${safeParseEvents(s.events).join(', ')}`;
571
+ }).join('\n\n');
572
+ }));
573
+ // --- GitHub OAuth Bind ---
574
+ this.addTool(new ZhinTool('github_bind')
575
+ .desc('绑定 GitHub 账号 — 通过 OAuth 授权,让 bot 以你的身份执行 star/fork 等操作')
576
+ .keyword('github bind', '绑定github', 'github 绑定', 'github 授权')
577
+ .tag('github', 'oauth')
578
+ .execute(async (_args, ctx) => {
579
+ if (!ctx?.message)
580
+ return '❌ 无法获取消息上下文';
581
+ const msg = ctx.message;
582
+ const bot = this.bots.values().next().value;
583
+ if (!bot?.$config.client_id) {
584
+ return '❌ GitHub OAuth 未配置(需要在 bot 配置中添加 client_id 和 client_secret)';
585
+ }
586
+ const nonce = crypto.randomBytes(16).toString('hex');
587
+ const state = `${msg.$adapter}:${msg.$sender.id}:${nonce}`;
588
+ oauthStates.set(state, {
589
+ platform: msg.$adapter,
590
+ platformUid: msg.$sender.id,
591
+ expires: Date.now() + OAUTH_STATE_TTL,
592
+ });
593
+ const baseUrl = this.publicUrl;
594
+ if (!baseUrl) {
595
+ return '❌ 未配置 public_url,无法生成 OAuth 链接\n💡 请在 bot 配置中添加 public_url(如 https://bot.example.com)';
596
+ }
597
+ const link = `${baseUrl}/pub/github/oauth?state=${encodeURIComponent(state)}`;
598
+ const fullText = `🔗 请点击以下链接授权你的 GitHub 账号:\n\n${link}\n\n⏱️ 链接有效期 5 分钟`;
599
+ // 由工具直接发到用户,避免 AI 总结时把链接吞掉
600
+ try {
601
+ const targetAdapter = this.plugin.root?.inject(msg.$adapter);
602
+ if (targetAdapter instanceof Adapter) {
603
+ await targetAdapter.sendMessage({
604
+ context: msg.$adapter,
605
+ bot: msg.$bot,
606
+ id: msg.$channel.id,
607
+ type: msg.$channel.type,
608
+ content: fullText,
609
+ });
610
+ }
611
+ }
612
+ catch (e) {
613
+ this.plugin.logger.warn('github.bind 直发链接失败,将仅通过返回值返回链接', e);
614
+ }
615
+ return '已向当前会话发送绑定链接,请提醒用户查收并点击链接完成授权。';
616
+ }));
617
+ // --- GitHub OAuth Unbind ---
618
+ this.addTool(new ZhinTool('github_unbind')
619
+ .desc('解除 GitHub 账号绑定')
620
+ .keyword('github unbind', '解绑github', 'github 解绑')
621
+ .tag('github', 'oauth')
622
+ .execute(async (_args, ctx) => {
623
+ if (!ctx?.message)
624
+ return '❌ 无法获取消息上下文';
625
+ const msg = ctx.message;
626
+ const db = this.plugin.root?.inject('database');
627
+ const model = db?.models?.get('github_oauth_users');
628
+ if (!model)
629
+ return '❌ 数据库未就绪';
630
+ const [existing] = await model.select().where({ platform: msg.$adapter, platform_uid: msg.$sender.id });
631
+ if (!existing)
632
+ return '❌ 你还没有绑定 GitHub 账号';
633
+ await model.delete({ id: existing.id });
634
+ return `✅ 已解除 GitHub 账号绑定(${existing.github_login})`;
635
+ }));
636
+ // --- GitHub OAuth Whoami ---
637
+ this.addTool(new ZhinTool('github_whoami')
638
+ .desc('查看当前绑定的 GitHub 账号信息')
639
+ .keyword('github whoami', 'github 我是谁', 'github 账号')
640
+ .tag('github', 'oauth')
641
+ .execute(async (_args, ctx) => {
642
+ if (!ctx?.message)
643
+ return '❌ 无法获取消息上下文';
644
+ const msg = ctx.message;
645
+ const db = this.plugin.root?.inject('database');
646
+ const model = db?.models?.get('github_oauth_users');
647
+ if (!model)
648
+ return '❌ 数据库未就绪';
649
+ const [existing] = await model.select().where({ platform: msg.$adapter, platform_uid: msg.$sender.id });
650
+ if (!existing)
651
+ return '❌ 你还没有绑定 GitHub 账号\n💡 使用 /github bind 进行绑定';
652
+ const oauthClient = new GitHubOAuthClient(existing.access_token);
653
+ const userRes = await oauthClient.getUser();
654
+ if (!userRes.ok) {
655
+ return `⚠️ 已绑定 ${existing.github_login},但 token 可能已失效\n💡 请使用 /github bind 重新授权`;
656
+ }
657
+ const u = userRes.data;
658
+ return [
659
+ `🔗 GitHub 账号已绑定`,
660
+ `👤 ${u.login}${u.name ? ` (${u.name})` : ''}`,
661
+ `🔑 授权范围: ${existing.scope || 'N/A'}`,
662
+ `📅 绑定时间: ${new Date(existing.created_at).toLocaleDateString()}`,
663
+ ].join('\n');
664
+ }));
665
+ // --- GitHub Star (用户级操作) ---
666
+ this.addTool(new ZhinTool('github_star')
667
+ .desc('Star / Unstar 一个仓库(使用你的 GitHub 账号)')
668
+ .keyword('star', 'unstar', '收藏', '取消收藏')
669
+ .tag('github', 'user')
670
+ .param('repo', { type: 'string', description: 'owner/repo (必填)' }, true)
671
+ .param('unstar', { type: 'boolean', description: '设为 true 则取消 star' })
672
+ .execute(async (args, ctx) => {
673
+ if (!ctx?.message)
674
+ return '❌ 无法获取消息上下文';
675
+ const msg = ctx.message;
676
+ const repo = args.repo;
677
+ if (!repo.includes('/'))
678
+ return '❌ 格式应为 owner/repo';
679
+ const oauthClient = await this.getOAuthClient(msg.$adapter, msg.$sender.id);
680
+ if (!oauthClient) {
681
+ return '❌ 你还没有绑定 GitHub 账号,star 需要使用你自己的身份\n💡 使用 /github bind 绑定';
682
+ }
683
+ if (args.unstar) {
684
+ const r = await oauthClient.unstarRepo(repo);
685
+ return r.ok || r.status === 204 ? `✅ 已取消 star: ${repo}` : `❌ 操作失败: ${JSON.stringify(r.data)}`;
686
+ }
687
+ else {
688
+ const r = await oauthClient.starRepo(repo);
689
+ return r.ok || r.status === 204 ? `⭐ 已 star: ${repo}` : `❌ 操作失败: ${JSON.stringify(r.data)}`;
690
+ }
691
+ }));
692
+ // --- GitHub Fork (用户级操作) ---
693
+ this.addTool(new ZhinTool('github_fork')
694
+ .desc('Fork 一个仓库到你的 GitHub 账号')
695
+ .keyword('fork', '复刻')
696
+ .tag('github', 'user')
697
+ .param('repo', { type: 'string', description: 'owner/repo (必填)' }, true)
698
+ .execute(async (args, ctx) => {
699
+ if (!ctx?.message)
700
+ return '❌ 无法获取消息上下文';
701
+ const msg = ctx.message;
702
+ const repo = args.repo;
703
+ if (!repo.includes('/'))
704
+ return '❌ 格式应为 owner/repo';
705
+ const oauthClient = await this.getOAuthClient(msg.$adapter, msg.$sender.id);
706
+ if (!oauthClient) {
707
+ return '❌ 你还没有绑定 GitHub 账号,fork 需要使用你自己的身份\n💡 使用 /github bind 绑定';
708
+ }
709
+ const r = await oauthClient.forkRepo(repo);
710
+ return r.ok ? `🍴 已 fork: ${r.data.full_name}\n🔗 ${r.data.html_url}` : `❌ 操作失败: ${r.data?.message || JSON.stringify(r.data)}`;
711
+ }));
712
+ // --- Search ---
713
+ this.addTool(new ZhinTool('github_search')
714
+ .desc('GitHub 搜索:在 issues/repos/code 中搜索')
715
+ .keyword('search', '搜索', '查找', 'github search')
716
+ .tag('github', 'search')
717
+ .param('action', { type: 'string', description: 'issues|repos|code', enum: ['issues', 'repos', 'code'] }, true)
718
+ .param('query', { type: 'string', description: '搜索关键词' }, true)
719
+ .param('limit', { type: 'number', description: '返回数量,默认 10' })
720
+ .execute(async (args) => {
721
+ const api = this.getAPI();
722
+ if (!api)
723
+ return '❌ 没有可用的 GitHub bot';
724
+ const q = args.query;
725
+ const limit = args.limit || 10;
726
+ switch (args.action) {
727
+ case 'issues': {
728
+ const r = await api.searchIssues(q, limit);
729
+ if (!r.ok)
730
+ return `❌ ${JSON.stringify(r.data)}`;
731
+ if (!r.data.items.length)
732
+ return `📭 没有匹配的 Issue/PR`;
733
+ return `🔍 共 ${r.data.total_count} 条,显示前 ${r.data.items.length}:\n\n` +
734
+ r.data.items.map((i) => `${i.pull_request ? '🔀' : '🐛'} ${i.repository_url.replace('https://api.github.com/repos/', '')}#${i.number}\n ${i.title}\n 👤 ${i.user.login} | ${i.state}`).join('\n\n');
735
+ }
736
+ case 'repos': {
737
+ const r = await api.searchRepos(q, limit);
738
+ if (!r.ok)
739
+ return `❌ ${JSON.stringify(r.data)}`;
740
+ if (!r.data.items.length)
741
+ return `📭 没有匹配的仓库`;
742
+ return `🔍 共 ${r.data.total_count} 条,显示前 ${r.data.items.length}:\n\n` +
743
+ r.data.items.map((repo) => `📦 ${repo.full_name}${repo.private ? ' 🔒' : ''}\n ${repo.description || '(无描述)'}\n ⭐ ${repo.stargazers_count} | 🍴 ${repo.forks_count} | 📝 ${repo.language || '?'}`).join('\n\n');
744
+ }
745
+ case 'code': {
746
+ const r = await api.searchCode(q, limit);
747
+ if (!r.ok)
748
+ return `❌ ${JSON.stringify(r.data)}`;
749
+ if (!r.data.items.length)
750
+ return `📭 没有匹配的代码`;
751
+ return `🔍 共 ${r.data.total_count} 条,显示前 ${r.data.items.length}:\n\n` +
752
+ r.data.items.map((c) => `📄 ${c.repository.full_name}/${c.path}\n 🔗 ${c.html_url}`).join('\n\n');
753
+ }
754
+ default: return `❌ 未知搜索类型: ${args.action}`;
755
+ }
756
+ }));
757
+ // --- Label ---
758
+ this.addTool(new ZhinTool('github_label')
759
+ .desc('GitHub 标签管理:查看/添加/移除 Issue/PR 标签')
760
+ .keyword('label', '标签', 'tag')
761
+ .tag('github', 'label')
762
+ .param('action', { type: 'string', description: 'list|add|remove', enum: ['list', 'add', 'remove'] }, true)
763
+ .param('repo', { type: 'string', description: 'owner/repo (必填)' }, true)
764
+ .param('number', { type: 'number', description: 'Issue/PR 编号 (add/remove 必填)' })
765
+ .param('labels', { type: 'string', description: '标签名,逗号分隔 (add/remove)' })
766
+ .execute(async (args) => {
767
+ const api = this.getAPI();
768
+ if (!api)
769
+ return '❌ 没有可用的 GitHub bot';
770
+ const repo = args.repo;
771
+ switch (args.action) {
772
+ case 'list': {
773
+ const r = await api.listLabels(repo);
774
+ if (!r.ok)
775
+ return `❌ ${JSON.stringify(r.data)}`;
776
+ if (!r.data.length)
777
+ return `📭 仓库没有标签`;
778
+ return `🏷️ ${repo} 标签 (${r.data.length}):\n` +
779
+ r.data.map((l) => ` • ${l.name}${l.description ? ` — ${l.description}` : ''}`).join('\n');
780
+ }
781
+ case 'add': {
782
+ if (!args.number)
783
+ return '❌ 请提供 Issue/PR 编号';
784
+ if (!args.labels)
785
+ return '❌ 请提供标签名';
786
+ const labels = args.labels.split(',').map(s => s.trim());
787
+ const r = await api.addLabels(repo, args.number, labels);
788
+ return r.ok ? `✅ 已添加标签: ${labels.join(', ')}` : `❌ ${JSON.stringify(r.data)}`;
789
+ }
790
+ case 'remove': {
791
+ if (!args.number)
792
+ return '❌ 请提供 Issue/PR 编号';
793
+ if (!args.labels)
794
+ return '❌ 请提供要移除的标签名';
795
+ const labels = args.labels.split(',').map(s => s.trim());
796
+ const results = [];
797
+ for (const label of labels) {
798
+ const r = await api.removeLabel(repo, args.number, label);
799
+ results.push(r.ok ? `✅ ${label}` : `❌ ${label}: ${r.data?.message || 'failed'}`);
800
+ }
801
+ return results.join('\n');
802
+ }
803
+ default: return `❌ 未知操作: ${args.action}`;
804
+ }
805
+ }));
806
+ // --- Assign ---
807
+ this.addTool(new ZhinTool('github_assign')
808
+ .desc('GitHub 指派管理:给 Issue/PR 添加/移除指派人')
809
+ .keyword('assign', '指派', '分配')
810
+ .tag('github', 'assign')
811
+ .param('action', { type: 'string', description: 'add|remove', enum: ['add', 'remove'] }, true)
812
+ .param('repo', { type: 'string', description: 'owner/repo (必填)' }, true)
813
+ .param('number', { type: 'number', description: 'Issue/PR 编号 (必填)' }, true)
814
+ .param('assignees', { type: 'string', description: '用户名,逗号分隔 (必填)' }, true)
815
+ .execute(async (args) => {
816
+ const api = this.getAPI();
817
+ if (!api)
818
+ return '❌ 没有可用的 GitHub bot';
819
+ const repo = args.repo;
820
+ const num = args.number;
821
+ const assignees = args.assignees.split(',').map(s => s.trim());
822
+ if (args.action === 'add') {
823
+ const r = await api.addAssignees(repo, num, assignees);
824
+ return r.ok ? `✅ 已指派: ${assignees.join(', ')}` : `❌ ${r.data?.message || JSON.stringify(r.data)}`;
825
+ }
826
+ else {
827
+ const r = await api.removeAssignees(repo, num, assignees);
828
+ return r.ok ? `✅ 已移除指派: ${assignees.join(', ')}` : `❌ ${r.data?.message || JSON.stringify(r.data)}`;
829
+ }
830
+ }));
831
+ // --- File ---
832
+ this.addTool(new ZhinTool('github_file')
833
+ .desc('读取 GitHub 仓库中的文件内容')
834
+ .keyword('file', '文件', '查看文件', '读取文件', 'cat')
835
+ .tag('github', 'file')
836
+ .param('repo', { type: 'string', description: 'owner/repo (必填)' }, true)
837
+ .param('path', { type: 'string', description: '文件路径 (必填)' }, true)
838
+ .param('ref', { type: 'string', description: '分支/tag/commit SHA (可选,默认主分支)' })
839
+ .execute(async (args) => {
840
+ const api = this.getAPI();
841
+ if (!api)
842
+ return '❌ 没有可用的 GitHub bot';
843
+ const r = await api.getFileContent(args.repo, args.path, args.ref);
844
+ if (!r.ok)
845
+ return `❌ ${r.data?.message || JSON.stringify(r.data)}`;
846
+ if (Array.isArray(r.data)) {
847
+ return `📂 ${args.path} (目录,${r.data.length} 项):\n` +
848
+ r.data.map((f) => ` ${f.type === 'dir' ? '📁' : '📄'} ${f.name}`).join('\n');
849
+ }
850
+ if (r.data.type === 'file' && r.data.content) {
851
+ const decoded = Buffer.from(r.data.content, 'base64').toString('utf-8');
852
+ const maxLen = 3000;
853
+ const truncated = decoded.length > maxLen;
854
+ return `📄 ${r.data.path} (${r.data.size} bytes)\n\n${decoded.slice(0, maxLen)}${truncated ? `\n\n... (截断,共 ${decoded.length} 字符)` : ''}`;
855
+ }
856
+ return `📄 ${r.data.path} — ${r.data.type} (${r.data.size} bytes)\n🔗 ${r.data.html_url}`;
857
+ }));
858
+ // --- Commits ---
859
+ this.addTool(new ZhinTool('github_commits')
860
+ .desc('GitHub 提交查询:列出提交记录或对比两个分支')
861
+ .keyword('commit', '提交', '历史', 'log', 'compare', '对比')
862
+ .tag('github', 'commits')
863
+ .param('action', { type: 'string', description: 'list|compare', enum: ['list', 'compare'] }, true)
864
+ .param('repo', { type: 'string', description: 'owner/repo (必填)' }, true)
865
+ .param('sha', { type: 'string', description: '分支/SHA (list)' })
866
+ .param('path', { type: 'string', description: '按文件路径过滤 (list)' })
867
+ .param('base', { type: 'string', description: '基准分支 (compare)' })
868
+ .param('head', { type: 'string', description: '目标分支 (compare)' })
869
+ .param('limit', { type: 'number', description: '返回数量,默认 10' })
870
+ .execute(async (args) => {
871
+ const api = this.getAPI();
872
+ if (!api)
873
+ return '❌ 没有可用的 GitHub bot';
874
+ const repo = args.repo;
875
+ if (args.action === 'list') {
876
+ const r = await api.listCommits(repo, args.sha, args.path, args.limit || 10);
877
+ if (!r.ok)
878
+ return `❌ ${JSON.stringify(r.data)}`;
879
+ if (!r.data.length)
880
+ return '📭 没有找到提交记录';
881
+ return r.data.map((c) => `• ${c.sha.substring(0, 7)} ${c.commit.message.split('\n')[0]}\n 👤 ${c.commit.author?.name || '?'} | 📅 ${c.commit.author?.date?.split('T')[0] || '?'}`).join('\n\n');
882
+ }
883
+ else {
884
+ if (!args.base || !args.head)
885
+ return '❌ compare 需要 base 和 head 参数';
886
+ const r = await api.compareCommits(repo, args.base, args.head);
887
+ if (!r.ok)
888
+ return `❌ ${r.data?.message || JSON.stringify(r.data)}`;
889
+ const d = r.data;
890
+ return [
891
+ `🔀 ${args.base} ← ${args.head}`,
892
+ `📊 ${d.status} | ${d.ahead_by} ahead, ${d.behind_by} behind`,
893
+ `📝 ${d.total_commits} commits | ${d.files?.length || 0} files changed`,
894
+ d.commits?.length ? '\n最近提交:\n' + d.commits.slice(0, 5).map((c) => ` • ${c.sha.substring(0, 7)} ${c.commit.message.split('\n')[0]}`).join('\n') : '',
895
+ ].filter(Boolean).join('\n');
896
+ }
897
+ }));
898
+ // --- Edit (Issue/PR) ---
899
+ this.addTool(new ZhinTool('github_edit')
900
+ .desc('编辑 GitHub Issue 或 PR 的标题、正文、状态')
901
+ .keyword('edit', '编辑', '修改', 'update', '更新')
902
+ .tag('github', 'edit')
903
+ .param('type', { type: 'string', description: 'issue|pr', enum: ['issue', 'pr'] }, true)
904
+ .param('repo', { type: 'string', description: 'owner/repo (必填)' }, true)
905
+ .param('number', { type: 'number', description: 'Issue/PR 编号 (必填)' }, true)
906
+ .param('title', { type: 'string', description: '新标题' })
907
+ .param('body', { type: 'string', description: '新正文' })
908
+ .param('state', { type: 'string', description: 'open|closed' })
909
+ .execute(async (args) => {
910
+ const api = this.getAPI();
911
+ if (!api)
912
+ return '❌ 没有可用的 GitHub bot';
913
+ const repo = args.repo;
914
+ const num = args.number;
915
+ const data = {};
916
+ if (args.title)
917
+ data.title = args.title;
918
+ if (args.body)
919
+ data.body = args.body;
920
+ if (args.state)
921
+ data.state = args.state;
922
+ if (!Object.keys(data).length)
923
+ return '❌ 请至少提供一个要修改的字段 (title/body/state)';
924
+ const r = args.type === 'pr'
925
+ ? await api.updatePR(repo, num, data)
926
+ : await api.updateIssue(repo, num, data);
927
+ if (!r.ok)
928
+ return `❌ ${r.data?.message || JSON.stringify(r.data)}`;
929
+ return `✅ ${args.type === 'pr' ? 'PR' : 'Issue'} #${num} 已更新\n🔗 ${r.data.html_url}`;
930
+ }));
931
+ this.plugin.logger.debug('GitHub 工具已注册: pr, issue, repo, search, label, assign, file, commits, edit, subscribe, unsubscribe, subscriptions, bind, unbind, whoami, star, fork');
932
+ }
933
+ // ── 通知推送 ───────────────────────────────────────────────────────
934
+ async dispatchNotification(eventName, payload) {
935
+ let eventType = null;
936
+ switch (eventName) {
937
+ case 'push':
938
+ eventType = 'push';
939
+ break;
940
+ case 'issues':
941
+ eventType = 'issue';
942
+ break;
943
+ case 'star':
944
+ eventType = payload.action === 'deleted' ? 'unstar' : 'star';
945
+ break;
946
+ case 'fork':
947
+ eventType = 'fork';
948
+ break;
949
+ case 'pull_request':
950
+ eventType = 'pull_request';
951
+ break;
952
+ }
953
+ if (!eventType) {
954
+ this.plugin.logger.debug(`dispatchNotification: 未知事件 ${eventName},跳过`);
955
+ return;
956
+ }
957
+ const repo = payload.repository.full_name;
958
+ const db = this.plugin.root?.inject('database');
959
+ const model = db?.models?.get('github_subscriptions');
960
+ if (!model) {
961
+ this.plugin.logger.warn('dispatchNotification: 数据库模型 github_subscriptions 未就绪');
962
+ return;
963
+ }
964
+ const subs = await model.select().where({ repo });
965
+ this.plugin.logger.debug(`dispatchNotification: ${repo} ${eventName}(${eventType}) — 找到 ${subs?.length || 0} 条订阅`);
966
+ if (!subs?.length)
967
+ return;
968
+ const text = formatNotification(eventName, payload);
969
+ for (const sub of subs) {
970
+ const s = sub;
971
+ const events = safeParseEvents(s.events);
972
+ if (!events.includes(eventType)) {
973
+ this.plugin.logger.debug(`dispatchNotification: ${s.adapter}:${s.target_id} 未订阅 ${eventType},跳过`);
974
+ continue;
975
+ }
976
+ try {
977
+ const adapter = this.plugin.root?.inject(s.adapter);
978
+ if (!adapter?.sendMessage) {
979
+ this.plugin.logger.warn(`dispatchNotification: 适配器 ${s.adapter} 不存在或无 sendMessage 方法`);
980
+ continue;
981
+ }
982
+ this.plugin.logger.info(`dispatchNotification: 推送 ${eventType} → ${s.adapter}:${s.bot}:${s.target_id}`);
983
+ await adapter.sendMessage({ context: s.adapter, bot: s.bot, id: s.target_id, type: s.target_type, content: text });
984
+ }
985
+ catch (e) {
986
+ this.plugin.logger.error(`通知推送失败 → ${s.adapter}:${s.target_id}`, e);
987
+ }
988
+ }
989
+ }
990
+ }
991
+ //# sourceMappingURL=adapter.js.map