koishi-plugin-guild-request-approval 0.0.0-stage → 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/index.js +427 -0
- package/package.json +44 -4
- package/readme.md +43 -0
- package/README.md +0 -3
package/index.js
ADDED
|
@@ -0,0 +1,427 @@
|
|
|
1
|
+
// ESM 模块(见 package.json 的 "type": "module")。想用 CommonJS 就把下面这行换成
|
|
2
|
+
// const { Schema } = require('koishi'),再把两个 export const 改成 module.exports = { ... }
|
|
3
|
+
import { Schema } from 'koishi'
|
|
4
|
+
|
|
5
|
+
export const name = 'guild-request-approval'
|
|
6
|
+
|
|
7
|
+
// 故意不 inject database / http:所有状态放内存,装上就能跑,进程重启后挂起的申请会丢。
|
|
8
|
+
export const inject = []
|
|
9
|
+
|
|
10
|
+
export const Config = Schema.object({
|
|
11
|
+
mode: Schema.union([
|
|
12
|
+
Schema.const('accept').description('自动通过'),
|
|
13
|
+
Schema.const('reject').description('自动拒绝'),
|
|
14
|
+
Schema.const('manual').description('转发给管理员,人工回复审批'),
|
|
15
|
+
Schema.const('auto').description('按条件筛选,不满足时走 fallback'),
|
|
16
|
+
]).default('manual').description('审批模式'),
|
|
17
|
+
|
|
18
|
+
target: Schema.string().default('')
|
|
19
|
+
.description('人工审批的转发目标:`group:123456`、`private:10001`,只写数字默认当群号。'),
|
|
20
|
+
|
|
21
|
+
admins: Schema.array(Schema.string()).default([])
|
|
22
|
+
.description('有权回复审批的管理员 QQ;留空表示“转发目标所在会话里的任何人都能批”。'),
|
|
23
|
+
|
|
24
|
+
timeout: Schema.natural().default(300).description('人工审批等待时长(秒),0 = 永不超时。'),
|
|
25
|
+
|
|
26
|
+
timeoutAction: Schema.union([
|
|
27
|
+
Schema.const('reject').description('自动拒绝'),
|
|
28
|
+
Schema.const('accept').description('自动通过'),
|
|
29
|
+
Schema.const('none').description('不处理,留在 QQ 那边自然过期'),
|
|
30
|
+
]).default('reject').description('超时后的动作'),
|
|
31
|
+
|
|
32
|
+
minLevel: Schema.natural().default(0).description('auto 模式:最低 QQ 等级,0 = 不检查。'),
|
|
33
|
+
|
|
34
|
+
minAgeYears: Schema.number().default(0).description('auto 模式:最短注册年限(可写 0.5),0 = 不检查。'),
|
|
35
|
+
|
|
36
|
+
unknownAs: Schema.union([
|
|
37
|
+
Schema.const('fail').description('当作不满足,转 fallback'),
|
|
38
|
+
Schema.const('pass').description('当作满足,放行'),
|
|
39
|
+
]).default('fail').description('auto 模式拿不到等级/注册时间时怎么办'),
|
|
40
|
+
|
|
41
|
+
fallback: Schema.union([
|
|
42
|
+
Schema.const('manual').description('转人工审批'),
|
|
43
|
+
Schema.const('reject').description('直接拒绝'),
|
|
44
|
+
]).default('manual').description('auto 模式条件不满足时的去向'),
|
|
45
|
+
|
|
46
|
+
maxPending: Schema.natural().default(200).description('同时挂起的申请上限,超了就先结算最旧的。'),
|
|
47
|
+
|
|
48
|
+
acceptComment: Schema.string().default('').description('通过时的附言,可空。'),
|
|
49
|
+
|
|
50
|
+
rejectComment: Schema.string().default('').description('拒绝 / 超时拒绝的默认理由。'),
|
|
51
|
+
|
|
52
|
+
forwardTemplate: Schema.string().role('textarea').default(
|
|
53
|
+
'【入群申请 #{id}】\n群号:{guildId}\n申请人:{userId}\n附言:{comment}\n{reason}\n时间:{time}\n\n回复「同意 {id}」或「拒绝 {id} 理由」',
|
|
54
|
+
).description('转发文案,支持 {id} {guildId} {userId} {comment} {reason} {time}'),
|
|
55
|
+
|
|
56
|
+
replyOnResult: Schema.boolean().default(true).description('处理完是否在回复处回执一句。'),
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
const MODES = ['accept', 'reject', 'manual', 'auto']
|
|
60
|
+
const YEAR_MS = 365.25 * 24 * 3600 * 1000
|
|
61
|
+
|
|
62
|
+
// `group:123` / `123` / `private:456` / `qq:789`(中文冒号也认)
|
|
63
|
+
function parseTarget(raw) {
|
|
64
|
+
const s = String(raw ?? '').trim()
|
|
65
|
+
if (!s) return { type: 'group', id: '' }
|
|
66
|
+
const m = /^(group|private|user|qq|guild|channel)\s*[::]\s*(.+)$/i.exec(s)
|
|
67
|
+
if (!m) return { type: 'group', id: s }
|
|
68
|
+
const type = /^(private|user|qq)$/i.test(m[1]) ? 'private' : 'group'
|
|
69
|
+
return { type, id: m[2].trim() }
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function firstNumber(obj, keys) {
|
|
73
|
+
if (!obj) return null
|
|
74
|
+
for (const key of keys) {
|
|
75
|
+
const v = obj[key]
|
|
76
|
+
if (v === null || v === undefined || v === '') continue
|
|
77
|
+
const n = Number(v)
|
|
78
|
+
if (Number.isFinite(n)) return n
|
|
79
|
+
}
|
|
80
|
+
return null
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// 秒 / 毫秒 / YYYYMMDD / 日期字符串,都当成时间戳吃掉
|
|
84
|
+
function toTimestamp(input) {
|
|
85
|
+
if (input === null || input === undefined || input === '') return null
|
|
86
|
+
if (input instanceof Date) return Number.isNaN(input.getTime()) ? null : input.getTime()
|
|
87
|
+
const n = Number(input)
|
|
88
|
+
if (Number.isFinite(n)) {
|
|
89
|
+
if (n > 1e12) return n
|
|
90
|
+
if (n > 1e9) return n * 1000
|
|
91
|
+
if (n >= 19000101 && n <= 29991231) {
|
|
92
|
+
const s = String(Math.trunc(n))
|
|
93
|
+
return new Date(+s.slice(0, 4), +s.slice(4, 6) - 1, +s.slice(6, 8)).getTime()
|
|
94
|
+
}
|
|
95
|
+
return null
|
|
96
|
+
}
|
|
97
|
+
const t = Date.parse(String(input))
|
|
98
|
+
return Number.isNaN(t) ? null : t
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function renderTemplate(tpl, rec) {
|
|
102
|
+
return String(tpl ?? '').replace(/\{(\w+)\}/g, (_, key) => {
|
|
103
|
+
if (key === 'time') return new Date(rec.createdAt).toLocaleString('zh-CN', { hour12: false })
|
|
104
|
+
if (key === 'comment') return rec.comment || '(无)'
|
|
105
|
+
if (key === 'reason') return rec.reason ? `筛选未过:${rec.reason}` : ''
|
|
106
|
+
const v = rec[key]
|
|
107
|
+
return v === null || v === undefined ? '' : String(v)
|
|
108
|
+
})
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// 等级 / 注册时间只能“有就用”:官方 QQ 机器人接口根本不给这两项,
|
|
112
|
+
// 这里按 事件字段 → bot.internal(OneBot 的 getGroupMemberInfo)的顺序碰运气。
|
|
113
|
+
async function resolveProfile(bot, guildId, userId, event) {
|
|
114
|
+
const out = { level: null, ageYears: null }
|
|
115
|
+
const pools = [event?.user, event?.member, event?.d, event].filter(Boolean)
|
|
116
|
+
|
|
117
|
+
for (const pool of pools) {
|
|
118
|
+
if (out.level === null) {
|
|
119
|
+
out.level = firstNumber(pool, ['level', 'qqLevel', 'qq_level', 'userLevel', 'user_level'])
|
|
120
|
+
}
|
|
121
|
+
if (out.ageYears === null) {
|
|
122
|
+
const raws = ['regTime', 'registerTime', 'register_time', 'regTimestamp', 'createTime', 'createdAt', 'created_at']
|
|
123
|
+
for (const key of raws) {
|
|
124
|
+
const ts = toTimestamp(pool[key])
|
|
125
|
+
if (ts) {
|
|
126
|
+
out.ageYears = (Date.now() - ts) / YEAR_MS
|
|
127
|
+
break
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
if (out.level === null && bot?.internal && typeof bot.internal.getGroupMemberInfo === 'function') {
|
|
134
|
+
try {
|
|
135
|
+
const info = await bot.internal.getGroupMemberInfo(guildId, userId)
|
|
136
|
+
out.level = firstNumber(info, ['level', 'qq_level', 'qqLevel'])
|
|
137
|
+
} catch (e) {
|
|
138
|
+
// 拿不到很正常(官方接口没有这个能力),不用当错误
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
return out
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function evaluate(profile, config) {
|
|
146
|
+
const fails = []
|
|
147
|
+
const minLevel = Number(config.minLevel) || 0
|
|
148
|
+
const minAgeYears = Number(config.minAgeYears) || 0
|
|
149
|
+
|
|
150
|
+
if (minLevel > 0) {
|
|
151
|
+
if (profile.level === null) {
|
|
152
|
+
if (config.unknownAs === 'fail') fails.push('拿不到 QQ 等级')
|
|
153
|
+
} else if (profile.level < minLevel) {
|
|
154
|
+
fails.push(`QQ 等级 ${profile.level} 低于 ${minLevel}`)
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
if (minAgeYears > 0) {
|
|
159
|
+
if (profile.ageYears === null) {
|
|
160
|
+
if (config.unknownAs === 'fail') fails.push('拿不到注册时间')
|
|
161
|
+
} else if (profile.ageYears < minAgeYears) {
|
|
162
|
+
fails.push(`注册 ${profile.ageYears.toFixed(1)} 年,不足 ${minAgeYears} 年`)
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
return fails
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
async function sendTo(bot, target, content) {
|
|
170
|
+
const { type, id } = parseTarget(target)
|
|
171
|
+
if (!id) throw new Error('没有配置转发目标 target')
|
|
172
|
+
if (!bot) throw new Error('没有可用的 bot')
|
|
173
|
+
if (type === 'private') {
|
|
174
|
+
// 官方 QQ 适配器的私聊必须走 sendPrivateMessage,sendMessage('private:x') 在它上面不成立
|
|
175
|
+
if (typeof bot.sendPrivateMessage !== 'function') throw new Error('当前适配器不支持私聊,改成 group:<群号>')
|
|
176
|
+
return bot.sendPrivateMessage(id, content)
|
|
177
|
+
}
|
|
178
|
+
return bot.sendMessage(id, content)
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
export function apply(ctx, config) {
|
|
182
|
+
const logger = ctx.logger('guild-request-approval')
|
|
183
|
+
const pending = new Map()
|
|
184
|
+
const seen = new Set() // adapter-qq-crack 偶尔会把同一条申请推两次
|
|
185
|
+
let seq = 0
|
|
186
|
+
|
|
187
|
+
const safeSend = async (session, text) => {
|
|
188
|
+
try {
|
|
189
|
+
await session.send(text)
|
|
190
|
+
} catch (e) {
|
|
191
|
+
logger.warn('回执发送失败:%s', e?.message ?? e)
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
const findPending = (input) => {
|
|
196
|
+
const key = String(input ?? '').trim().replace(/^[##]/, '')
|
|
197
|
+
if (!key) return null
|
|
198
|
+
if (pending.has(key)) return pending.get(key)
|
|
199
|
+
for (const rec of pending.values()) {
|
|
200
|
+
if (String(rec.messageId) === key) return rec
|
|
201
|
+
}
|
|
202
|
+
return null
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
const settle = async (rec, approve, comment, by) => {
|
|
206
|
+
if (rec.status !== 'pending') return false
|
|
207
|
+
rec.status = approve ? 'accepted' : 'rejected'
|
|
208
|
+
if (rec.timer) {
|
|
209
|
+
clearTimeout(rec.timer)
|
|
210
|
+
rec.timer = null
|
|
211
|
+
}
|
|
212
|
+
pending.delete(rec.id)
|
|
213
|
+
try {
|
|
214
|
+
await rec.bot.handleGuildMemberRequest(rec.messageId, approve, comment || undefined)
|
|
215
|
+
logger.info('申请 #%s %s@%s → %s%s', rec.id, rec.userId, rec.guildId,
|
|
216
|
+
approve ? '通过' : '拒绝', by ? `(${by})` : '')
|
|
217
|
+
return true
|
|
218
|
+
} catch (e) {
|
|
219
|
+
// 适配器没实现 handleGuildMemberRequest 时只会在这里炸,不会把插件带崩
|
|
220
|
+
logger.warn('申请 #%s 调用 handleGuildMemberRequest 失败:%s', rec.id, e?.message ?? e)
|
|
221
|
+
rec.status = 'error'
|
|
222
|
+
return false
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
const armTimeout = (rec) => {
|
|
227
|
+
const seconds = Number(config.timeout) || 0
|
|
228
|
+
if (seconds <= 0) return
|
|
229
|
+
// unref:别让挂起的定时器拖住进程退出
|
|
230
|
+
rec.timer = setTimeout(async () => {
|
|
231
|
+
rec.timer = null
|
|
232
|
+
if (config.timeoutAction === 'none') {
|
|
233
|
+
rec.status = 'expired'
|
|
234
|
+
pending.delete(rec.id)
|
|
235
|
+
logger.info('申请 #%s 超时,按配置留在 QQ 侧', rec.id)
|
|
236
|
+
return
|
|
237
|
+
}
|
|
238
|
+
const ok = await settle(rec, config.timeoutAction === 'accept', config.rejectComment || '超时未处理', '超时')
|
|
239
|
+
if (ok && config.replyOnResult) {
|
|
240
|
+
try {
|
|
241
|
+
await sendTo(rec.bot, config.target, `申请 #${rec.id} 超时,已自动${config.timeoutAction === 'accept' ? '通过' : '拒绝'}`)
|
|
242
|
+
} catch (e) {
|
|
243
|
+
// 回执失败无所谓,主流程已经完成了
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
}, seconds * 1000)
|
|
247
|
+
rec.timer.unref?.()
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
const fromTarget = (session) => {
|
|
251
|
+
const { type, id } = parseTarget(config.target)
|
|
252
|
+
if (!id) return false
|
|
253
|
+
if (type === 'private') return String(session.userId) === id
|
|
254
|
+
return [session.guildId, session.channelId].some((v) => v !== null && v !== undefined && String(v) === id)
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
const allowed = (session) => {
|
|
258
|
+
const admins = (config.admins ?? []).map(String).map((s) => s.trim()).filter(Boolean)
|
|
259
|
+
if (admins.length) return admins.includes(String(session.userId))
|
|
260
|
+
return fromTarget(session)
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
const forward = async (rec) => {
|
|
264
|
+
const text = renderTemplate(config.forwardTemplate, rec)
|
|
265
|
+
const { id } = parseTarget(config.target)
|
|
266
|
+
if (!id) throw new Error('没有配置转发目标 target')
|
|
267
|
+
await sendTo(rec.bot || ctx.bots[0], config.target, text)
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
ctx.on('guild-member-request', async (session) => {
|
|
271
|
+
// adapter-qq-crack 的事件字段不保证齐全:缺一个就跳,别猜
|
|
272
|
+
const guildId = session.guildId
|
|
273
|
+
const userId = session.userId
|
|
274
|
+
const messageId = session.messageId || session.event?.message?.id || session.event?.messageId
|
|
275
|
+
const comment = session.event?.comment || session.event?.message?.content || session.content || ''
|
|
276
|
+
|
|
277
|
+
if (!guildId || !userId || !messageId) {
|
|
278
|
+
logger.warn('入群申请字段缺失,已跳过:guildId=%o userId=%o messageId=%o', guildId, userId, messageId)
|
|
279
|
+
return
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
if (seen.has(String(messageId))) {
|
|
283
|
+
logger.debug('重复的申请事件 %s,忽略', messageId)
|
|
284
|
+
return
|
|
285
|
+
}
|
|
286
|
+
seen.add(String(messageId))
|
|
287
|
+
if (seen.size > 1000) seen.delete(seen.values().next().value)
|
|
288
|
+
|
|
289
|
+
const bot = session.bot || ctx.bots[0]
|
|
290
|
+
if (!bot) {
|
|
291
|
+
logger.warn('没有可用的 bot,跳过 %s 的申请', userId)
|
|
292
|
+
return
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
const mode = MODES.includes(config.mode) ? config.mode : 'manual'
|
|
296
|
+
seq += 1
|
|
297
|
+
const rec = {
|
|
298
|
+
id: String(seq),
|
|
299
|
+
guildId,
|
|
300
|
+
userId,
|
|
301
|
+
messageId,
|
|
302
|
+
comment,
|
|
303
|
+
reason: '',
|
|
304
|
+
bot,
|
|
305
|
+
createdAt: Date.now(),
|
|
306
|
+
status: 'pending',
|
|
307
|
+
timer: null,
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
if (mode === 'accept') {
|
|
311
|
+
await settle(rec, true, config.acceptComment)
|
|
312
|
+
return
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
if (mode === 'reject') {
|
|
316
|
+
await settle(rec, false, config.rejectComment || '本群暂不开放')
|
|
317
|
+
return
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
if (mode === 'auto') {
|
|
321
|
+
const profile = await resolveProfile(bot, guildId, userId, session.event)
|
|
322
|
+
const fails = evaluate(profile, config)
|
|
323
|
+
logger.debug('auto 判定 #%s level=%o ageYears=%o fails=%o', rec.id, profile.level, profile.ageYears, fails)
|
|
324
|
+
if (!fails.length) {
|
|
325
|
+
await settle(rec, true, config.acceptComment)
|
|
326
|
+
return
|
|
327
|
+
}
|
|
328
|
+
if (config.fallback === 'reject') {
|
|
329
|
+
await settle(rec, false, fails.join(';') || config.rejectComment)
|
|
330
|
+
return
|
|
331
|
+
}
|
|
332
|
+
rec.reason = fails.join(';')
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
// manual(auto 转人工也走这儿)
|
|
336
|
+
pending.set(rec.id, rec)
|
|
337
|
+
const cap = Number(config.maxPending) || 200
|
|
338
|
+
if (pending.size > cap) {
|
|
339
|
+
const oldest = pending.values().next().value
|
|
340
|
+
if (oldest && oldest.id !== rec.id) {
|
|
341
|
+
logger.warn('挂起申请超过 %s 条,先结算最旧的 #%s', cap, oldest.id)
|
|
342
|
+
if (config.timeoutAction === 'none') {
|
|
343
|
+
oldest.status = 'expired'
|
|
344
|
+
pending.delete(oldest.id)
|
|
345
|
+
} else {
|
|
346
|
+
await settle(oldest, config.timeoutAction === 'accept', config.rejectComment || '积压超限')
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
try {
|
|
352
|
+
await forward(rec)
|
|
353
|
+
} catch (e) {
|
|
354
|
+
// 转不出去就等于没人能批,按超时策略收掉,别让申请一直吊在 QQ 那边
|
|
355
|
+
logger.warn('转发申请 #%s 失败:%s', rec.id, e?.message ?? e)
|
|
356
|
+
if (config.timeoutAction === 'none') {
|
|
357
|
+
rec.status = 'expired'
|
|
358
|
+
pending.delete(rec.id)
|
|
359
|
+
} else {
|
|
360
|
+
await settle(rec, config.timeoutAction === 'accept', config.rejectComment || '无人可审批')
|
|
361
|
+
}
|
|
362
|
+
return
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
armTimeout(rec)
|
|
366
|
+
})
|
|
367
|
+
|
|
368
|
+
// 手写正则而不是 ctx.command:审批人是在群里随口回一句,不该被全局前缀绑住
|
|
369
|
+
const REJECT_RE = /^(?:不同意|拒绝|驳回|reject|deny)\s*[##]?([0-9A-Za-z_-]+)\s*([\s\S]*)$/i
|
|
370
|
+
const APPROVE_RE = /^(?:同意|通过|批准|approve|accept)\s*[##]?([0-9A-Za-z_-]+)\s*([\s\S]*)$/i
|
|
371
|
+
|
|
372
|
+
ctx.on('message', async (session) => {
|
|
373
|
+
if (!session.content) return
|
|
374
|
+
if (session.userId && session.selfId && session.userId === session.selfId) return
|
|
375
|
+
|
|
376
|
+
// QQ 消息里会混进 <at id="..."/> 这类片段,先剥掉再匹配
|
|
377
|
+
let text = String(session.content).replace(/<[^>]*>/g, ' ').trim()
|
|
378
|
+
text = text.replace(/^[!!//.。、,,\s]+/, '')
|
|
379
|
+
if (!text) return
|
|
380
|
+
|
|
381
|
+
let approved = false
|
|
382
|
+
let matched = REJECT_RE.exec(text)
|
|
383
|
+
if (matched) {
|
|
384
|
+
approved = false
|
|
385
|
+
} else {
|
|
386
|
+
matched = APPROVE_RE.exec(text)
|
|
387
|
+
if (matched) approved = true
|
|
388
|
+
}
|
|
389
|
+
if (!matched) return
|
|
390
|
+
|
|
391
|
+
const input = matched[1]
|
|
392
|
+
const comment = (matched[2] || '').trim()
|
|
393
|
+
const rec = findPending(input)
|
|
394
|
+
|
|
395
|
+
if (!rec) {
|
|
396
|
+
if (allowed(session)) await safeSend(session, `没找到待处理的申请 #${input.replace(/^[##]/, '')}`)
|
|
397
|
+
return
|
|
398
|
+
}
|
|
399
|
+
if (!allowed(session)) {
|
|
400
|
+
await safeSend(session, '你没有审批权限')
|
|
401
|
+
return
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
const ok = await settle(rec, approved, approved ? (comment || config.acceptComment) : (comment || config.rejectComment), `by ${session.userId}`)
|
|
405
|
+
if (!ok) {
|
|
406
|
+
await safeSend(session, `申请 #${rec.id} 处理失败,看看日志`)
|
|
407
|
+
return
|
|
408
|
+
}
|
|
409
|
+
if (config.replyOnResult) {
|
|
410
|
+
await safeSend(session, approved
|
|
411
|
+
? `已同意 #${rec.id}`
|
|
412
|
+
: `已拒绝 #${rec.id}${comment ? ':' + comment : ''}`)
|
|
413
|
+
}
|
|
414
|
+
// 私聊里批的,顺手在转发目标那边播报一句,其他管理员不至于重复操作
|
|
415
|
+
if (!fromTarget(session)) {
|
|
416
|
+
try {
|
|
417
|
+
await sendTo(rec.bot, config.target, `申请 #${rec.id} 已被 ${session.userId} ${approved ? '同意' : '拒绝'}`)
|
|
418
|
+
} catch (e) {
|
|
419
|
+
// 播报失败不影响结果
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
})
|
|
423
|
+
|
|
424
|
+
if (['manual', 'auto'].includes(config.mode) && !parseTarget(config.target).id) {
|
|
425
|
+
logger.warn('当前是 %s 模式但没配置 target,申请会被直接按超时策略处理', config.mode)
|
|
426
|
+
}
|
|
427
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,46 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "koishi-plugin-guild-request-approval",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "QQ 群入群申请审批:自动通过 / 自动拒绝 / 转发人工审批 / 条件筛选,适配 adapter-qq",
|
|
5
|
+
"main": "index.js",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"files": [
|
|
8
|
+
"index.js",
|
|
9
|
+
"readme.md"
|
|
10
|
+
],
|
|
11
|
+
"keywords": [
|
|
12
|
+
"chatbot",
|
|
13
|
+
"koishi",
|
|
14
|
+
"plugin",
|
|
15
|
+
"qq",
|
|
16
|
+
"guild",
|
|
17
|
+
"request",
|
|
18
|
+
"adapter-qq"
|
|
19
|
+
],
|
|
20
|
+
"license": "MIT",
|
|
21
|
+
"author": "7-Ice",
|
|
22
|
+
"repository": {
|
|
23
|
+
"type": "git",
|
|
24
|
+
"url": "git+https://github.com/7-Ice/koishi-plugin-guild-request-approval.git"
|
|
25
|
+
},
|
|
26
|
+
"homepage": "https://github.com/7-Ice/koishi-plugin-guild-request-approval",
|
|
27
|
+
"bugs": {
|
|
28
|
+
"url": "https://github.com/7-Ice/koishi-plugin-guild-request-approval/issues"
|
|
29
|
+
},
|
|
30
|
+
"peerDependencies": {
|
|
31
|
+
"koishi": "^4.0.0"
|
|
32
|
+
},
|
|
33
|
+
"engines": {
|
|
34
|
+
"node": ">=18"
|
|
35
|
+
},
|
|
36
|
+
"koishi": {
|
|
37
|
+
"description": {
|
|
38
|
+
"zh": "群入群申请审批:accept / reject / manual / auto 四种模式,可转发给管理员回复「同意 1」「拒绝 1 理由」,支持超时兜底",
|
|
39
|
+
"en": "Guild member request approval with accept / reject / manual / auto modes and timeout fallback"
|
|
40
|
+
},
|
|
41
|
+
"service": {
|
|
42
|
+
"required": [],
|
|
43
|
+
"optional": []
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
}
|
package/readme.md
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# koishi-plugin-guild-request-approval
|
|
2
|
+
|
|
3
|
+
处理 QQ 群入群申请,四种模式随便挑:直接过、直接拒、丢给管理员、按条件筛。
|
|
4
|
+
|
|
5
|
+
[](https://www.npmjs.com/package/koishi-plugin-guild-request-approval)
|
|
6
|
+
[](https://www.npmjs.com/package/koishi-plugin-guild-request-approval)
|
|
7
|
+
[](package.json)
|
|
8
|
+
[](https://koishi.chat)
|
|
9
|
+
|
|
10
|
+
## 装
|
|
11
|
+
|
|
12
|
+
```bash
|
|
13
|
+
npm i koishi-plugin-guild-request-approval
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
或者 Koishi 控制台 → 插件市场搜 `guild-request-approval`。装完点开配置,选模式、填目标就行,不用 database。
|
|
17
|
+
|
|
18
|
+
## 配置项
|
|
19
|
+
|
|
20
|
+
| 项 | 说明 |
|
|
21
|
+
| --- | --- |
|
|
22
|
+
| `mode` | `accept` 直接过 / `reject` 直接拒 / `manual` 转人工 / `auto` 条件筛 |
|
|
23
|
+
| `target` | 转人工的落地位置:`group:123456`、`private:10001`,只写数字当群号 |
|
|
24
|
+
| `admins` | 能回复审批的 QQ 列表;留空 = 转发目标会话里谁都能批 |
|
|
25
|
+
| `timeout` | 等多久(秒),`0` = 一直等 |
|
|
26
|
+
| `timeoutAction` | 超时后 `reject` / `accept` / `none` |
|
|
27
|
+
| `minLevel` | `auto` 用,最低 QQ 等级,`0` 不检查 |
|
|
28
|
+
| `minAgeYears` | `auto` 用,最短注册年限,`0` 不检查 |
|
|
29
|
+
| `unknownAs` | `auto` 拿不到等级/注册时间时:`fail` 当不满足,`pass` 放行 |
|
|
30
|
+
| `fallback` | `auto` 不满足条件时:`manual` 转人工,`reject` 直接拒 |
|
|
31
|
+
| `maxPending` | 最多挂起多少条,超了先结算最旧的 |
|
|
32
|
+
| `acceptComment` / `rejectComment` | 通过/拒绝附言 |
|
|
33
|
+
| `forwardTemplate` | 转发文案,占位 `{id} {guildId} {userId} {comment} {reason} {time}` |
|
|
34
|
+
| `replyOnResult` | 处理完回不回一句 |
|
|
35
|
+
|
|
36
|
+
官方机器人接口不提供 QQ 等级和注册时间,`auto` 模式在这类适配器上会一直走“拿不到数据”分支——要么把 `unknownAs` 设成 `pass`,要么就用 `manual`。
|
|
37
|
+
|
|
38
|
+
## 验证
|
|
39
|
+
|
|
40
|
+
- `accept` / `reject`:小号发申请,群里立刻进人 / 立刻被拒。
|
|
41
|
+
- `manual`:申请转发到 `target`,回复 `同意 1` 或 `拒绝 1 太新了`,回执出现即生效;不管它,到点看 `timeoutAction` 有没有执行。
|
|
42
|
+
- `auto`:把 `minLevel` 设成 999,申请必然转人工或直接拒,然后把 `unknownAs` 改成 `pass` 再试一次,行为应当翻转。
|
|
43
|
+
- 日志标签 `guild-request-approval` 里能看到每条申请的判定结果,字段缺失会打 warn。
|
package/README.md
DELETED