@dsh-plugin/dsh-synapse 0.1.0-dev.41044228975 → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +661 -661
- package/README.md +0 -14
- package/package.json +1 -1
- package/vendor/gugle-chat-bot/LICENSE +661 -661
- package/vendor/gugle-chat-bot/package.json +57 -57
- package/vendor/gugle-chat-bot/src/allow-rule.yml +9345 -9345
- package/vendor/gugle-chat-bot/src/api.json +4284 -4284
- package/vendor/gugle-chat-bot/src/command/index.ts +141 -141
- package/vendor/gugle-chat-bot/src/config/features.ts +547 -547
- package/vendor/gugle-chat-bot/src/config/index.ts +184 -184
- package/vendor/gugle-chat-bot/src/config/manager.ts +356 -356
- package/vendor/gugle-chat-bot/src/constants/index.ts +13 -13
- package/vendor/gugle-chat-bot/src/custom.ts +548 -548
- package/vendor/gugle-chat-bot/src/event/index.ts +118 -118
- package/vendor/gugle-chat-bot/src/features/admin/index.ts +414 -414
- package/vendor/gugle-chat-bot/src/features/bili/image/impl/video.ts +39 -39
- package/vendor/gugle-chat-bot/src/features/bili/image/index.ts +37 -37
- package/vendor/gugle-chat-bot/src/features/bili/index.ts +65 -65
- package/vendor/gugle-chat-bot/src/features/bili/template/style/video.css +97 -97
- package/vendor/gugle-chat-bot/src/features/bili/template/video.html +56 -56
- package/vendor/gugle-chat-bot/src/features/discord-bridge/index.ts +1128 -1128
- package/vendor/gugle-chat-bot/src/features/discord-bridge/markdown.ts +105 -105
- package/vendor/gugle-chat-bot/src/features/discord-bridge/slash.ts +355 -355
- package/vendor/gugle-chat-bot/src/features/discord-bridge/source.ts +96 -96
- package/vendor/gugle-chat-bot/src/features/discord-bridge/template/message.html +14 -14
- package/vendor/gugle-chat-bot/src/features/discord-bridge/template/style/message.css +144 -144
- package/vendor/gugle-chat-bot/src/features/github/api.ts +93 -93
- package/vendor/gugle-chat-bot/src/features/github/binding.ts +161 -161
- package/vendor/gugle-chat-bot/src/features/github/image/impl/issue.ts +132 -132
- package/vendor/gugle-chat-bot/src/features/github/image/impl/pr.ts +144 -144
- package/vendor/gugle-chat-bot/src/features/github/image/impl/release.ts +160 -160
- package/vendor/gugle-chat-bot/src/features/github/image/index.ts +310 -310
- package/vendor/gugle-chat-bot/src/features/github/index.ts +302 -302
- package/vendor/gugle-chat-bot/src/features/github/template/issue.html +118 -118
- package/vendor/gugle-chat-bot/src/features/github/template/pull_request.html +125 -125
- package/vendor/gugle-chat-bot/src/features/github/template/release.html +53 -53
- package/vendor/gugle-chat-bot/src/features/github/template/style/issue.css +468 -468
- package/vendor/gugle-chat-bot/src/features/github/template/style/pull_request.css +468 -468
- package/vendor/gugle-chat-bot/src/features/github/template/style/release.css +412 -412
- package/vendor/gugle-chat-bot/src/features/hermes/client.ts +184 -184
- package/vendor/gugle-chat-bot/src/features/hermes/config.ts +210 -210
- package/vendor/gugle-chat-bot/src/features/hermes/context.ts +67 -67
- package/vendor/gugle-chat-bot/src/features/hermes/history.ts +86 -86
- package/vendor/gugle-chat-bot/src/features/hermes/index.ts +1533 -1533
- package/vendor/gugle-chat-bot/src/features/hermes/message.ts +319 -319
- package/vendor/gugle-chat-bot/src/features/hermes/reaction.ts +181 -181
- package/vendor/gugle-chat-bot/src/features/hermes/render.ts +118 -118
- package/vendor/gugle-chat-bot/src/features/hermes/reply-units.ts +331 -331
- package/vendor/gugle-chat-bot/src/features/hermes/skills.ts +406 -406
- package/vendor/gugle-chat-bot/src/features/hermes/template/approval.html +30 -30
- package/vendor/gugle-chat-bot/src/features/hermes/template/progress.html +21 -21
- package/vendor/gugle-chat-bot/src/features/hermes/template/style/approval.css +81 -81
- package/vendor/gugle-chat-bot/src/features/hermes/template/style/progress.css +120 -120
- package/vendor/gugle-chat-bot/src/features/hermes/trigger.ts +86 -86
- package/vendor/gugle-chat-bot/src/features/hermes/types.ts +327 -327
- package/vendor/gugle-chat-bot/src/features/management/index.ts +156 -156
- package/vendor/gugle-chat-bot/src/features/minecraft/index.ts +127 -127
- package/vendor/gugle-chat-bot/src/features/modrinth/index.ts +71 -71
- package/vendor/gugle-chat-bot/src/features/parentheses/index.ts +117 -117
- package/vendor/gugle-chat-bot/src/features/peak-valley-timer/index.ts +451 -451
- package/vendor/gugle-chat-bot/src/features/poke/index.ts +24 -24
- package/vendor/gugle-chat-bot/src/features/version-tracker/index.ts +77 -77
- package/vendor/gugle-chat-bot/src/features/welcome/index.ts +57 -57
- package/vendor/gugle-chat-bot/src/github-ip.json +5814 -5814
- package/vendor/gugle-chat-bot/src/image/index.ts +278 -278
- package/vendor/gugle-chat-bot/src/index.ts +695 -695
- package/vendor/gugle-chat-bot/src/logger/index.ts +54 -54
- package/vendor/gugle-chat-bot/src/result.json +359 -359
- package/vendor/gugle-chat-bot/src/type/bili.d.ts +825 -825
- package/vendor/gugle-chat-bot/src/type/event.d.ts +146 -146
- package/vendor/gugle-chat-bot/src/type/github.d.ts +446 -446
- package/vendor/gugle-chat-bot/src/type/index.d.ts +254 -254
- package/vendor/gugle-chat-bot/tsconfig.json +46 -46
- package/scripts/dump-npm-latest.mjs +0 -30
|
@@ -1,302 +1,302 @@
|
|
|
1
|
-
import { EventManager } from 'gugle-event';
|
|
2
|
-
import { Logger } from 'winston';
|
|
3
|
-
import http from 'node:http';
|
|
4
|
-
import { QQBot } from '@/index';
|
|
5
|
-
import { AllIssueEvent, AllPullRequestEvent, AllReleaseEvent, Issue, PullRequest } from '@/type/github';
|
|
6
|
-
import { GroupMessageWSMSG, Message, SentMessage, TextMessage } from '@/type';
|
|
7
|
-
import { GitHubImage } from '@/features/github/image';
|
|
8
|
-
import { botConfig } from '@/config';
|
|
9
|
-
import { getGithubSubscribers, isGithubEnabledGroup } from '@/config/features';
|
|
10
|
-
import { GitHubBindingManager } from '@/features/github/binding';
|
|
11
|
-
import { fetchGithubApi } from '@/features/github/api';
|
|
12
|
-
import { shouldPushRelease } from '@/features/github/image/impl/release';
|
|
13
|
-
import axios, { AxiosInstance } from 'axios';
|
|
14
|
-
|
|
15
|
-
/** 一条 Issue/PR 引用(`owner/repo#123`,repo 省略时为默认仓库) */
|
|
16
|
-
export interface IssueReference {
|
|
17
|
-
repository: string;
|
|
18
|
-
number: number;
|
|
19
|
-
}
|
|
20
|
-
|
|
21
|
-
export class Github {
|
|
22
|
-
private readonly bot: QQBot;
|
|
23
|
-
private readonly logger: Logger;
|
|
24
|
-
private readonly eventManager: EventManager;
|
|
25
|
-
private httpServer: http.Server<typeof http.IncomingMessage, typeof http.ServerResponse>;
|
|
26
|
-
|
|
27
|
-
public static readonly axiosInstance: AxiosInstance = axios.create({
|
|
28
|
-
timeout: 15000,
|
|
29
|
-
baseURL: botConfig.httpUrl,
|
|
30
|
-
headers: {
|
|
31
|
-
'Content-Type': 'application/json',
|
|
32
|
-
'User-Agent': botConfig.userAgent
|
|
33
|
-
}
|
|
34
|
-
});
|
|
35
|
-
|
|
36
|
-
/**
|
|
37
|
-
* 请求 GitHub API(经 ghapi.anvilcraft.dev 反代),供 processMessage 使用。
|
|
38
|
-
* @param apiPath GitHub API 路径(不含域名),如 /repos/owner/repo/issues/123
|
|
39
|
-
*/
|
|
40
|
-
public static fetchGithubApi<T>(bot: QQBot, apiPath: string): Promise<T> {
|
|
41
|
-
return fetchGithubApi<T>(apiPath, bot.logger);
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
public constructor(bot: QQBot) {
|
|
45
|
-
this.bot = bot;
|
|
46
|
-
this.logger = bot.logger!;
|
|
47
|
-
this.eventManager = new EventManager();
|
|
48
|
-
this.eventManager.listen('github-issues', this.listenIssueEvent.bind(this));
|
|
49
|
-
this.eventManager.listen('github-pull_request', this.listenPullRequestEvent.bind(this));
|
|
50
|
-
this.eventManager.listen('github-release', this.listenReleaseEvent.bind(this));
|
|
51
|
-
this.httpServer = http.createServer((req, res) => {
|
|
52
|
-
if (req.method === 'POST') {
|
|
53
|
-
let body = '';
|
|
54
|
-
req.on('data', chunk => {
|
|
55
|
-
body += chunk.toString(); // 将数据块拼接成字符串
|
|
56
|
-
});
|
|
57
|
-
req.on('end', () => {
|
|
58
|
-
try {
|
|
59
|
-
const event = (req.headers['x-github-event'] as string) || '';
|
|
60
|
-
body = JSON.parse(body);
|
|
61
|
-
bot.logger?.debug(`Receive Github Event: github-${event}`);
|
|
62
|
-
this.post(`github-${event}`, bot, body);
|
|
63
|
-
res.statusCode = 202;
|
|
64
|
-
res.setHeader('Content-Type', 'text/plain');
|
|
65
|
-
res.end('Accepted');
|
|
66
|
-
} catch (e) {
|
|
67
|
-
bot.logger?.error(e);
|
|
68
|
-
}
|
|
69
|
-
});
|
|
70
|
-
return;
|
|
71
|
-
}
|
|
72
|
-
res.statusCode = 202;
|
|
73
|
-
res.setHeader('Content-Type', 'text/plain');
|
|
74
|
-
res.end('Accepted');
|
|
75
|
-
});
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
private listenIssueEvent(bot: QQBot, msg: AllIssueEvent) {
|
|
79
|
-
let promise: Promise<string> | undefined = undefined;
|
|
80
|
-
if (msg.action === 'opened' || msg.action === 'reopened') {
|
|
81
|
-
promise = GitHubImage.issuesOpened(msg, this.logger);
|
|
82
|
-
} else if (msg.action === 'closed') {
|
|
83
|
-
promise = GitHubImage.issuesClosed(msg, this.logger);
|
|
84
|
-
}
|
|
85
|
-
const repository = msg.repository.full_name;
|
|
86
|
-
this.sendGeneratedImage(bot, repository, 'issue', promise);
|
|
87
|
-
}
|
|
88
|
-
|
|
89
|
-
private listenPullRequestEvent(bot: QQBot, msg: AllPullRequestEvent) {
|
|
90
|
-
let promise: Promise<string> | undefined = undefined;
|
|
91
|
-
if (msg.action === 'opened' || msg.action === 'reopened') {
|
|
92
|
-
promise = GitHubImage.prOpened(msg, this.logger);
|
|
93
|
-
} else if (msg.action === 'closed') {
|
|
94
|
-
promise = GitHubImage.prClosed(msg, this.logger);
|
|
95
|
-
}
|
|
96
|
-
const repository = msg.repository.full_name;
|
|
97
|
-
this.sendGeneratedImage(bot, repository, 'pull request', promise);
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
/**
|
|
101
|
-
* 处理仓库发布 release 的 webhook 事件。
|
|
102
|
-
*
|
|
103
|
-
* GitHub 对同一次发布会投递多个 action(实测同一 release id 会在数秒内先后收到
|
|
104
|
-
* created + released、published + created + prereleased 等组合,且组合不固定),
|
|
105
|
-
* 因此按 action 过滤并不能避免重复,这里以 release id 在时间窗口内去重。
|
|
106
|
-
*/
|
|
107
|
-
private listenReleaseEvent(bot: QQBot, msg: AllReleaseEvent) {
|
|
108
|
-
const decision = shouldPushRelease(msg);
|
|
109
|
-
if (!decision.push) {
|
|
110
|
-
this.logger?.debug(`Skip release event ${msg.release?.id} (${msg.action}): ${decision.reason}`);
|
|
111
|
-
return;
|
|
112
|
-
}
|
|
113
|
-
const repository = msg.repository.full_name;
|
|
114
|
-
this.sendGeneratedImage(bot, repository, 'release', GitHubImage.releasePublished(msg, this.logger));
|
|
115
|
-
}
|
|
116
|
-
|
|
117
|
-
/**
|
|
118
|
-
* 把生成的图片推送给订阅了该仓库的群(仓库级路由)。
|
|
119
|
-
* 只发给 github.json 中 repository → 订阅群列表对应的群。
|
|
120
|
-
*/
|
|
121
|
-
private sendGeneratedImage(bot: QQBot, repository: string, type: string, result: Promise<string> | undefined = undefined) {
|
|
122
|
-
const subscribers = getGithubSubscribers(repository);
|
|
123
|
-
if (subscribers.length === 0) {
|
|
124
|
-
this.logger?.debug(`No subscribers for repository ${repository}, skip ${type} message...`);
|
|
125
|
-
return;
|
|
126
|
-
}
|
|
127
|
-
result
|
|
128
|
-
?.then(base64 => {
|
|
129
|
-
const msg: SentMessage = [
|
|
130
|
-
{
|
|
131
|
-
type: 'image',
|
|
132
|
-
data: {
|
|
133
|
-
file: `data:image/png;base64, ${base64}`
|
|
134
|
-
}
|
|
135
|
-
}
|
|
136
|
-
];
|
|
137
|
-
subscribers.forEach(group => {
|
|
138
|
-
// fromWebhook:标记为 webhook 推送,互通桥据此不转发到 need_cmd/need_reply 频道
|
|
139
|
-
bot.sendGroupMsg(group, msg, { fromWebhook: true });
|
|
140
|
-
});
|
|
141
|
-
this.logger?.debug(`Sent process ${type} message of ${repository} to ${subscribers.length} group(s)...`);
|
|
142
|
-
})
|
|
143
|
-
.catch(e => {
|
|
144
|
-
this.logger?.error(e);
|
|
145
|
-
});
|
|
146
|
-
}
|
|
147
|
-
|
|
148
|
-
public post(event: string, ...args: any): Promise<any[]> {
|
|
149
|
-
return this.eventManager.post(event, ...args);
|
|
150
|
-
}
|
|
151
|
-
|
|
152
|
-
public start(port: number): void {
|
|
153
|
-
const self = this;
|
|
154
|
-
this.httpServer.listen(port, () => {
|
|
155
|
-
self.logger?.info(`http server listen on port ${port}`);
|
|
156
|
-
});
|
|
157
|
-
}
|
|
158
|
-
|
|
159
|
-
/**
|
|
160
|
-
* 判断仓库是否允许被访问:owner 已绑定 GitHub 账号,或命中允许列表配置。
|
|
161
|
-
* 消息查询与订阅共用该判定,保证「能查」与「能订阅」口径一致。
|
|
162
|
-
*/
|
|
163
|
-
public static isAllowedRepository(repository: string): boolean {
|
|
164
|
-
const [owner] = repository.split('/');
|
|
165
|
-
if (owner && GitHubBindingManager.isBoundUsername(owner)) return true;
|
|
166
|
-
|
|
167
|
-
return botConfig.githubAllowedRepositories.some(pattern => {
|
|
168
|
-
if (pattern.endsWith('/*')) return repository.startsWith(`${pattern.slice(0, -2)}/`);
|
|
169
|
-
return repository === pattern;
|
|
170
|
-
});
|
|
171
|
-
}
|
|
172
|
-
|
|
173
|
-
/** 单条消息最多解析的 Issue/PR 引用数(防止刷屏,超出部分忽略) */
|
|
174
|
-
public static readonly MAX_REFERENCES_PER_MESSAGE = 10;
|
|
175
|
-
|
|
176
|
-
/**
|
|
177
|
-
* 解析文本中的全部 Issue/PR 引用(`owner/repo#123` 或 `#123`)。
|
|
178
|
-
* 按出现顺序去重(`repository#number`),最多返回 MAX_REFERENCES_PER_MESSAGE 条。
|
|
179
|
-
*/
|
|
180
|
-
public static parseReferences(text: string, defaultRepository: string = 'Anvil-Dev/AnvilCraft'): IssueReference[] {
|
|
181
|
-
const pattern = /(?:([A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+))?#(\d+)/g;
|
|
182
|
-
const references: IssueReference[] = [];
|
|
183
|
-
const seen = new Set<string>();
|
|
184
|
-
let match: RegExpExecArray | null;
|
|
185
|
-
while ((match = pattern.exec(text)) !== null) {
|
|
186
|
-
const repository = match[1] || defaultRepository;
|
|
187
|
-
const number = Number.parseInt(match[2]!, 10);
|
|
188
|
-
const key = `${repository}#${number}`;
|
|
189
|
-
if (seen.has(key)) continue;
|
|
190
|
-
seen.add(key);
|
|
191
|
-
references.push({ repository, number });
|
|
192
|
-
if (references.length >= Github.MAX_REFERENCES_PER_MESSAGE) break;
|
|
193
|
-
}
|
|
194
|
-
return references;
|
|
195
|
-
}
|
|
196
|
-
|
|
197
|
-
/**
|
|
198
|
-
* 渲染单个 Issue/PR 引用为 QQ 消息段:成功为图片段,失败为文本说明段。
|
|
199
|
-
* 仓库不在允许列表时返回提示文本(不发请求)。
|
|
200
|
-
*/
|
|
201
|
-
public static async renderReference(bot: QQBot, reference: IssueReference): Promise<Message> {
|
|
202
|
-
const { repository, number } = reference;
|
|
203
|
-
if (!this.isAllowedRepository(repository)) {
|
|
204
|
-
return {
|
|
205
|
-
type: 'text',
|
|
206
|
-
data: {
|
|
207
|
-
text: `仓库 ${repository} 不在允许访问的仓库列表中`
|
|
208
|
-
}
|
|
209
|
-
};
|
|
210
|
-
}
|
|
211
|
-
|
|
212
|
-
const issueApiPath = `/repos/${repository}/issues/${number}`;
|
|
213
|
-
const pullApiPath = `/repos/${repository}/pulls/${number}`;
|
|
214
|
-
|
|
215
|
-
// 经 ghapi 反代获取 issue 数据
|
|
216
|
-
let issueData: Issue & { pull_request?: unknown };
|
|
217
|
-
try {
|
|
218
|
-
issueData = await Github.fetchGithubApi<Issue & { pull_request?: unknown }>(bot, issueApiPath);
|
|
219
|
-
} catch (e) {
|
|
220
|
-
bot.logger?.error(e);
|
|
221
|
-
return {
|
|
222
|
-
type: 'text',
|
|
223
|
-
data: {
|
|
224
|
-
text: `请求失败,原因:${(e as Error)?.message ?? e}`
|
|
225
|
-
}
|
|
226
|
-
};
|
|
227
|
-
}
|
|
228
|
-
|
|
229
|
-
try {
|
|
230
|
-
if (issueData.pull_request) {
|
|
231
|
-
// 如果是 PR,经 ghapi 反代获取 PR 数据
|
|
232
|
-
const prData = await Github.fetchGithubApi<PullRequest>(bot, pullApiPath);
|
|
233
|
-
const imageData = await GitHubImage.prHandler(prData, bot.logger);
|
|
234
|
-
return {
|
|
235
|
-
type: 'image',
|
|
236
|
-
data: {
|
|
237
|
-
file: `data:image/png;base64, ${imageData}`
|
|
238
|
-
}
|
|
239
|
-
};
|
|
240
|
-
}
|
|
241
|
-
// 处理 issue
|
|
242
|
-
const imageData = await GitHubImage.issuesHandler(issueData as Issue, bot.logger);
|
|
243
|
-
return {
|
|
244
|
-
type: 'image',
|
|
245
|
-
data: {
|
|
246
|
-
file: `data:image/png;base64, ${imageData}`
|
|
247
|
-
}
|
|
248
|
-
};
|
|
249
|
-
} catch (e) {
|
|
250
|
-
bot.logger?.error(e);
|
|
251
|
-
return {
|
|
252
|
-
type: 'text',
|
|
253
|
-
data: {
|
|
254
|
-
text: `图片处理失败,原因:${(e as Error)?.message ?? e}`
|
|
255
|
-
}
|
|
256
|
-
};
|
|
257
|
-
}
|
|
258
|
-
}
|
|
259
|
-
|
|
260
|
-
/**
|
|
261
|
-
* QQ 群消息入口:解析消息中的全部 Issue/PR 引用并逐个渲染。
|
|
262
|
-
* 第一个引用的结果并入 sentMessage(随调用方的回复消息一起发出);
|
|
263
|
-
* 其余引用各自作为独立群消息发送(用户要求多编号时分多条消息发送)。
|
|
264
|
-
* 渲染串行执行以保证消息顺序与编号顺序一致。
|
|
265
|
-
*/
|
|
266
|
-
public static async processMessage(bot: QQBot, msg: GroupMessageWSMSG, sentMessage: Message[]): Promise<void> {
|
|
267
|
-
if (!isGithubEnabledGroup(msg.group_id)) return;
|
|
268
|
-
|
|
269
|
-
const receivedMessage: TextMessage[] = [];
|
|
270
|
-
msg.message.forEach(message => {
|
|
271
|
-
if (message.type != 'text') return;
|
|
272
|
-
receivedMessage.push(message);
|
|
273
|
-
});
|
|
274
|
-
|
|
275
|
-
const strMsg = receivedMessage.map(msg => msg.data.text).join(' ');
|
|
276
|
-
const references = Github.parseReferences(strMsg);
|
|
277
|
-
if (references.length === 0) return;
|
|
278
|
-
|
|
279
|
-
for (let index = 0; index < references.length; index++) {
|
|
280
|
-
const rendered = await Github.renderReference(bot, references[index]!);
|
|
281
|
-
if (index === 0) {
|
|
282
|
-
sentMessage.push(rendered);
|
|
283
|
-
} else {
|
|
284
|
-
await bot.sendGroupMsg(msg.group_id, [rendered]);
|
|
285
|
-
}
|
|
286
|
-
}
|
|
287
|
-
}
|
|
288
|
-
|
|
289
|
-
/**
|
|
290
|
-
* Discord 侧入口:解析文本中的全部 Issue/PR 引用并渲染为消息段列表(由调用方发送)。
|
|
291
|
-
* groupId 为 Discord 频道桥接的 QQ 群号,用于复用「该群是否启用 github 功能」的判定。
|
|
292
|
-
*/
|
|
293
|
-
public static async processDiscordMessage(bot: QQBot, text: string, groupId?: number): Promise<Message[]> {
|
|
294
|
-
if (groupId !== undefined && !isGithubEnabledGroup(groupId)) return [];
|
|
295
|
-
const references = Github.parseReferences(text);
|
|
296
|
-
const results: Message[] = [];
|
|
297
|
-
for (const reference of references) {
|
|
298
|
-
results.push(await Github.renderReference(bot, reference));
|
|
299
|
-
}
|
|
300
|
-
return results;
|
|
301
|
-
}
|
|
302
|
-
}
|
|
1
|
+
import { EventManager } from 'gugle-event';
|
|
2
|
+
import { Logger } from 'winston';
|
|
3
|
+
import http from 'node:http';
|
|
4
|
+
import { QQBot } from '@/index';
|
|
5
|
+
import { AllIssueEvent, AllPullRequestEvent, AllReleaseEvent, Issue, PullRequest } from '@/type/github';
|
|
6
|
+
import { GroupMessageWSMSG, Message, SentMessage, TextMessage } from '@/type';
|
|
7
|
+
import { GitHubImage } from '@/features/github/image';
|
|
8
|
+
import { botConfig } from '@/config';
|
|
9
|
+
import { getGithubSubscribers, isGithubEnabledGroup } from '@/config/features';
|
|
10
|
+
import { GitHubBindingManager } from '@/features/github/binding';
|
|
11
|
+
import { fetchGithubApi } from '@/features/github/api';
|
|
12
|
+
import { shouldPushRelease } from '@/features/github/image/impl/release';
|
|
13
|
+
import axios, { AxiosInstance } from 'axios';
|
|
14
|
+
|
|
15
|
+
/** 一条 Issue/PR 引用(`owner/repo#123`,repo 省略时为默认仓库) */
|
|
16
|
+
export interface IssueReference {
|
|
17
|
+
repository: string;
|
|
18
|
+
number: number;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export class Github {
|
|
22
|
+
private readonly bot: QQBot;
|
|
23
|
+
private readonly logger: Logger;
|
|
24
|
+
private readonly eventManager: EventManager;
|
|
25
|
+
private httpServer: http.Server<typeof http.IncomingMessage, typeof http.ServerResponse>;
|
|
26
|
+
|
|
27
|
+
public static readonly axiosInstance: AxiosInstance = axios.create({
|
|
28
|
+
timeout: 15000,
|
|
29
|
+
baseURL: botConfig.httpUrl,
|
|
30
|
+
headers: {
|
|
31
|
+
'Content-Type': 'application/json',
|
|
32
|
+
'User-Agent': botConfig.userAgent
|
|
33
|
+
}
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* 请求 GitHub API(经 ghapi.anvilcraft.dev 反代),供 processMessage 使用。
|
|
38
|
+
* @param apiPath GitHub API 路径(不含域名),如 /repos/owner/repo/issues/123
|
|
39
|
+
*/
|
|
40
|
+
public static fetchGithubApi<T>(bot: QQBot, apiPath: string): Promise<T> {
|
|
41
|
+
return fetchGithubApi<T>(apiPath, bot.logger);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
public constructor(bot: QQBot) {
|
|
45
|
+
this.bot = bot;
|
|
46
|
+
this.logger = bot.logger!;
|
|
47
|
+
this.eventManager = new EventManager();
|
|
48
|
+
this.eventManager.listen('github-issues', this.listenIssueEvent.bind(this));
|
|
49
|
+
this.eventManager.listen('github-pull_request', this.listenPullRequestEvent.bind(this));
|
|
50
|
+
this.eventManager.listen('github-release', this.listenReleaseEvent.bind(this));
|
|
51
|
+
this.httpServer = http.createServer((req, res) => {
|
|
52
|
+
if (req.method === 'POST') {
|
|
53
|
+
let body = '';
|
|
54
|
+
req.on('data', chunk => {
|
|
55
|
+
body += chunk.toString(); // 将数据块拼接成字符串
|
|
56
|
+
});
|
|
57
|
+
req.on('end', () => {
|
|
58
|
+
try {
|
|
59
|
+
const event = (req.headers['x-github-event'] as string) || '';
|
|
60
|
+
body = JSON.parse(body);
|
|
61
|
+
bot.logger?.debug(`Receive Github Event: github-${event}`);
|
|
62
|
+
this.post(`github-${event}`, bot, body);
|
|
63
|
+
res.statusCode = 202;
|
|
64
|
+
res.setHeader('Content-Type', 'text/plain');
|
|
65
|
+
res.end('Accepted');
|
|
66
|
+
} catch (e) {
|
|
67
|
+
bot.logger?.error(e);
|
|
68
|
+
}
|
|
69
|
+
});
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
res.statusCode = 202;
|
|
73
|
+
res.setHeader('Content-Type', 'text/plain');
|
|
74
|
+
res.end('Accepted');
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
private listenIssueEvent(bot: QQBot, msg: AllIssueEvent) {
|
|
79
|
+
let promise: Promise<string> | undefined = undefined;
|
|
80
|
+
if (msg.action === 'opened' || msg.action === 'reopened') {
|
|
81
|
+
promise = GitHubImage.issuesOpened(msg, this.logger);
|
|
82
|
+
} else if (msg.action === 'closed') {
|
|
83
|
+
promise = GitHubImage.issuesClosed(msg, this.logger);
|
|
84
|
+
}
|
|
85
|
+
const repository = msg.repository.full_name;
|
|
86
|
+
this.sendGeneratedImage(bot, repository, 'issue', promise);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
private listenPullRequestEvent(bot: QQBot, msg: AllPullRequestEvent) {
|
|
90
|
+
let promise: Promise<string> | undefined = undefined;
|
|
91
|
+
if (msg.action === 'opened' || msg.action === 'reopened') {
|
|
92
|
+
promise = GitHubImage.prOpened(msg, this.logger);
|
|
93
|
+
} else if (msg.action === 'closed') {
|
|
94
|
+
promise = GitHubImage.prClosed(msg, this.logger);
|
|
95
|
+
}
|
|
96
|
+
const repository = msg.repository.full_name;
|
|
97
|
+
this.sendGeneratedImage(bot, repository, 'pull request', promise);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* 处理仓库发布 release 的 webhook 事件。
|
|
102
|
+
*
|
|
103
|
+
* GitHub 对同一次发布会投递多个 action(实测同一 release id 会在数秒内先后收到
|
|
104
|
+
* created + released、published + created + prereleased 等组合,且组合不固定),
|
|
105
|
+
* 因此按 action 过滤并不能避免重复,这里以 release id 在时间窗口内去重。
|
|
106
|
+
*/
|
|
107
|
+
private listenReleaseEvent(bot: QQBot, msg: AllReleaseEvent) {
|
|
108
|
+
const decision = shouldPushRelease(msg);
|
|
109
|
+
if (!decision.push) {
|
|
110
|
+
this.logger?.debug(`Skip release event ${msg.release?.id} (${msg.action}): ${decision.reason}`);
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
const repository = msg.repository.full_name;
|
|
114
|
+
this.sendGeneratedImage(bot, repository, 'release', GitHubImage.releasePublished(msg, this.logger));
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* 把生成的图片推送给订阅了该仓库的群(仓库级路由)。
|
|
119
|
+
* 只发给 github.json 中 repository → 订阅群列表对应的群。
|
|
120
|
+
*/
|
|
121
|
+
private sendGeneratedImage(bot: QQBot, repository: string, type: string, result: Promise<string> | undefined = undefined) {
|
|
122
|
+
const subscribers = getGithubSubscribers(repository);
|
|
123
|
+
if (subscribers.length === 0) {
|
|
124
|
+
this.logger?.debug(`No subscribers for repository ${repository}, skip ${type} message...`);
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
result
|
|
128
|
+
?.then(base64 => {
|
|
129
|
+
const msg: SentMessage = [
|
|
130
|
+
{
|
|
131
|
+
type: 'image',
|
|
132
|
+
data: {
|
|
133
|
+
file: `data:image/png;base64, ${base64}`
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
];
|
|
137
|
+
subscribers.forEach(group => {
|
|
138
|
+
// fromWebhook:标记为 webhook 推送,互通桥据此不转发到 need_cmd/need_reply 频道
|
|
139
|
+
bot.sendGroupMsg(group, msg, { fromWebhook: true });
|
|
140
|
+
});
|
|
141
|
+
this.logger?.debug(`Sent process ${type} message of ${repository} to ${subscribers.length} group(s)...`);
|
|
142
|
+
})
|
|
143
|
+
.catch(e => {
|
|
144
|
+
this.logger?.error(e);
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
public post(event: string, ...args: any): Promise<any[]> {
|
|
149
|
+
return this.eventManager.post(event, ...args);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
public start(port: number): void {
|
|
153
|
+
const self = this;
|
|
154
|
+
this.httpServer.listen(port, () => {
|
|
155
|
+
self.logger?.info(`http server listen on port ${port}`);
|
|
156
|
+
});
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* 判断仓库是否允许被访问:owner 已绑定 GitHub 账号,或命中允许列表配置。
|
|
161
|
+
* 消息查询与订阅共用该判定,保证「能查」与「能订阅」口径一致。
|
|
162
|
+
*/
|
|
163
|
+
public static isAllowedRepository(repository: string): boolean {
|
|
164
|
+
const [owner] = repository.split('/');
|
|
165
|
+
if (owner && GitHubBindingManager.isBoundUsername(owner)) return true;
|
|
166
|
+
|
|
167
|
+
return botConfig.githubAllowedRepositories.some(pattern => {
|
|
168
|
+
if (pattern.endsWith('/*')) return repository.startsWith(`${pattern.slice(0, -2)}/`);
|
|
169
|
+
return repository === pattern;
|
|
170
|
+
});
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/** 单条消息最多解析的 Issue/PR 引用数(防止刷屏,超出部分忽略) */
|
|
174
|
+
public static readonly MAX_REFERENCES_PER_MESSAGE = 10;
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* 解析文本中的全部 Issue/PR 引用(`owner/repo#123` 或 `#123`)。
|
|
178
|
+
* 按出现顺序去重(`repository#number`),最多返回 MAX_REFERENCES_PER_MESSAGE 条。
|
|
179
|
+
*/
|
|
180
|
+
public static parseReferences(text: string, defaultRepository: string = 'Anvil-Dev/AnvilCraft'): IssueReference[] {
|
|
181
|
+
const pattern = /(?:([A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+))?#(\d+)/g;
|
|
182
|
+
const references: IssueReference[] = [];
|
|
183
|
+
const seen = new Set<string>();
|
|
184
|
+
let match: RegExpExecArray | null;
|
|
185
|
+
while ((match = pattern.exec(text)) !== null) {
|
|
186
|
+
const repository = match[1] || defaultRepository;
|
|
187
|
+
const number = Number.parseInt(match[2]!, 10);
|
|
188
|
+
const key = `${repository}#${number}`;
|
|
189
|
+
if (seen.has(key)) continue;
|
|
190
|
+
seen.add(key);
|
|
191
|
+
references.push({ repository, number });
|
|
192
|
+
if (references.length >= Github.MAX_REFERENCES_PER_MESSAGE) break;
|
|
193
|
+
}
|
|
194
|
+
return references;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* 渲染单个 Issue/PR 引用为 QQ 消息段:成功为图片段,失败为文本说明段。
|
|
199
|
+
* 仓库不在允许列表时返回提示文本(不发请求)。
|
|
200
|
+
*/
|
|
201
|
+
public static async renderReference(bot: QQBot, reference: IssueReference): Promise<Message> {
|
|
202
|
+
const { repository, number } = reference;
|
|
203
|
+
if (!this.isAllowedRepository(repository)) {
|
|
204
|
+
return {
|
|
205
|
+
type: 'text',
|
|
206
|
+
data: {
|
|
207
|
+
text: `仓库 ${repository} 不在允许访问的仓库列表中`
|
|
208
|
+
}
|
|
209
|
+
};
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
const issueApiPath = `/repos/${repository}/issues/${number}`;
|
|
213
|
+
const pullApiPath = `/repos/${repository}/pulls/${number}`;
|
|
214
|
+
|
|
215
|
+
// 经 ghapi 反代获取 issue 数据
|
|
216
|
+
let issueData: Issue & { pull_request?: unknown };
|
|
217
|
+
try {
|
|
218
|
+
issueData = await Github.fetchGithubApi<Issue & { pull_request?: unknown }>(bot, issueApiPath);
|
|
219
|
+
} catch (e) {
|
|
220
|
+
bot.logger?.error(e);
|
|
221
|
+
return {
|
|
222
|
+
type: 'text',
|
|
223
|
+
data: {
|
|
224
|
+
text: `请求失败,原因:${(e as Error)?.message ?? e}`
|
|
225
|
+
}
|
|
226
|
+
};
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
try {
|
|
230
|
+
if (issueData.pull_request) {
|
|
231
|
+
// 如果是 PR,经 ghapi 反代获取 PR 数据
|
|
232
|
+
const prData = await Github.fetchGithubApi<PullRequest>(bot, pullApiPath);
|
|
233
|
+
const imageData = await GitHubImage.prHandler(prData, bot.logger);
|
|
234
|
+
return {
|
|
235
|
+
type: 'image',
|
|
236
|
+
data: {
|
|
237
|
+
file: `data:image/png;base64, ${imageData}`
|
|
238
|
+
}
|
|
239
|
+
};
|
|
240
|
+
}
|
|
241
|
+
// 处理 issue
|
|
242
|
+
const imageData = await GitHubImage.issuesHandler(issueData as Issue, bot.logger);
|
|
243
|
+
return {
|
|
244
|
+
type: 'image',
|
|
245
|
+
data: {
|
|
246
|
+
file: `data:image/png;base64, ${imageData}`
|
|
247
|
+
}
|
|
248
|
+
};
|
|
249
|
+
} catch (e) {
|
|
250
|
+
bot.logger?.error(e);
|
|
251
|
+
return {
|
|
252
|
+
type: 'text',
|
|
253
|
+
data: {
|
|
254
|
+
text: `图片处理失败,原因:${(e as Error)?.message ?? e}`
|
|
255
|
+
}
|
|
256
|
+
};
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/**
|
|
261
|
+
* QQ 群消息入口:解析消息中的全部 Issue/PR 引用并逐个渲染。
|
|
262
|
+
* 第一个引用的结果并入 sentMessage(随调用方的回复消息一起发出);
|
|
263
|
+
* 其余引用各自作为独立群消息发送(用户要求多编号时分多条消息发送)。
|
|
264
|
+
* 渲染串行执行以保证消息顺序与编号顺序一致。
|
|
265
|
+
*/
|
|
266
|
+
public static async processMessage(bot: QQBot, msg: GroupMessageWSMSG, sentMessage: Message[]): Promise<void> {
|
|
267
|
+
if (!isGithubEnabledGroup(msg.group_id)) return;
|
|
268
|
+
|
|
269
|
+
const receivedMessage: TextMessage[] = [];
|
|
270
|
+
msg.message.forEach(message => {
|
|
271
|
+
if (message.type != 'text') return;
|
|
272
|
+
receivedMessage.push(message);
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
const strMsg = receivedMessage.map(msg => msg.data.text).join(' ');
|
|
276
|
+
const references = Github.parseReferences(strMsg);
|
|
277
|
+
if (references.length === 0) return;
|
|
278
|
+
|
|
279
|
+
for (let index = 0; index < references.length; index++) {
|
|
280
|
+
const rendered = await Github.renderReference(bot, references[index]!);
|
|
281
|
+
if (index === 0) {
|
|
282
|
+
sentMessage.push(rendered);
|
|
283
|
+
} else {
|
|
284
|
+
await bot.sendGroupMsg(msg.group_id, [rendered]);
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
/**
|
|
290
|
+
* Discord 侧入口:解析文本中的全部 Issue/PR 引用并渲染为消息段列表(由调用方发送)。
|
|
291
|
+
* groupId 为 Discord 频道桥接的 QQ 群号,用于复用「该群是否启用 github 功能」的判定。
|
|
292
|
+
*/
|
|
293
|
+
public static async processDiscordMessage(bot: QQBot, text: string, groupId?: number): Promise<Message[]> {
|
|
294
|
+
if (groupId !== undefined && !isGithubEnabledGroup(groupId)) return [];
|
|
295
|
+
const references = Github.parseReferences(text);
|
|
296
|
+
const results: Message[] = [];
|
|
297
|
+
for (const reference of references) {
|
|
298
|
+
results.push(await Github.renderReference(bot, reference));
|
|
299
|
+
}
|
|
300
|
+
return results;
|
|
301
|
+
}
|
|
302
|
+
}
|