koishi-plugin-qq-github-bridge 0.0.0-stage → 1.0.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 +21 -0
- package/README.md +167 -2
- package/client/env.d.ts +5 -0
- package/client/index.ts +6 -0
- package/client/page.vue +164 -0
- package/client/tsconfig.json +15 -0
- package/dist/index.js +1 -0
- package/dist/style.css +1 -0
- package/docs/ARCHITECTURE.md +100 -0
- package/docs/RELEASING.md +68 -0
- package/docs/RESEARCH.md +37 -0
- package/docs/REVIEW.md +48 -0
- package/docs/TESTING.md +92 -0
- package/lib/config.d.ts +35 -0
- package/lib/config.js +99 -0
- package/lib/config.js.map +1 -0
- package/lib/console.d.ts +7 -0
- package/lib/console.js +73 -0
- package/lib/console.js.map +1 -0
- package/lib/database/models.d.ts +37 -0
- package/lib/database/models.js +21 -0
- package/lib/database/models.js.map +1 -0
- package/lib/handlers/commands.d.ts +28 -0
- package/lib/handlers/commands.js +184 -0
- package/lib/handlers/commands.js.map +1 -0
- package/lib/handlers/message.d.ts +4 -0
- package/lib/handlers/message.js +58 -0
- package/lib/handlers/message.js.map +1 -0
- package/lib/index.d.ts +7 -0
- package/lib/index.js +44 -0
- package/lib/index.js.map +1 -0
- package/lib/rpc.d.ts +8 -0
- package/lib/rpc.js +3 -0
- package/lib/rpc.js.map +1 -0
- package/lib/services/github.d.ts +22 -0
- package/lib/services/github.js +201 -0
- package/lib/services/github.js.map +1 -0
- package/lib/services/permission.d.ts +12 -0
- package/lib/services/permission.js +56 -0
- package/lib/services/permission.js.map +1 -0
- package/lib/services/repository.d.ts +8 -0
- package/lib/services/repository.js +22 -0
- package/lib/services/repository.js.map +1 -0
- package/lib/services/storage.d.ts +13 -0
- package/lib/services/storage.js +75 -0
- package/lib/services/storage.js.map +1 -0
- package/lib/types.d.ts +93 -0
- package/lib/types.js +3 -0
- package/lib/types.js.map +1 -0
- package/lib/utils/errors.d.ts +8 -0
- package/lib/utils/errors.js +28 -0
- package/lib/utils/errors.js.map +1 -0
- package/lib/utils/lock.d.ts +11 -0
- package/lib/utils/lock.js +38 -0
- package/lib/utils/lock.js.map +1 -0
- package/lib/utils/parser.d.ts +9 -0
- package/lib/utils/parser.js +87 -0
- package/lib/utils/parser.js.map +1 -0
- package/package.json +99 -4
- package/src/config.ts +111 -0
- package/src/console.ts +65 -0
- package/src/database/models.ts +45 -0
- package/src/handlers/commands.ts +177 -0
- package/src/handlers/message.ts +60 -0
- package/src/index.ts +37 -0
- package/src/rpc.ts +10 -0
- package/src/services/github.ts +189 -0
- package/src/services/permission.ts +47 -0
- package/src/services/repository.ts +22 -0
- package/src/services/storage.ts +67 -0
- package/src/types.ts +75 -0
- package/src/utils/errors.ts +18 -0
- package/src/utils/lock.ts +30 -0
- package/src/utils/parser.ts +71 -0
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
import { setTimeout as delay } from 'node:timers/promises'
|
|
2
|
+
import type { Context } from 'koishi'
|
|
3
|
+
import type { Config } from '../config'
|
|
4
|
+
import type { Actor, Outcome, Repository, TokenStatus } from '../types'
|
|
5
|
+
import { BridgeError, redact } from '../utils/errors'
|
|
6
|
+
|
|
7
|
+
interface Issue { number: number; state: 'open' | 'closed'; html_url: string; pull_request?: unknown }
|
|
8
|
+
interface Pull extends Issue { merged: boolean }
|
|
9
|
+
|
|
10
|
+
function metadata(value: string): string {
|
|
11
|
+
return value.slice(0,80).replace(/[\r\n\u0000-\u001f\u007f-\u009f\u200b-\u200f\u202a-\u202e\u2066-\u2069]/g, ' ')
|
|
12
|
+
.replace(/[&<>"'`*_\[\]{}()|@#!\\]/g, character => `&#${character.codePointAt(0)};`)
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export class GitHub {
|
|
16
|
+
private readonly abort = new AbortController()
|
|
17
|
+
private readonly base: string
|
|
18
|
+
private blockedUntil = 0
|
|
19
|
+
private lastMutation = 0
|
|
20
|
+
|
|
21
|
+
constructor(private readonly ctx: Context, private readonly config: Config) {
|
|
22
|
+
this.base = config.apiBaseUrl.replace(/\/+$/, '')
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
dispose() { this.abort.abort() }
|
|
26
|
+
|
|
27
|
+
private async sleep(ms: number) {
|
|
28
|
+
try { await delay(ms, undefined, { signal: this.abort.signal }) } catch {
|
|
29
|
+
throw new BridgeError('disposed', '插件正在停用,请稍后再试。')
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
private async request<T>(method: 'GET' | 'POST' | 'PATCH', path: string, data?: object): Promise<T> {
|
|
34
|
+
if (this.abort.signal.aborted) throw new BridgeError('disposed', '插件正在停用,请稍后再试。')
|
|
35
|
+
if (this.blockedUntil > Date.now()) throw new BridgeError('rate-limit', `GitHub 限流中,请在约 ${Math.ceil((this.blockedUntil - Date.now()) / 1000)} 秒后再试。`)
|
|
36
|
+
const writing = method !== 'GET'
|
|
37
|
+
if (writing) {
|
|
38
|
+
const wait = 1000 - (Date.now() - this.lastMutation)
|
|
39
|
+
if (wait > 0) await this.sleep(wait)
|
|
40
|
+
this.lastMutation = Date.now()
|
|
41
|
+
}
|
|
42
|
+
for (let attempt = 0; ; attempt++) {
|
|
43
|
+
let response: { status: number; data: unknown; headers: Headers }
|
|
44
|
+
try {
|
|
45
|
+
response = await this.ctx.http(this.base + path, {
|
|
46
|
+
method, data,
|
|
47
|
+
headers: {
|
|
48
|
+
Authorization: `Bearer ${this.config.token}`,
|
|
49
|
+
Accept: 'application/vnd.github+json',
|
|
50
|
+
'X-GitHub-Api-Version': this.config.apiVersion,
|
|
51
|
+
'User-Agent': 'koishi-plugin-qq-github-bridge/1.0.0',
|
|
52
|
+
},
|
|
53
|
+
timeout: this.config.requestTimeout,
|
|
54
|
+
signal: this.abort.signal,
|
|
55
|
+
// Never send credentials or execute writes at a redirected/unbound repository.
|
|
56
|
+
redirect: 'manual',
|
|
57
|
+
validateStatus: () => true,
|
|
58
|
+
})
|
|
59
|
+
} catch {
|
|
60
|
+
if (!writing && attempt < this.config.readRetries && !this.abort.signal.aborted) {
|
|
61
|
+
await this.sleep(300 * 2 ** attempt)
|
|
62
|
+
continue
|
|
63
|
+
}
|
|
64
|
+
throw new BridgeError(writing ? 'write-unknown' : 'network', writing
|
|
65
|
+
? 'GitHub 写入请求未得到确认(网络超时、连接失败或插件停用)。操作可能已成功;请先到 GitHub 核对,不要直接重发。'
|
|
66
|
+
: '无法连接 GitHub(超时、DNS 或连接故障),请稍后重试或联系部署者。', writing)
|
|
67
|
+
}
|
|
68
|
+
if (response.status >= 200 && response.status < 300) return response.data as T
|
|
69
|
+
const status = response.status
|
|
70
|
+
const getHeader = (name: string) => response.headers?.get?.(name)
|
|
71
|
+
const serverMessage = (response.data as { message?: unknown } | null)?.message
|
|
72
|
+
const rateLimited = status === 429 || (status === 403 && (getHeader('x-ratelimit-remaining') === '0' ||
|
|
73
|
+
!!getHeader('retry-after') || (typeof serverMessage === 'string' && /rate limit|abuse/i.test(serverMessage))))
|
|
74
|
+
if (rateLimited) {
|
|
75
|
+
const retryHeader = getHeader('retry-after')
|
|
76
|
+
const seconds = Number(retryHeader)
|
|
77
|
+
const retryAt = retryHeader && !Number.isFinite(seconds) ? Date.parse(retryHeader) : Date.now() + seconds * 1000
|
|
78
|
+
const resetAt = getHeader('x-ratelimit-remaining') === '0' ? Number(getHeader('x-ratelimit-reset')) * 1000 : 0
|
|
79
|
+
this.blockedUntil = Math.max(Date.now() + 60000, Number.isFinite(retryAt) ? retryAt : 0, Number.isFinite(resetAt) ? resetAt : 0)
|
|
80
|
+
throw new BridgeError('rate-limit', 'GitHub 请求限流,已暂停后续请求,请稍后再试。')
|
|
81
|
+
}
|
|
82
|
+
if (status >= 500) {
|
|
83
|
+
if (!writing && attempt < this.config.readRetries) {
|
|
84
|
+
await this.sleep(300 * 2 ** attempt)
|
|
85
|
+
continue
|
|
86
|
+
}
|
|
87
|
+
throw new BridgeError(writing ? 'write-unknown' : 'github-server', writing
|
|
88
|
+
? 'GitHub 服务端异常,写入结果无法确认。请先到 GitHub 核对,插件不会自动重试。'
|
|
89
|
+
: 'GitHub 服务端暂时异常,有限重试后仍未恢复。', writing)
|
|
90
|
+
}
|
|
91
|
+
const errors: Record<number, [string, string]> = {
|
|
92
|
+
401: ['token-invalid', 'GitHub Token 无效或已过期,请部署者在后台更新。'],
|
|
93
|
+
403: ['github-forbidden', 'GitHub 拒绝访问:Token 权限不足、仓库或组织访问受限;请部署者检查权限和组织审批。'],
|
|
94
|
+
404: ['not-found', 'GitHub 仓库或目标不存在,或当前 Token 无权访问。'],
|
|
95
|
+
410: ['gone', '目标功能或资源已不可用(例如仓库关闭了 Issues)。'],
|
|
96
|
+
422: ['validation', 'GitHub 拒绝请求:数据不合法、触发反滥用限制,或当前目标不允许此状态转换。请在 GitHub 核对目标状态和 PR 源分支。'],
|
|
97
|
+
}
|
|
98
|
+
if (status >= 300 && status < 400) throw new BridgeError('redirect', 'GitHub 返回重定向;为保持群绑定边界已停止请求,请部署者更新仓库绑定或 API 地址。')
|
|
99
|
+
const [code, message] = errors[status] || ['github-error', `GitHub 请求失败(HTTP ${status})。请部署者检查配置。`]
|
|
100
|
+
throw new BridgeError(code, message)
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
private path(repo: Repository): string {
|
|
105
|
+
return `/repos/${encodeURIComponent(repo.owner)}/${encodeURIComponent(repo.repo)}`
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
private link(value: unknown): string | undefined {
|
|
109
|
+
if (typeof value !== 'string' || value.length > 2000 || value.includes(this.config.token)) return
|
|
110
|
+
try {
|
|
111
|
+
const url = new URL(value)
|
|
112
|
+
// html_url is returned by the trusted API, never used as a request target.
|
|
113
|
+
// Proxies / Enterprise Cloud can have a different API and web hostname.
|
|
114
|
+
if (url.username || url.password || url.search || decodeURIComponent(value).includes(this.config.token)) return
|
|
115
|
+
if (url.protocol !== 'https:' && !(url.protocol === 'http:' && ['127.0.0.1', 'localhost', '[::1]'].includes(url.hostname))) return
|
|
116
|
+
return url.href
|
|
117
|
+
} catch { return }
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
private async target(repo: Repository, number: number): Promise<Issue> {
|
|
121
|
+
const issue = await this.request<Issue>('GET', `${this.path(repo)}/issues/${number}`)
|
|
122
|
+
if (!issue || issue.number !== number || !['open', 'closed'].includes(issue.state)) {
|
|
123
|
+
throw new BridgeError('github-response', 'GitHub 返回了不符合预期的目标数据,已停止操作。')
|
|
124
|
+
}
|
|
125
|
+
return issue
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
formatComment(actor: Actor, body: string, eventKey: string): string {
|
|
129
|
+
if (body.includes(this.config.token)) throw new BridgeError('secret-in-body', '评论包含部署 Token,已拒绝发送。请移除敏感信息。')
|
|
130
|
+
const nickname = metadata(redact(actor.nickname, this.config.token))
|
|
131
|
+
const header = `<sub>QQ Bridge 已验证发送者 · 昵称:「${nickname}」 · QQ:${actor.qq} · QQ群:${actor.groupId}</sub>`
|
|
132
|
+
const comment = `${header}\n\n---\n\n${this.config.commentTemplate.replace('{body}', () => body)}\n\n<!-- qq-github-bridge:${eventKey} -->`
|
|
133
|
+
// GitHub imposes its own limit; also bound bytes to safely accommodate Unicode text.
|
|
134
|
+
if (comment.length > 65000 || Buffer.byteLength(comment, 'utf8') > 65000) {
|
|
135
|
+
throw new BridgeError('comment-too-large', '渲染后的评论过长,请缩短正文或评论模板。')
|
|
136
|
+
}
|
|
137
|
+
return comment
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
async comment(repo: Repository, number: number, actor: Actor, body: string, eventKey: string): Promise<Outcome> {
|
|
141
|
+
const comment = this.formatComment(actor, body, eventKey)
|
|
142
|
+
const issue = await this.target(repo, number)
|
|
143
|
+
const result = await this.request<{ id?: number; html_url?: unknown }>('POST', `${this.path(repo)}/issues/${number}/comments`, { body: comment })
|
|
144
|
+
const link = this.link(result?.html_url)
|
|
145
|
+
if (!link || !Number.isSafeInteger(result?.id) || result.id! <= 0) {
|
|
146
|
+
throw new BridgeError('write-unknown', 'GitHub 返回了成功状态,但评论标识或链接不完整,无法确认评论结果。请先到 GitHub 核对,不要直接重发。', true)
|
|
147
|
+
}
|
|
148
|
+
return {
|
|
149
|
+
message: `已在 ${repo.fullName} ${issue.pull_request ? 'PR' : 'Issue'} #${number} 发布评论。\n${link}`,
|
|
150
|
+
result: 'success',
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
async state(repo: Repository, number: number, desired: 'open' | 'closed'): Promise<Outcome> {
|
|
155
|
+
let target: Issue | Pull = await this.target(repo, number)
|
|
156
|
+
const isPR = !!target.pull_request
|
|
157
|
+
if (isPR) {
|
|
158
|
+
target = await this.request<Pull>('GET', `${this.path(repo)}/pulls/${number}`)
|
|
159
|
+
if (!target || target.number !== number || !['open', 'closed'].includes(target.state) || typeof (target as Pull).merged !== 'boolean') {
|
|
160
|
+
throw new BridgeError('github-response', 'GitHub 返回了不符合预期的 PR 状态,已停止操作。')
|
|
161
|
+
}
|
|
162
|
+
if (desired === 'open' && (target as Pull).merged) throw new BridgeError('pr-merged', '该 PR 已合并,不能重新打开。')
|
|
163
|
+
}
|
|
164
|
+
const label = `${repo.fullName} ${isPR ? 'PR' : 'Issue'} #${number}`
|
|
165
|
+
if (target.state === desired) return { message: `${label} 当前已经${desired === 'open' ? '打开' : '关闭'},无需重复操作。`, result: 'noop' }
|
|
166
|
+
const updated = await this.request<Issue>('PATCH', `${this.path(repo)}/${isPR ? 'pulls' : 'issues'}/${number}`, { state: desired })
|
|
167
|
+
if (updated?.number !== number || updated?.state !== desired) {
|
|
168
|
+
throw new BridgeError('write-unknown', 'GitHub 未确认预期状态,操作结果不确定;请到 GitHub 核对。', true)
|
|
169
|
+
}
|
|
170
|
+
return { message: `已${desired === 'open' ? '重新打开' : '关闭'} ${label}。`, result: 'success' }
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
async validate(repo?: Repository): Promise<TokenStatus> {
|
|
174
|
+
const user = await this.request<{ login: string }>('GET', '/user')
|
|
175
|
+
if (!user || !/^[A-Za-z0-9][A-Za-z0-9-]{0,99}$/.test(user.login)) throw new BridgeError('github-response', 'Token 验证响应无效,请检查 API 地址。')
|
|
176
|
+
const repositories: TokenStatus['repositories'] = []
|
|
177
|
+
if (repo) {
|
|
178
|
+
try {
|
|
179
|
+
const data = await this.request<{ full_name: string }>('GET', this.path(repo))
|
|
180
|
+
if (data?.full_name?.toLowerCase() !== repo.fullName.toLowerCase()) throw new BridgeError('repo-mismatch', '仓库响应与群绑定不一致。')
|
|
181
|
+
repositories.push({ repository: repo.fullName, accessible: true, message: '仓库可读取。' })
|
|
182
|
+
} catch (error) {
|
|
183
|
+
if (!(error instanceof BridgeError)) throw error
|
|
184
|
+
repositories.push({ repository: repo.fullName, accessible: false, message: error.message })
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
return { login: redact(user.login, this.config.token), repositories, message: 'Token 身份已验证。只读检测不能证明写权限;Issues 和 Pull requests 均需 Read and write。' }
|
|
188
|
+
}
|
|
189
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import type { Context } from 'koishi'
|
|
2
|
+
import type { Config } from '../config'
|
|
3
|
+
import { QQ_PATTERN } from '../config'
|
|
4
|
+
import type { Actor, Outcome } from '../types'
|
|
5
|
+
import { BridgeError } from '../utils/errors'
|
|
6
|
+
|
|
7
|
+
export class Permissions {
|
|
8
|
+
private readonly superAdmins: Set<string>
|
|
9
|
+
constructor(private readonly ctx: Context, config: Config) {
|
|
10
|
+
this.superAdmins = new Set(config.superAdmins)
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
async requireAdmin(actor: Actor, superOnly = false) {
|
|
14
|
+
// Console actors are constructed only after a fresh authenticated deployer check.
|
|
15
|
+
if (actor.source === 'console') return
|
|
16
|
+
if (this.superAdmins.has(actor.qq)) return
|
|
17
|
+
if (superOnly) throw new BridgeError('forbidden', '只有超级管理员可以撤销管理员权限。')
|
|
18
|
+
const [admin] = await this.ctx.database.get('qgh_admin', actor.qq)
|
|
19
|
+
if (!admin) throw new BridgeError('forbidden', '此操作需要 GitHub Bridge 管理员权限。')
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
async add(actor: Actor, qq: string): Promise<Outcome> {
|
|
23
|
+
await this.requireAdmin(actor)
|
|
24
|
+
if (!QQ_PATTERN.test(qq)) throw new BridgeError('qq', '请输入有效的数字 QQ 号。')
|
|
25
|
+
if (this.superAdmins.has(qq)) return { message: `${qq} 已是后台配置的超级管理员。`, result: 'noop' }
|
|
26
|
+
const [existing] = await this.ctx.database.get('qgh_admin', qq)
|
|
27
|
+
if (existing) return { message: `${qq} 已是管理员,无需重复添加。`, result: 'noop' }
|
|
28
|
+
await this.ctx.database.create('qgh_admin', {
|
|
29
|
+
qq, addedBy: actor.source === 'console' ? `console:${actor.id}` : actor.qq, createdAt: new Date(),
|
|
30
|
+
})
|
|
31
|
+
return { message: `已添加管理员 ${qq},权限立即生效。`, result: 'success' }
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
async remove(actor: Actor, qq: string): Promise<Outcome> {
|
|
35
|
+
await this.requireAdmin(actor, true)
|
|
36
|
+
if (!QQ_PATTERN.test(qq)) throw new BridgeError('qq', '请输入有效的数字 QQ 号。')
|
|
37
|
+
if (this.superAdmins.has(qq)) throw new BridgeError('super-admin', '超级管理员只能通过插件后台配置修改,不能在管理员列表或 QQ 指令中撤销。')
|
|
38
|
+
const [existing] = await this.ctx.database.get('qgh_admin', qq)
|
|
39
|
+
if (!existing) return { message: `${qq} 当前不是管理员。`, result: 'noop' }
|
|
40
|
+
await this.ctx.database.remove('qgh_admin', qq)
|
|
41
|
+
return { message: `已撤销管理员 ${qq}。此前由其添加的其他管理员保持不变。`, result: 'success' }
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
async list(offset = 0, limit = 50) {
|
|
45
|
+
return this.ctx.database.get('qgh_admin', {}, { sort: { createdAt: 'asc', qq: 'asc' }, offset, limit })
|
|
46
|
+
}
|
|
47
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import type { Binding } from '../config'
|
|
2
|
+
import type { Repository } from '../types'
|
|
3
|
+
|
|
4
|
+
export class RepositoryResolver {
|
|
5
|
+
private readonly groups = new Map<string, Repository>()
|
|
6
|
+
|
|
7
|
+
constructor(bindings: Binding[]) {
|
|
8
|
+
for (const item of bindings) {
|
|
9
|
+
if (item.enabled) this.groups.set(item.groupId, {
|
|
10
|
+
owner: item.owner,
|
|
11
|
+
repo: item.repo,
|
|
12
|
+
fullName: `${item.owner}/${item.repo}`,
|
|
13
|
+
})
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
resolve(groupId: string): Repository | undefined { return this.groups.get(groupId) }
|
|
18
|
+
|
|
19
|
+
all(): Repository[] {
|
|
20
|
+
return [...new Map([...this.groups.values()].map(repo => [repo.fullName.toLowerCase(), repo])).values()]
|
|
21
|
+
}
|
|
22
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto'
|
|
2
|
+
import type { Context } from 'koishi'
|
|
3
|
+
import type { Config } from '../config'
|
|
4
|
+
import type { Actor, IncomingMessage } from '../types'
|
|
5
|
+
import { BridgeError } from '../utils/errors'
|
|
6
|
+
|
|
7
|
+
export class Storage {
|
|
8
|
+
constructor(private readonly ctx: Context, private readonly config: Config) {}
|
|
9
|
+
|
|
10
|
+
async claim(message: IncomingMessage): Promise<boolean> {
|
|
11
|
+
// The primary key is the atomic claim, including across reconnects / plugin reloads.
|
|
12
|
+
const [existing] = await this.ctx.database.get('qgh_event', message.eventKey)
|
|
13
|
+
if (existing) return false
|
|
14
|
+
try {
|
|
15
|
+
await this.ctx.database.create('qgh_event', {
|
|
16
|
+
id: message.eventKey,
|
|
17
|
+
// Allow clock skew and avoid cleanup racing a still-valid incoming event.
|
|
18
|
+
expiresAt: new Date(Math.max(Date.now(), message.timestamp) + this.config.dedupWindow + 60000),
|
|
19
|
+
})
|
|
20
|
+
return true
|
|
21
|
+
} catch {
|
|
22
|
+
const [winner] = await this.ctx.database.get('qgh_event', message.eventKey)
|
|
23
|
+
if (winner) return false
|
|
24
|
+
throw new BridgeError('database', '数据库去重记录写入失败,未执行操作。')
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
async cooldown(qq: string) {
|
|
29
|
+
if (!this.config.userCooldown) return
|
|
30
|
+
const [existing] = await this.ctx.database.get('qgh_cooldown', qq)
|
|
31
|
+
const now = Date.now()
|
|
32
|
+
if (existing && +existing.expiresAt > now) {
|
|
33
|
+
throw new BridgeError('cooldown', `操作过于频繁,请在 ${Math.ceil((+existing.expiresAt - now) / 1000)} 秒后重试。`)
|
|
34
|
+
}
|
|
35
|
+
await this.ctx.database.upsert('qgh_cooldown', [{ qq, expiresAt: new Date(now + this.config.userCooldown) }])
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
async begin(actor: Actor, action: string, target: string): Promise<string | undefined> {
|
|
39
|
+
if (!this.config.auditEnabled) return
|
|
40
|
+
const id = randomUUID()
|
|
41
|
+
await this.ctx.database.create('qgh_audit', {
|
|
42
|
+
id, actorId: actor.id, actorQQ: actor.qq, source: actor.source, action, target,
|
|
43
|
+
groupId: actor.groupId, repository: actor.repository, createdAt: new Date(),
|
|
44
|
+
result: 'pending', detail: '',
|
|
45
|
+
})
|
|
46
|
+
return id
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
async finish(id: string | undefined, result: string, detail: string): Promise<boolean> {
|
|
50
|
+
if (!id) return true
|
|
51
|
+
try {
|
|
52
|
+
// detail is an internal error code or constant, never exception text / API response data.
|
|
53
|
+
await this.ctx.database.set('qgh_audit', id, { result, detail })
|
|
54
|
+
return true
|
|
55
|
+
} catch {
|
|
56
|
+
this.ctx.logger('qq-github-bridge').warn('审计结果写入失败,原始记录保留为 pending;请核对操作结果。')
|
|
57
|
+
return false
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
async cleanup() {
|
|
62
|
+
const now = Date.now()
|
|
63
|
+
await this.ctx.database.remove('qgh_event', { expiresAt: { $lt: new Date(now) } })
|
|
64
|
+
await this.ctx.database.remove('qgh_cooldown', { expiresAt: { $lt: new Date(now) } })
|
|
65
|
+
await this.ctx.database.remove('qgh_audit', { createdAt: { $lt: new Date(now - this.config.auditRetentionDays * 86400000) } })
|
|
66
|
+
}
|
|
67
|
+
}
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
export interface Repository {
|
|
2
|
+
owner: string
|
|
3
|
+
repo: string
|
|
4
|
+
fullName: string
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export interface Actor {
|
|
8
|
+
source: 'qq' | 'console'
|
|
9
|
+
id: string
|
|
10
|
+
qq: string
|
|
11
|
+
nickname: string
|
|
12
|
+
groupId: string
|
|
13
|
+
repository: string
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface IncomingMessage {
|
|
17
|
+
actor: Actor
|
|
18
|
+
repository: Repository
|
|
19
|
+
eventKey: string
|
|
20
|
+
timestamp: number
|
|
21
|
+
text: string
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export type Command =
|
|
25
|
+
| { type: 'comment'; number: number; body: string }
|
|
26
|
+
| { type: 'close'; number: number }
|
|
27
|
+
| { type: 'open'; number: number }
|
|
28
|
+
| { type: 'admin-add'; qq: string }
|
|
29
|
+
| { type: 'admin-remove'; qq: string }
|
|
30
|
+
| { type: 'admin-list'; page: number }
|
|
31
|
+
| { type: 'confirm'; code: string }
|
|
32
|
+
| { type: 'invalid'; message: string }
|
|
33
|
+
|
|
34
|
+
export type MutatingCommand = Exclude<Command, { type: 'invalid' | 'confirm' | 'admin-list' }>
|
|
35
|
+
|
|
36
|
+
export interface Outcome {
|
|
37
|
+
message: string
|
|
38
|
+
result?: 'success' | 'noop'
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface AdminView {
|
|
42
|
+
qq: string
|
|
43
|
+
addedBy: string
|
|
44
|
+
createdAt: string
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export interface AuditView {
|
|
48
|
+
id: string
|
|
49
|
+
actorId: string
|
|
50
|
+
actorQQ: string
|
|
51
|
+
source: string
|
|
52
|
+
action: string
|
|
53
|
+
target: string
|
|
54
|
+
groupId: string
|
|
55
|
+
repository: string
|
|
56
|
+
createdAt: string
|
|
57
|
+
result: string
|
|
58
|
+
detail: string
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export interface Dashboard {
|
|
62
|
+
admins: AdminView[]
|
|
63
|
+
hasMoreAdmins: boolean
|
|
64
|
+
superAdmins: string[]
|
|
65
|
+
bindings: Array<{ groupId: string; repository: string; enabled: boolean }>
|
|
66
|
+
audits: AuditView[]
|
|
67
|
+
auditEnabled: boolean
|
|
68
|
+
auditRetentionDays: number
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export interface TokenStatus {
|
|
72
|
+
login: string
|
|
73
|
+
repositories: Array<{ repository: string; accessible: boolean; message: string }>
|
|
74
|
+
message: string
|
|
75
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/** Only deliberately authored messages may cross the chat / console boundary. */
|
|
2
|
+
export class BridgeError extends Error {
|
|
3
|
+
constructor(public readonly code: string, message: string, public readonly uncertain = false) {
|
|
4
|
+
super(message)
|
|
5
|
+
this.name = 'BridgeError'
|
|
6
|
+
}
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function publicError(error: unknown): BridgeError {
|
|
10
|
+
return error instanceof BridgeError
|
|
11
|
+
? error
|
|
12
|
+
: new BridgeError('internal', '内部服务或数据库暂不可用,操作未能确认完成。请联系部署者检查服务。')
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function redact(text: string, token: string): string {
|
|
16
|
+
if (token) text = text.split(token).join('[已隐藏敏感信息]')
|
|
17
|
+
return text.replace(/(?:gh[pousr]_[A-Za-z0-9_]+|github_pat_[A-Za-z0-9_]+)/g, '[已隐藏敏感信息]')
|
|
18
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { BridgeError } from './errors'
|
|
2
|
+
|
|
3
|
+
/** Bounded, instance-owned queue. A rejected task never poisons the next one. */
|
|
4
|
+
export class SerialQueue {
|
|
5
|
+
private tail: Promise<void> = Promise.resolve()
|
|
6
|
+
private pending = 0
|
|
7
|
+
private closed = false
|
|
8
|
+
|
|
9
|
+
constructor(private readonly capacity = 64) {}
|
|
10
|
+
|
|
11
|
+
async run<T>(task: () => Promise<T>): Promise<T> {
|
|
12
|
+
if (this.closed) throw new BridgeError('disposed', '插件正在停用,请稍后再试。')
|
|
13
|
+
if (this.pending >= this.capacity) throw new BridgeError('busy', '当前操作较多,请稍后再试。')
|
|
14
|
+
this.pending++
|
|
15
|
+
const previous = this.tail
|
|
16
|
+
let release!: () => void
|
|
17
|
+
this.tail = new Promise<void>(resolve => { release = resolve })
|
|
18
|
+
try {
|
|
19
|
+
await previous
|
|
20
|
+
if (this.closed) throw new BridgeError('disposed', '插件正在停用,请稍后再试。')
|
|
21
|
+
return await task()
|
|
22
|
+
} finally {
|
|
23
|
+
this.pending--
|
|
24
|
+
release()
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
close() { this.closed = true }
|
|
29
|
+
drain() { return this.tail }
|
|
30
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { RE2 } from 're2-wasm'
|
|
2
|
+
import type { Config } from '../config'
|
|
3
|
+
import { QQ_PATTERN } from '../config'
|
|
4
|
+
import type { Command } from '../types'
|
|
5
|
+
import { BridgeError } from './errors'
|
|
6
|
+
|
|
7
|
+
export class MessageParser {
|
|
8
|
+
private readonly reply: RE2
|
|
9
|
+
|
|
10
|
+
constructor(private readonly config: Config) {
|
|
11
|
+
const pattern = config.replyPattern || `^${config.replyPrefix}(?<number>\\d+)\\s+(?<body>[\\s\\S]+)$`
|
|
12
|
+
if (pattern.length > 1024 || !pattern.startsWith('^') || !pattern.endsWith('$') ||
|
|
13
|
+
!pattern.includes('(?<number>') || !pattern.includes('(?<body>')) {
|
|
14
|
+
throw new BridgeError('config-regex', '回复正则最多 1024 字符,须使用 ^ 和 $ 全文匹配,并声明 number 和 body 命名捕获组。')
|
|
15
|
+
}
|
|
16
|
+
try { this.reply = new RE2(pattern, 'u') } catch {
|
|
17
|
+
// Never expose the pattern: a misconfigured pattern could itself contain secrets.
|
|
18
|
+
throw new BridgeError('config-regex', '回复正则无效或使用了 RE2 不支持的语法(如回溯引用、前后查找)。')
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
parse(text: string): Command | null {
|
|
23
|
+
if (text.length > this.config.maxCommentLength + 4096) return null
|
|
24
|
+
const { adminPrefix, closePrefix, openPrefix } = this.config
|
|
25
|
+
// Reserve privileged command namespaces before trying any custom comment expression.
|
|
26
|
+
if (text.startsWith(adminPrefix) && /^(?:\s|$)/.test(text.slice(adminPrefix.length))) {
|
|
27
|
+
const parts = text.split(/\s+/)
|
|
28
|
+
if (parts[1] === 'confirm' && parts.length === 3 && /^[a-f0-9]{12}$/.test(parts[2])) {
|
|
29
|
+
return { type: 'confirm', code: parts[2] }
|
|
30
|
+
}
|
|
31
|
+
if (parts[1] === 'list' && (parts.length === 2 || (parts.length === 3 && /^[1-9][0-9]{0,5}$/.test(parts[2])))) {
|
|
32
|
+
return { type: 'admin-list', page: parts[2] ? Number(parts[2]) : 1 }
|
|
33
|
+
}
|
|
34
|
+
if (parts.length === 3 && QQ_PATTERN.test(parts[2])) {
|
|
35
|
+
if (parts[1] === 'add') return { type: 'admin-add', qq: parts[2] }
|
|
36
|
+
if (parts[1] === 'remove') return { type: 'admin-remove', qq: parts[2] }
|
|
37
|
+
}
|
|
38
|
+
return { type: 'invalid', message: `用法:${adminPrefix} add/remove QQ号;${adminPrefix} list [页码];${adminPrefix} confirm 确认码。` }
|
|
39
|
+
}
|
|
40
|
+
for (const [prefix, type] of [[closePrefix, 'close'], [openPrefix, 'open']] as const) {
|
|
41
|
+
if (!text.startsWith(prefix)) continue
|
|
42
|
+
const suffix = text.slice(prefix.length)
|
|
43
|
+
// Ordinary words starting with the prefix are not commands.
|
|
44
|
+
if (suffix && !/^[0-9\s]/.test(suffix)) continue
|
|
45
|
+
const number = this.number(suffix)
|
|
46
|
+
return number ? { type, number } : { type: 'invalid', message: `用法:${prefix}1003(编号须为正整数)。` }
|
|
47
|
+
}
|
|
48
|
+
const match = this.reply.exec(text)
|
|
49
|
+
// An alternative branch or a terminal newline must not defeat full matching.
|
|
50
|
+
if (match && match.index === 0 && match[0] === text) {
|
|
51
|
+
const number = this.number(match.groups?.number)
|
|
52
|
+
const body = match.groups?.body
|
|
53
|
+
if (!number) return { type: 'invalid', message: 'Issue / PR 编号必须是有效的正整数。' }
|
|
54
|
+
if (!body?.trim()) return { type: 'invalid', message: '评论正文不能为空。' }
|
|
55
|
+
if (body.length > this.config.maxCommentLength) return { type: 'invalid', message: `评论过长,最多 ${this.config.maxCommentLength} 字符。` }
|
|
56
|
+
if (!this.config.allowMultiline && /[\r\n]/.test(body)) return { type: 'invalid', message: '当前配置不允许多行评论。' }
|
|
57
|
+
if (/\u0000/.test(body)) return { type: 'invalid', message: '评论包含不支持的空字符。' }
|
|
58
|
+
return { type: 'comment', number, body }
|
|
59
|
+
}
|
|
60
|
+
if (!this.config.replyPattern && new RegExp(`^${this.config.replyPrefix}[0-9]+\\s*$`).test(text)) {
|
|
61
|
+
return { type: 'invalid', message: '评论正文不能为空。' }
|
|
62
|
+
}
|
|
63
|
+
return null
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
private number(value?: string): number | undefined {
|
|
67
|
+
if (!value || !/^[1-9][0-9]*$/.test(value)) return
|
|
68
|
+
const number = Number(value)
|
|
69
|
+
if (Number.isSafeInteger(number)) return number
|
|
70
|
+
}
|
|
71
|
+
}
|