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
package/src/config.ts
ADDED
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import { Schema } from 'koishi'
|
|
2
|
+
import { BridgeError } from './utils/errors'
|
|
3
|
+
|
|
4
|
+
export const QQ_PATTERN = /^[1-9][0-9]{4,19}$/
|
|
5
|
+
export const DEFAULT_TEMPLATE = '以下正文由 QQ 群成员通过 GitHub Bridge 提交。\n\n{body}'
|
|
6
|
+
|
|
7
|
+
export interface Binding {
|
|
8
|
+
groupId: string
|
|
9
|
+
owner: string
|
|
10
|
+
repo: string
|
|
11
|
+
enabled: boolean
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface Config {
|
|
15
|
+
token: string
|
|
16
|
+
apiBaseUrl: string
|
|
17
|
+
apiVersion: string
|
|
18
|
+
requestTimeout: number
|
|
19
|
+
readRetries: number
|
|
20
|
+
commentTemplate: string
|
|
21
|
+
bindings: Binding[]
|
|
22
|
+
replyPrefix: string
|
|
23
|
+
replyPattern: string
|
|
24
|
+
closePrefix: string
|
|
25
|
+
openPrefix: string
|
|
26
|
+
adminPrefix: string
|
|
27
|
+
superAdmins: string[]
|
|
28
|
+
enableAdminCommands: boolean
|
|
29
|
+
userCooldown: number
|
|
30
|
+
maxCommentLength: number
|
|
31
|
+
allowMultiline: boolean
|
|
32
|
+
auditEnabled: boolean
|
|
33
|
+
auditRetentionDays: number
|
|
34
|
+
dedupWindow: number
|
|
35
|
+
confirmHighRisk: boolean
|
|
36
|
+
confirmationTimeout: number
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const prefix = (value: string, description: string) => Schema.string().default(value).description(description)
|
|
40
|
+
const qq = () => Schema.string().pattern(QQ_PATTERN).description('真实数字 QQ 号,按字符串填写。')
|
|
41
|
+
|
|
42
|
+
export const Config: Schema<Partial<Config>, Config> = Schema.intersect([
|
|
43
|
+
Schema.object({
|
|
44
|
+
token: Schema.string().role('secret').default('').description('统一 GitHub Personal Access Token。支持 Fine-grained PAT;不要在 QQ 中发送。'),
|
|
45
|
+
apiBaseUrl: Schema.string().default('https://api.github.com').description('GitHub REST API 根地址;企业版通常为 https://主机/api/v3。必须使用 HTTPS(本机测试除外)。'),
|
|
46
|
+
apiVersion: Schema.string().default('2026-03-10').description('GitHub API 版本。企业版可按服务器支持情况修改。'),
|
|
47
|
+
requestTimeout: Schema.number().min(1000).max(60000).step(1).default(15000).description('单次请求超时(毫秒)。写入不会自动重试。'),
|
|
48
|
+
readRetries: Schema.number().min(0).max(3).step(1).default(2).description('只读请求遇到网络故障或 5xx 时的重试次数。'),
|
|
49
|
+
commentTemplate: Schema.string().role('textarea').default(DEFAULT_TEMPLATE).description('正文模板,必须且只能包含一次 {body}。固定身份头由插件生成,不可覆盖;原始正文原样替换。'),
|
|
50
|
+
}).description('GitHub 设置'),
|
|
51
|
+
Schema.object({
|
|
52
|
+
bindings: Schema.array(Schema.object({
|
|
53
|
+
groupId: qq().description('QQ群号;一个群仅能出现一次。'),
|
|
54
|
+
owner: Schema.string().required().description('仓库所属用户或组织,不填 URL。'),
|
|
55
|
+
repo: Schema.string().required().description('仓库名称,不填 URL 或 owner/。'),
|
|
56
|
+
enabled: Schema.boolean().default(true).description('启用此群的仓库绑定。'),
|
|
57
|
+
})).role('table').default([]).description('多个群可绑定同一仓库;未绑定或禁用的群完全忽略。'),
|
|
58
|
+
}).description('仓库绑定'),
|
|
59
|
+
Schema.object({
|
|
60
|
+
replyPrefix: prefix('re', '普通评论前缀,例如 re1003 内容。'),
|
|
61
|
+
replyPattern: Schema.string().role('textarea').default('').description('留空使用回复前缀。自定义 RE2 表达式须以 ^ 开始、$ 结束,并有命名组 (?<number>...) 和 (?<body>...)。不支持回溯引用与前后查找;固定使用 u 模式。'),
|
|
62
|
+
closePrefix: prefix('close', '关闭前缀,例如 close1003。'),
|
|
63
|
+
openPrefix: prefix('open', '重开前缀,例如 open1003。'),
|
|
64
|
+
adminPrefix: prefix('ghadmin', '管理员指令前缀,例如 ghadmin add 123456789。二次确认使用此前缀 confirm 确认码。'),
|
|
65
|
+
}).description('命令设置'),
|
|
66
|
+
Schema.object({
|
|
67
|
+
superAdmins: Schema.array(qq()).min(1).required().description('至少配置一个超级管理员 QQ。通过本页恢复控制权;QQ 指令不能撤销。'),
|
|
68
|
+
enableAdminCommands: Schema.boolean().default(true).description('允许在已绑定群使用管理员新增、撤销和列表命令。'),
|
|
69
|
+
}).description('独立权限设置'),
|
|
70
|
+
Schema.object({
|
|
71
|
+
userCooldown: Schema.number().min(0).max(3600000).step(1).default(5000).description('每位 QQ 用户的操作冷却(毫秒),跨群共用;确认指令不额外冷却。'),
|
|
72
|
+
maxCommentLength: Schema.number().min(1).max(30000).step(1).default(6000).description('评论正文最大字符长度(UTF-16 单元)。'),
|
|
73
|
+
allowMultiline: Schema.boolean().default(true).description('允许多行评论和 Markdown。'),
|
|
74
|
+
auditEnabled: Schema.boolean().default(true).description('保存操作审计到 Koishi 数据库;不存 Token、昵称或评论正文。'),
|
|
75
|
+
auditRetentionDays: Schema.number().min(1).max(365).step(1).default(30).description('审计保留天数,每小时自动清理过期记录。'),
|
|
76
|
+
dedupWindow: Schema.number().min(300000).max(604800000).step(1).default(86400000).description('消息去重时间窗口(毫秒),默认 24 小时。过期事件不执行;记录跨重启保留。'),
|
|
77
|
+
confirmHighRisk: Schema.boolean().default(true).description('关闭、重开、新增和撤销管理员前要求同一 QQ、同一群在限时内确认。'),
|
|
78
|
+
confirmationTimeout: Schema.number().min(10000).max(300000).step(1).default(60000).description('高风险操作确认有效期(毫秒);重载后失效。'),
|
|
79
|
+
}).description('安全与可靠性'),
|
|
80
|
+
])
|
|
81
|
+
|
|
82
|
+
export function validateConfig(config: Config) {
|
|
83
|
+
const invalid = (message: string): never => { throw new BridgeError('config', message) }
|
|
84
|
+
if (!config.token || config.token.length > 500 || /\s|[^\x21-\x7e]/.test(config.token)) {
|
|
85
|
+
invalid('请在后台填写有效格式的 GitHub Token(不可为空或包含空白)。')
|
|
86
|
+
}
|
|
87
|
+
let base: URL
|
|
88
|
+
try { base = new URL(config.apiBaseUrl) } catch { return invalid('GitHub API 地址不是有效 URL。') }
|
|
89
|
+
const loopback = ['localhost', '127.0.0.1', '[::1]'].includes(base.hostname)
|
|
90
|
+
if (base.protocol !== 'https:' && !(base.protocol === 'http:' && loopback)) invalid('GitHub API 必须使用 HTTPS。')
|
|
91
|
+
if (base.username || base.password || base.search || base.hash) invalid('GitHub API 地址不得包含用户名、密码、查询参数或片段。')
|
|
92
|
+
if (!/^\d{4}-\d{2}-\d{2}$/.test(config.apiVersion)) invalid('API 版本必须采用 YYYY-MM-DD 格式。')
|
|
93
|
+
const prefixes = [config.replyPrefix, config.closePrefix, config.openPrefix, config.adminPrefix]
|
|
94
|
+
if (prefixes.some(value => !/^[A-Za-z][A-Za-z0-9_-]{0,31}$/.test(value))) invalid('指令前缀须以英文字母开头,最多 32 位,仅含英文字母、数字、下划线和连字符。')
|
|
95
|
+
if (prefixes.some((p, i) => prefixes.some((q, j) => i !== j && p.startsWith(q)))) invalid('指令前缀不得相同或互为前缀。')
|
|
96
|
+
if (!config.superAdmins.length || config.superAdmins.some(id => !QQ_PATTERN.test(id))) invalid('必须配置至少一个有效的超级管理员 QQ。')
|
|
97
|
+
if (new Set(config.superAdmins).size !== config.superAdmins.length) invalid('超级管理员列表存在重复 QQ。')
|
|
98
|
+
const groups = new Set<string>()
|
|
99
|
+
if (config.bindings.length > 500) invalid('最多允许配置 500 个群绑定。')
|
|
100
|
+
for (const binding of config.bindings) {
|
|
101
|
+
if (!QQ_PATTERN.test(binding.groupId)) invalid('群绑定中存在无效的 QQ 群号。')
|
|
102
|
+
if (groups.has(binding.groupId)) invalid('同一 QQ 群只能绑定一次。')
|
|
103
|
+
groups.add(binding.groupId)
|
|
104
|
+
if (!/^[A-Za-z0-9][A-Za-z0-9-]{0,99}$/.test(binding.owner) ||
|
|
105
|
+
!/^[A-Za-z0-9_.-]{1,100}$/.test(binding.repo) || ['.', '..'].includes(binding.repo)) invalid('仓库 Owner 或名称不合法;请分别填写名称,不要填写 URL。')
|
|
106
|
+
}
|
|
107
|
+
if (config.commentTemplate.length > 4000 || config.commentTemplate.split('{body}').length !== 2) invalid('评论模板最多 4000 字符,并且必须且只能包含一次 {body}。')
|
|
108
|
+
if (/\{(?!body\})[^{}]*\}/.test(config.commentTemplate)) invalid('评论模板仅支持 {body} 占位符。')
|
|
109
|
+
if (config.commentTemplate.includes(config.token)) invalid('评论模板不能包含 GitHub Token。')
|
|
110
|
+
return config
|
|
111
|
+
}
|
package/src/console.ts
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { resolve } from 'node:path'
|
|
2
|
+
import type { Context } from 'koishi'
|
|
3
|
+
import type { Client } from '@koishijs/plugin-console'
|
|
4
|
+
import type {} from '@koishijs/plugin-auth'
|
|
5
|
+
import { QQ_PATTERN } from './config'
|
|
6
|
+
import type { Actor } from './types'
|
|
7
|
+
import type {} from './rpc'
|
|
8
|
+
import type { Bridge } from './handlers/commands'
|
|
9
|
+
import { BridgeError, publicError, redact } from './utils/errors'
|
|
10
|
+
|
|
11
|
+
/** Web deployer access is separate from the QQ administrator model. No QQ authority is used. */
|
|
12
|
+
export async function authenticateConsole(ctx: Context, client: Client): Promise<Actor> {
|
|
13
|
+
if (!ctx.get('auth')) throw new BridgeError('console-auth', '控制台登录服务未启用,管理接口已停用。')
|
|
14
|
+
const auth = client.auth
|
|
15
|
+
if (!auth || auth.expiredAt <= Date.now()) throw new BridgeError('console-auth', '请先以部署管理员账号登录控制台。')
|
|
16
|
+
const [token] = await ctx.database.get('token', { id: auth.id, token: auth.token, expiredAt: { $gt: Date.now() } }, ['id'])
|
|
17
|
+
const [user] = await ctx.database.get('user', { id: auth.id }, ['id', 'authority'])
|
|
18
|
+
if (!token || !user || user.authority < 5) throw new BridgeError('console-auth', '只有已登录的控制台部署管理员(等级 5)可以维护此插件。')
|
|
19
|
+
const [binding] = await ctx.database.get('binding', { aid: user.id, platform: 'onebot' }, { limit: 1 })
|
|
20
|
+
return {
|
|
21
|
+
source: 'console', id: String(user.id), qq: binding && QQ_PATTERN.test(binding.pid) ? binding.pid : '',
|
|
22
|
+
nickname: '', groupId: '', repository: '',
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function registerConsole(ctx: Context, bridge: Bridge) {
|
|
27
|
+
// Without the official auth service, even the listeners do not exist.
|
|
28
|
+
ctx.inject(['console', 'auth'], ctx => {
|
|
29
|
+
ctx.console.addEntry({ dev: resolve(__dirname, '../client/index.ts'), prod: resolve(__dirname, '../dist') })
|
|
30
|
+
const safe = async <T>(task: () => Promise<T>): Promise<T> => {
|
|
31
|
+
try { return await task() } catch (error) {
|
|
32
|
+
// Console logs thrown objects, so replace them rather than rethrowing raw errors.
|
|
33
|
+
throw new Error(redact(publicError(error).message, bridge.config.token))
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
ctx.console.addListener('qq-github-bridge/dashboard', function (page = 1) {
|
|
37
|
+
return safe(async () => {
|
|
38
|
+
if (!Number.isSafeInteger(page) || page < 1 || page > 100000) throw new BridgeError('input', '页码无效。')
|
|
39
|
+
return bridge.dashboard(() => authenticateConsole(ctx, this), page)
|
|
40
|
+
})
|
|
41
|
+
}, { authority: 5 })
|
|
42
|
+
ctx.console.addListener('qq-github-bridge/admin', function (action, qq) {
|
|
43
|
+
return safe(async () => {
|
|
44
|
+
if (!['add', 'remove'].includes(action) || typeof qq !== 'string' || !QQ_PATTERN.test(qq)) throw new BridgeError('input', '管理员操作或 QQ 号无效。')
|
|
45
|
+
return bridge.manage(() => authenticateConsole(ctx, this), action, qq)
|
|
46
|
+
})
|
|
47
|
+
}, { authority: 5 })
|
|
48
|
+
ctx.console.addListener('qq-github-bridge/validate', function (groupId) {
|
|
49
|
+
return safe(async () => {
|
|
50
|
+
if (groupId !== undefined && (typeof groupId !== 'string' || !QQ_PATTERN.test(groupId))) throw new BridgeError('input', 'QQ群号无效。')
|
|
51
|
+
return bridge.validateToken(() => authenticateConsole(ctx, this), groupId)
|
|
52
|
+
})
|
|
53
|
+
}, { authority: 5 })
|
|
54
|
+
// Console 5.30 addListener() assigns into a dictionary and does NOT register
|
|
55
|
+
// a disposal effect. Explicitly remove only this activation's own listeners.
|
|
56
|
+
const listeners = ctx.console.listeners
|
|
57
|
+
const owned = ['dashboard', 'admin', 'validate'].map(name => {
|
|
58
|
+
const key = `qq-github-bridge/${name}`
|
|
59
|
+
return [key, listeners[key]] as const
|
|
60
|
+
})
|
|
61
|
+
ctx.on('dispose', () => {
|
|
62
|
+
for (const [key, listener] of owned) if (listeners[key] === listener) delete listeners[key]
|
|
63
|
+
})
|
|
64
|
+
})
|
|
65
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import type { Context } from 'koishi'
|
|
2
|
+
|
|
3
|
+
export interface AdminRow { qq: string; addedBy: string; createdAt: Date }
|
|
4
|
+
export interface EventRow { id: string; expiresAt: Date }
|
|
5
|
+
export interface CooldownRow { qq: string; expiresAt: Date }
|
|
6
|
+
export interface AuditRow {
|
|
7
|
+
id: string
|
|
8
|
+
actorId: string
|
|
9
|
+
actorQQ: string
|
|
10
|
+
source: string
|
|
11
|
+
action: string
|
|
12
|
+
target: string
|
|
13
|
+
groupId: string
|
|
14
|
+
repository: string
|
|
15
|
+
createdAt: Date
|
|
16
|
+
result: string
|
|
17
|
+
detail: string
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
declare module 'koishi' {
|
|
21
|
+
interface Tables {
|
|
22
|
+
qgh_admin: AdminRow
|
|
23
|
+
qgh_event: EventRow
|
|
24
|
+
qgh_cooldown: CooldownRow
|
|
25
|
+
qgh_audit: AuditRow
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Additive schema, stable keys. Future renamed fields should use Minato's legacy migration. */
|
|
30
|
+
export function registerModels(ctx: Context) {
|
|
31
|
+
ctx.model.extend('qgh_admin', {
|
|
32
|
+
qq: 'string(20)', addedBy: 'string(64)', createdAt: 'timestamp',
|
|
33
|
+
}, { primary: 'qq' })
|
|
34
|
+
ctx.model.extend('qgh_event', {
|
|
35
|
+
id: 'string(64)', expiresAt: 'timestamp',
|
|
36
|
+
}, { primary: 'id' })
|
|
37
|
+
ctx.model.extend('qgh_cooldown', {
|
|
38
|
+
qq: 'string(20)', expiresAt: 'timestamp',
|
|
39
|
+
}, { primary: 'qq' })
|
|
40
|
+
ctx.model.extend('qgh_audit', {
|
|
41
|
+
id: 'string(36)', actorId: 'string(64)', actorQQ: 'string(20)', source: 'string(16)',
|
|
42
|
+
action: 'string(32)', target: 'string(100)', groupId: 'string(20)', repository: 'string(201)',
|
|
43
|
+
createdAt: 'timestamp', result: 'string(24)', detail: 'string(255)',
|
|
44
|
+
}, { primary: 'id' })
|
|
45
|
+
}
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
import { randomBytes } from 'node:crypto'
|
|
2
|
+
import type { Context } from 'koishi'
|
|
3
|
+
import type { Config } from '../config'
|
|
4
|
+
import type { Actor, Command, Dashboard, IncomingMessage, MutatingCommand, Outcome } from '../types'
|
|
5
|
+
import { GitHub } from '../services/github'
|
|
6
|
+
import { Permissions } from '../services/permission'
|
|
7
|
+
import { RepositoryResolver } from '../services/repository'
|
|
8
|
+
import { Storage } from '../services/storage'
|
|
9
|
+
import { MessageParser } from '../utils/parser'
|
|
10
|
+
import { SerialQueue } from '../utils/lock'
|
|
11
|
+
import { BridgeError, publicError, redact } from '../utils/errors'
|
|
12
|
+
|
|
13
|
+
interface Confirmation { command: MutatingCommand; code: string; expires: number; actorId: string; groupId: string; repository: string }
|
|
14
|
+
|
|
15
|
+
export class Bridge {
|
|
16
|
+
readonly repositories: RepositoryResolver
|
|
17
|
+
private readonly parser: MessageParser
|
|
18
|
+
private readonly permissions: Permissions
|
|
19
|
+
private readonly storage: Storage
|
|
20
|
+
private readonly github: GitHub
|
|
21
|
+
// One bounded queue serializes permission changes and state writes in this instance.
|
|
22
|
+
// It also prevents different bound groups racing against the same target.
|
|
23
|
+
private readonly queue = new SerialQueue()
|
|
24
|
+
private readonly confirmations = new Map<string, Confirmation>()
|
|
25
|
+
|
|
26
|
+
constructor(private readonly ctx: Context, readonly config: Config) {
|
|
27
|
+
this.repositories = new RepositoryResolver(config.bindings)
|
|
28
|
+
this.parser = new MessageParser(config)
|
|
29
|
+
this.permissions = new Permissions(ctx, config)
|
|
30
|
+
this.storage = new Storage(ctx, config)
|
|
31
|
+
this.github = new GitHub(ctx, config)
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
async dispose() {
|
|
35
|
+
this.queue.close()
|
|
36
|
+
this.github.dispose()
|
|
37
|
+
this.confirmations.clear()
|
|
38
|
+
await this.queue.drain()
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
private target(command: Command) {
|
|
42
|
+
if ('number' in command) return String(command.number)
|
|
43
|
+
if ('qq' in command) return command.qq
|
|
44
|
+
return ''
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
private async authorize(actor: Actor, command: Command) {
|
|
48
|
+
if (command.type.startsWith('admin-') && actor.source === 'qq' && !this.config.enableAdminCommands) {
|
|
49
|
+
throw new BridgeError('admin-disabled', '部署者已关闭 QQ 管理员指令,请使用后台管理。')
|
|
50
|
+
}
|
|
51
|
+
if (command.type !== 'comment') await this.permissions.requireAdmin(actor, command.type === 'admin-remove')
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
private async audited(actor: Actor, command: Command, task: () => Promise<Outcome>): Promise<string> {
|
|
55
|
+
const id = await this.storage.begin(actor, command.type, this.target(command))
|
|
56
|
+
try {
|
|
57
|
+
const outcome = await task()
|
|
58
|
+
const saved = await this.storage.finish(id, outcome.result || 'success', 'completed')
|
|
59
|
+
return outcome.message + (saved ? '' : '\n操作已完成,但审计结果更新失败;后台记录暂为 pending。')
|
|
60
|
+
} catch (error) {
|
|
61
|
+
const safe = publicError(error)
|
|
62
|
+
await this.storage.finish(id, safe.uncertain ? 'unknown' : 'failed', safe.code)
|
|
63
|
+
throw safe
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
private async perform(message: IncomingMessage, command: MutatingCommand): Promise<Outcome> {
|
|
68
|
+
await this.authorize(message.actor, command)
|
|
69
|
+
if (command.type === 'comment') return this.github.comment(message.repository, command.number, message.actor, command.body, message.eventKey)
|
|
70
|
+
if (command.type === 'close' || command.type === 'open') return this.github.state(message.repository, command.number, command.type === 'close' ? 'closed' : 'open')
|
|
71
|
+
if (command.type === 'admin-add') return this.permissions.add(message.actor, command.qq)
|
|
72
|
+
return this.permissions.remove(message.actor, command.qq)
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
private confirmKey(message: IncomingMessage) { return `${message.actor.qq}:${message.actor.groupId}` }
|
|
76
|
+
|
|
77
|
+
private pruneConfirmations() {
|
|
78
|
+
for (const [key, pending] of this.confirmations) if (pending.expires < Date.now()) this.confirmations.delete(key)
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
async handle(message: IncomingMessage): Promise<string | null> {
|
|
82
|
+
const command = this.parser.parse(message.text)
|
|
83
|
+
if (!command) return null
|
|
84
|
+
try {
|
|
85
|
+
const result = await this.queue.run(async () => {
|
|
86
|
+
if (Date.now() - message.timestamp > this.config.dedupWindow) return ''
|
|
87
|
+
if (!await this.storage.claim(message)) return ''
|
|
88
|
+
if (command.type === 'invalid') return command.message
|
|
89
|
+
this.pruneConfirmations()
|
|
90
|
+
if (command.type === 'confirm') {
|
|
91
|
+
const key = this.confirmKey(message)
|
|
92
|
+
const pending = this.confirmations.get(key)
|
|
93
|
+
if (!pending || pending.code !== command.code || pending.actorId !== message.actor.qq ||
|
|
94
|
+
pending.groupId !== message.actor.groupId || pending.repository !== message.repository.fullName) {
|
|
95
|
+
throw new BridgeError('confirmation', '确认码无效或已过期。请由原操作者在原群重新发起操作。')
|
|
96
|
+
}
|
|
97
|
+
// Consume before any asynchronous work; a failed / uncertain write cannot reuse it.
|
|
98
|
+
this.confirmations.delete(key)
|
|
99
|
+
return this.audited(message.actor, pending.command, () => this.perform(message, pending.command))
|
|
100
|
+
}
|
|
101
|
+
await this.storage.cooldown(message.actor.qq)
|
|
102
|
+
return this.audited(message.actor, command, async () => {
|
|
103
|
+
await this.authorize(message.actor, command)
|
|
104
|
+
if (command.type === 'admin-list') {
|
|
105
|
+
const admins = await this.permissions.list((command.page - 1) * 20, 21)
|
|
106
|
+
const rows = admins.slice(0, 20).map(row => `${row.qq}(添加者:${row.addedBy};${row.createdAt.toISOString()})`)
|
|
107
|
+
return { message: `超级管理员:${this.config.superAdmins.join('、')}\n普通管理员(第 ${command.page} 页):\n${rows.join('\n') || '无'}${admins.length > 20 ? `\n下一页:${this.config.adminPrefix} list ${command.page + 1}` : ''}` }
|
|
108
|
+
}
|
|
109
|
+
if (command.type !== 'comment' && this.config.confirmHighRisk) {
|
|
110
|
+
if (this.confirmations.size >= 1000) throw new BridgeError('busy', '待确认操作过多,请稍后再试。')
|
|
111
|
+
const code = randomBytes(6).toString('hex')
|
|
112
|
+
this.confirmations.set(this.confirmKey(message), {
|
|
113
|
+
command, code, expires: Date.now() + this.config.confirmationTimeout,
|
|
114
|
+
actorId: message.actor.qq, groupId: message.actor.groupId, repository: message.repository.fullName,
|
|
115
|
+
})
|
|
116
|
+
const action = { close: '关闭', open: '重新打开', 'admin-add': '添加管理员', 'admin-remove': '撤销管理员' }[command.type]
|
|
117
|
+
return {
|
|
118
|
+
message: `待确认:${action} ${this.target(command)}${'number' in command ? `(${message.repository.fullName})` : ''}。\n请在 ${this.config.confirmationTimeout / 1000} 秒内由 QQ ${message.actor.qq} 在本群发送:\n${this.config.adminPrefix} confirm ${code}`,
|
|
119
|
+
result: 'noop',
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
return this.perform(message, command)
|
|
123
|
+
})
|
|
124
|
+
})
|
|
125
|
+
return redact(result, this.config.token)
|
|
126
|
+
} catch (error) {
|
|
127
|
+
const safe = publicError(error)
|
|
128
|
+
// Do not give HTTP errors (including headers) to Koishi's logger.
|
|
129
|
+
this.ctx.logger('qq-github-bridge').warn('操作未完成,错误类别:%s', safe.code)
|
|
130
|
+
return redact(safe.message, this.config.token)
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
async dashboard(authenticate: () => Promise<Actor>, page: number): Promise<Dashboard> {
|
|
135
|
+
return this.queue.run(async () => {
|
|
136
|
+
await authenticate()
|
|
137
|
+
const rows = await this.permissions.list((page - 1) * 50, 51)
|
|
138
|
+
const audits = await this.ctx.database.get('qgh_audit', {}, { sort: { createdAt: 'desc', id: 'desc' }, limit: 100 })
|
|
139
|
+
return {
|
|
140
|
+
admins: rows.slice(0, 50).map(row => ({ ...row, createdAt: row.createdAt.toISOString() })),
|
|
141
|
+
hasMoreAdmins: rows.length > 50,
|
|
142
|
+
superAdmins: [...this.config.superAdmins],
|
|
143
|
+
bindings: this.config.bindings.map(b => ({ groupId: b.groupId, repository: `${b.owner}/${b.repo}`, enabled: b.enabled })),
|
|
144
|
+
audits: audits.map(row => ({ ...row, createdAt: row.createdAt.toISOString() })),
|
|
145
|
+
auditEnabled: this.config.auditEnabled, auditRetentionDays: this.config.auditRetentionDays,
|
|
146
|
+
}
|
|
147
|
+
})
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
async manage(authenticate: () => Promise<Actor>, action: 'add' | 'remove', qq: string) {
|
|
151
|
+
return this.queue.run(async () => {
|
|
152
|
+
const actor = await authenticate()
|
|
153
|
+
const command: Command = { type: action === 'add' ? 'admin-add' : 'admin-remove', qq }
|
|
154
|
+
return this.audited(actor, command, () => action === 'add' ? this.permissions.add(actor, qq) : this.permissions.remove(actor, qq))
|
|
155
|
+
})
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
async validateToken(authenticate: () => Promise<Actor>, groupId?: string) {
|
|
159
|
+
return this.queue.run(async () => {
|
|
160
|
+
await authenticate()
|
|
161
|
+
const repository = groupId ? this.repositories.resolve(groupId) : undefined
|
|
162
|
+
if (groupId && !repository) throw new BridgeError('binding', '所选群没有启用的仓库绑定。')
|
|
163
|
+
return this.github.validate(repository)
|
|
164
|
+
})
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
async cleanup() {
|
|
168
|
+
try {
|
|
169
|
+
await this.queue.run(async () => {
|
|
170
|
+
this.pruneConfirmations()
|
|
171
|
+
await this.storage.cleanup()
|
|
172
|
+
})
|
|
173
|
+
} catch {
|
|
174
|
+
this.ctx.logger('qq-github-bridge').warn('过期记录清理未完成,将在下一周期重试。')
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto'
|
|
2
|
+
import type { Session } from 'koishi'
|
|
3
|
+
import { QQ_PATTERN } from '../config'
|
|
4
|
+
import type { IncomingMessage } from '../types'
|
|
5
|
+
import type { RepositoryResolver } from '../services/repository'
|
|
6
|
+
|
|
7
|
+
/** Narrow subset of the trusted OneBot transport payload, not message-supplied JSON. */
|
|
8
|
+
interface OneBotMessage {
|
|
9
|
+
post_type?: string
|
|
10
|
+
message_type?: string
|
|
11
|
+
sub_type?: string
|
|
12
|
+
anonymous?: unknown
|
|
13
|
+
self_id?: number | string
|
|
14
|
+
user_id?: number | string
|
|
15
|
+
group_id?: number | string
|
|
16
|
+
message_id?: number | string
|
|
17
|
+
time?: number
|
|
18
|
+
sender?: { user_id?: number | string; nickname?: string; card?: string }
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function numericId(value: unknown): string | undefined {
|
|
22
|
+
if (typeof value === 'number' && !Number.isSafeInteger(value)) return
|
|
23
|
+
if (typeof value !== 'string' && typeof value !== 'number') return
|
|
24
|
+
const text = String(value)
|
|
25
|
+
if (QQ_PATTERN.test(text)) return text
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function readMessage(session: Session, resolver: RepositoryResolver, window: number, now = Date.now()): IncomingMessage | null {
|
|
29
|
+
// Satori 4 normalizes the legacy 'message' event to 'message-created' at dispatch.
|
|
30
|
+
if (session.platform !== 'onebot' || !['message', 'message-created'].includes(session.type) || session.isDirect || !session.guildId) return null
|
|
31
|
+
// setInternal('onebot', payload) is how the official OneBot adapter exposes raw events.
|
|
32
|
+
const raw = (session as Session & { onebot?: OneBotMessage }).onebot
|
|
33
|
+
if (!raw || raw.post_type !== 'message' || raw.message_type !== 'group' || raw.anonymous || raw.sub_type === 'anonymous') return null
|
|
34
|
+
const qq = numericId(raw.user_id)
|
|
35
|
+
const sender = numericId(raw.sender?.user_id)
|
|
36
|
+
const groupId = numericId(raw.group_id)
|
|
37
|
+
if (!qq || qq !== sender || qq !== session.userId || !groupId || groupId !== session.guildId || groupId !== session.channelId) return null
|
|
38
|
+
if (numericId(raw.self_id) !== session.selfId || qq === session.selfId) return null
|
|
39
|
+
if (session.bot.ctx.bots.some(bot => bot.platform === 'onebot' && bot.selfId === qq)) return null
|
|
40
|
+
const repository = resolver.resolve(groupId)
|
|
41
|
+
if (!repository) return null
|
|
42
|
+
const messageId = raw.message_id
|
|
43
|
+
if ((typeof messageId !== 'string' && typeof messageId !== 'number') ||
|
|
44
|
+
!/^-?[0-9]{1,30}$/.test(String(messageId)) || (typeof messageId === 'number' && !Number.isSafeInteger(messageId))) return null
|
|
45
|
+
if (String(messageId) !== session.messageId) return null
|
|
46
|
+
const timestamp = typeof raw.time === 'number' && Number.isSafeInteger(raw.time) ? raw.time * 1000 : 0
|
|
47
|
+
if (!timestamp || now - timestamp > window || timestamp - now > 60000) return null
|
|
48
|
+
// Parse decoded text elements, never serialized Satori markup or raw CQ codes.
|
|
49
|
+
// Attachments / quotes / mentions are ignored as a whole so nothing is silently discarded.
|
|
50
|
+
if (session.quote || !session.elements?.length || session.elements.some(element => element.type !== 'text')) return null
|
|
51
|
+
const text = session.elements.map(element => element.attrs.content as string).join('')
|
|
52
|
+
const eventKey = createHash('sha256').update(`onebot:${groupId}:${messageId}`).digest('hex')
|
|
53
|
+
return {
|
|
54
|
+
actor: {
|
|
55
|
+
source: 'qq', id: qq, qq, groupId, repository: repository.fullName,
|
|
56
|
+
nickname: raw.sender?.nickname || raw.sender?.card || qq,
|
|
57
|
+
},
|
|
58
|
+
repository, eventKey, timestamp, text,
|
|
59
|
+
}
|
|
60
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { Context, h } from 'koishi'
|
|
2
|
+
import { Config, validateConfig } from './config'
|
|
3
|
+
import { registerModels } from './database/models'
|
|
4
|
+
import { registerConsole } from './console'
|
|
5
|
+
import { Bridge } from './handlers/commands'
|
|
6
|
+
import { readMessage } from './handlers/message'
|
|
7
|
+
|
|
8
|
+
export { Config } from './config'
|
|
9
|
+
export const name = 'qq-github-bridge'
|
|
10
|
+
export const inject = ['database', 'http']
|
|
11
|
+
export const usage = `
|
|
12
|
+
通过 OneBot / NapCat QQ 群评论、关闭和重开 GitHub Issue / PR。
|
|
13
|
+
|
|
14
|
+
1. 启用持久数据库(推荐 database-sqlite)、OneBot 适配器。
|
|
15
|
+
2. 填写统一 GitHub Token、至少一位超级管理员、群与仓库绑定。
|
|
16
|
+
3. 启用官方 console 和 auth,并用部署管理员账号登录,左侧即可打开「QQ GitHub Bridge」维护管理员、查看审计和验证 Token。
|
|
17
|
+
|
|
18
|
+
默认高风险操作需要同一 QQ 在原群发送确认码。详细命令与权限说明见 README。
|
|
19
|
+
`
|
|
20
|
+
|
|
21
|
+
export function apply(ctx: Context, config: Config) {
|
|
22
|
+
validateConfig(config)
|
|
23
|
+
registerModels(ctx)
|
|
24
|
+
const bridge = new Bridge(ctx, config)
|
|
25
|
+
ctx.middleware(async (session, next) => {
|
|
26
|
+
const message = readMessage(session, bridge.repositories, config.dedupWindow)
|
|
27
|
+
if (!message) return next()
|
|
28
|
+
const reply = await bridge.handle(message)
|
|
29
|
+
if (reply === null) return next()
|
|
30
|
+
// Treat responses as literal text, so API links / database fields cannot inject CQ elements.
|
|
31
|
+
if (reply) return h.text(reply)
|
|
32
|
+
}, true)
|
|
33
|
+
registerConsole(ctx, bridge)
|
|
34
|
+
ctx.on('ready', () => bridge.cleanup())
|
|
35
|
+
ctx.setInterval(() => void bridge.cleanup(), 3600000)
|
|
36
|
+
ctx.on('dispose', () => bridge.dispose())
|
|
37
|
+
}
|
package/src/rpc.ts
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { Client } from '@koishijs/plugin-console'
|
|
2
|
+
import type { Dashboard, TokenStatus } from './types'
|
|
3
|
+
|
|
4
|
+
declare module '@koishijs/console' {
|
|
5
|
+
interface Events {
|
|
6
|
+
'qq-github-bridge/dashboard'(this: Client, page?: number): Promise<Dashboard>
|
|
7
|
+
'qq-github-bridge/admin'(this: Client, action: 'add' | 'remove', qq: string): Promise<string>
|
|
8
|
+
'qq-github-bridge/validate'(this: Client, groupId?: string): Promise<TokenStatus>
|
|
9
|
+
}
|
|
10
|
+
}
|