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.
Files changed (74) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +167 -2
  3. package/client/env.d.ts +5 -0
  4. package/client/index.ts +6 -0
  5. package/client/page.vue +164 -0
  6. package/client/tsconfig.json +15 -0
  7. package/dist/index.js +1 -0
  8. package/dist/style.css +1 -0
  9. package/docs/ARCHITECTURE.md +100 -0
  10. package/docs/RELEASING.md +68 -0
  11. package/docs/RESEARCH.md +37 -0
  12. package/docs/REVIEW.md +48 -0
  13. package/docs/TESTING.md +92 -0
  14. package/lib/config.d.ts +35 -0
  15. package/lib/config.js +99 -0
  16. package/lib/config.js.map +1 -0
  17. package/lib/console.d.ts +7 -0
  18. package/lib/console.js +73 -0
  19. package/lib/console.js.map +1 -0
  20. package/lib/database/models.d.ts +37 -0
  21. package/lib/database/models.js +21 -0
  22. package/lib/database/models.js.map +1 -0
  23. package/lib/handlers/commands.d.ts +28 -0
  24. package/lib/handlers/commands.js +184 -0
  25. package/lib/handlers/commands.js.map +1 -0
  26. package/lib/handlers/message.d.ts +4 -0
  27. package/lib/handlers/message.js +58 -0
  28. package/lib/handlers/message.js.map +1 -0
  29. package/lib/index.d.ts +7 -0
  30. package/lib/index.js +44 -0
  31. package/lib/index.js.map +1 -0
  32. package/lib/rpc.d.ts +8 -0
  33. package/lib/rpc.js +3 -0
  34. package/lib/rpc.js.map +1 -0
  35. package/lib/services/github.d.ts +22 -0
  36. package/lib/services/github.js +201 -0
  37. package/lib/services/github.js.map +1 -0
  38. package/lib/services/permission.d.ts +12 -0
  39. package/lib/services/permission.js +56 -0
  40. package/lib/services/permission.js.map +1 -0
  41. package/lib/services/repository.d.ts +8 -0
  42. package/lib/services/repository.js +22 -0
  43. package/lib/services/repository.js.map +1 -0
  44. package/lib/services/storage.d.ts +13 -0
  45. package/lib/services/storage.js +75 -0
  46. package/lib/services/storage.js.map +1 -0
  47. package/lib/types.d.ts +93 -0
  48. package/lib/types.js +3 -0
  49. package/lib/types.js.map +1 -0
  50. package/lib/utils/errors.d.ts +8 -0
  51. package/lib/utils/errors.js +28 -0
  52. package/lib/utils/errors.js.map +1 -0
  53. package/lib/utils/lock.d.ts +11 -0
  54. package/lib/utils/lock.js +38 -0
  55. package/lib/utils/lock.js.map +1 -0
  56. package/lib/utils/parser.d.ts +9 -0
  57. package/lib/utils/parser.js +87 -0
  58. package/lib/utils/parser.js.map +1 -0
  59. package/package.json +99 -4
  60. package/src/config.ts +111 -0
  61. package/src/console.ts +65 -0
  62. package/src/database/models.ts +45 -0
  63. package/src/handlers/commands.ts +177 -0
  64. package/src/handlers/message.ts +60 -0
  65. package/src/index.ts +37 -0
  66. package/src/rpc.ts +10 -0
  67. package/src/services/github.ts +189 -0
  68. package/src/services/permission.ts +47 -0
  69. package/src/services/repository.ts +22 -0
  70. package/src/services/storage.ts +67 -0
  71. package/src/types.ts +75 -0
  72. package/src/utils/errors.ts +18 -0
  73. package/src/utils/lock.ts +30 -0
  74. 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
+ }