koishi-plugin-ll-schedule 1.6.2 → 1.7.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 +433 -149
- package/package.json +3 -1
package/index.js
CHANGED
|
@@ -11,28 +11,16 @@ const { Schema, h } = require('koishi')
|
|
|
11
11
|
|
|
12
12
|
exports.name = 'll-schedule'
|
|
13
13
|
|
|
14
|
-
exports.usage =
|
|
14
|
+
exports.usage = `剑灵活动提醒:全部活动写在 activities 活动表里,群里发「订阅」即可收到所有活动的提前提醒。
|
|
15
15
|
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
游戏下方有遁地按钮可以直达~
|
|
22
|
-
|
|
23
|
-
星期:周一/周六/每天/工作日/周末/1,3,5 时间:12:20, 18:20, 21:20(可多个)
|
|
24
|
-
内容支持 Markdown 卡片、模板变量 {date} {time} {week} {task}
|
|
25
|
-
|
|
26
|
-
群命令:
|
|
27
|
-
订阅 → 列出可以订阅的推送
|
|
28
|
-
订阅 任务名 → 本群订阅
|
|
29
|
-
取消订阅 → 列出本群已订阅的推送
|
|
30
|
-
取消订阅 任务名 → 本群取消订阅
|
|
31
|
-
订阅列表 / schedule.list / schedule.groups / schedule.test 任务名 / schedule.testcur 任务名(只发当前群) / 我的ID
|
|
32
|
-
管理员:schedule.unsuball 任务名(取消该任务全部群订阅并通知各群;填 all 清空全部)
|
|
33
|
-
|
|
34
|
-
推送接口:POST /ll-schedule/push {"secret":"密钥","task":"任务名","place1":"xxx","delay":0}
|
|
16
|
+
活动表格式(一份表管所有活动):
|
|
17
|
+
[世界BOSS] 提前:5分钟
|
|
18
|
+
周一 21:00-22:00 地点:白青山脉 / 仙幻岛
|
|
19
|
+
[寻宝] 提前:2分钟
|
|
20
|
+
每天 13:00-13:30
|
|
35
21
|
|
|
22
|
+
群命令:订阅 / 取消订阅 / 订阅列表 / schedule.list / schedule.test
|
|
23
|
+
管理员:schedule.unsuball(取消所有群的订阅)
|
|
36
24
|
`
|
|
37
25
|
|
|
38
26
|
// ===== 纯函数(可单测) =====
|
|
@@ -242,7 +230,137 @@ function buildTextFromMd(md) {
|
|
|
242
230
|
.trim()
|
|
243
231
|
}
|
|
244
232
|
|
|
245
|
-
|
|
233
|
+
// ===== 活动表(剑灵提醒)=====
|
|
234
|
+
// 文本格式:
|
|
235
|
+
// [世界BOSS] 提前:5分钟
|
|
236
|
+
// 周一 21:00-22:00 地点:白青山脉 / 仙幻岛
|
|
237
|
+
// 每天 13:00-13:30
|
|
238
|
+
function hmToMin(hm) {
|
|
239
|
+
const [h, m] = String(hm).split(':').map(Number)
|
|
240
|
+
return (h || 0) * 60 + (m || 0)
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
function parseActivities(text) {
|
|
244
|
+
const lines = String(text || '').split(/\r?\n/)
|
|
245
|
+
const acts = []
|
|
246
|
+
let cur = null
|
|
247
|
+
const pad = (s) => (String(s).length === 4 ? '0' + s : String(s))
|
|
248
|
+
for (const raw of lines) {
|
|
249
|
+
const line = raw.trim()
|
|
250
|
+
if (!line) continue
|
|
251
|
+
// [活动名] 可带 提前:N分钟
|
|
252
|
+
const head = line.match(/^[[【]([^\]】]+)[\]】]\s*(.*)$/)
|
|
253
|
+
if (head) {
|
|
254
|
+
const name = head[1].trim()
|
|
255
|
+
const rest = head[2] || ''
|
|
256
|
+
const adv = rest.match(/提前\s*[::]?\s*(\d+)\s*分钟?/)
|
|
257
|
+
cur = { name, advance: adv ? parseInt(adv[1], 10) : 2, slots: [] }
|
|
258
|
+
acts.push(cur)
|
|
259
|
+
continue
|
|
260
|
+
}
|
|
261
|
+
if (!cur) continue
|
|
262
|
+
if (/^[#/]/.test(line)) continue // 注释行
|
|
263
|
+
// 时段行:星期 开始-结束 [地点:xxx]
|
|
264
|
+
const m = line.match(/^(\S+)\s+(\d{1,2}:\d{2})\s*[-~—至]\s*(\d{1,2}:\d{2})\s*(.*)$/)
|
|
265
|
+
if (!m) continue
|
|
266
|
+
const rest = (m[4] || '').trim()
|
|
267
|
+
const pm = rest.match(/地点\s*[::]\s*(.+)$/)
|
|
268
|
+
cur.slots.push({ wd: m[1], start: pad(m[2]), end: pad(m[3]), place: pm ? pm[1].trim() : rest })
|
|
269
|
+
}
|
|
270
|
+
return acts.filter(a => a.slots.length > 0)
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
// 展开成"周内实例"(星期 × 时段)
|
|
274
|
+
function expandInstances(activities) {
|
|
275
|
+
const out = []
|
|
276
|
+
for (const act of activities || []) {
|
|
277
|
+
for (const s of act.slots || []) {
|
|
278
|
+
const days = parseDays(s.wd)
|
|
279
|
+
const list = days ? [...days] : [1, 2, 3, 4, 5, 6, 7]
|
|
280
|
+
for (const d of list) {
|
|
281
|
+
out.push({
|
|
282
|
+
activity: act.name,
|
|
283
|
+
advance: act.advance,
|
|
284
|
+
day: d,
|
|
285
|
+
start: s.start,
|
|
286
|
+
end: s.end,
|
|
287
|
+
place: s.place,
|
|
288
|
+
startMin: hmToMin(s.start),
|
|
289
|
+
})
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
return out
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
// 找"下一场"(跨天、跨周都算)
|
|
297
|
+
function findNextInstance(instances, current) {
|
|
298
|
+
let best = null
|
|
299
|
+
let bestKey = Infinity
|
|
300
|
+
for (const inst of instances) {
|
|
301
|
+
if (inst === current) continue
|
|
302
|
+
let offset = (inst.day - current.day + 7) % 7
|
|
303
|
+
if (offset === 0 && inst.startMin <= current.startMin) offset = 7
|
|
304
|
+
const key = offset * 1440 + inst.startMin
|
|
305
|
+
if (key < bestKey) {
|
|
306
|
+
bestKey = key
|
|
307
|
+
best = inst
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
return best
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
// 计算"现在这一分钟该发哪些提前提醒"
|
|
314
|
+
function computeReminders(activities, date, hm) {
|
|
315
|
+
const day = todayDay(date)
|
|
316
|
+
const instances = expandInstances(activities)
|
|
317
|
+
const nowMin = hmToMin(hm)
|
|
318
|
+
const fires = []
|
|
319
|
+
for (const inst of instances) {
|
|
320
|
+
if (inst.day !== day) continue
|
|
321
|
+
const fireMin = inst.startMin - (inst.advance || 0)
|
|
322
|
+
if (fireMin !== nowMin) continue
|
|
323
|
+
fires.push({
|
|
324
|
+
key: `${inst.activity}@${hm}@${inst.start}`,
|
|
325
|
+
inst,
|
|
326
|
+
next: findNextInstance(instances, inst),
|
|
327
|
+
})
|
|
328
|
+
}
|
|
329
|
+
return fires
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
const DEFAULT_REMIND_TPL = [
|
|
333
|
+
'⏰ 提醒:距离 [{activity}] 还有 {advance} 分钟!',
|
|
334
|
+
'🕐 活动时间:{start}-{end}',
|
|
335
|
+
'📍 活动地点:{place}',
|
|
336
|
+
'{{#next}}',
|
|
337
|
+
'────────────',
|
|
338
|
+
'▶ 下一时段活动:[{next}]',
|
|
339
|
+
'📅 活动时间:{nextStart}-{nextEnd}',
|
|
340
|
+
'{{/next}}',
|
|
341
|
+
].join('\n')
|
|
342
|
+
|
|
343
|
+
// 渲染提醒模板:{变量} 替换;含空变量的整行自动删除;{{#key}}...{{/key}} 条件块(变量为空则整块删除)
|
|
344
|
+
function renderRemindTemplate(tpl, vars) {
|
|
345
|
+
let out = String(tpl || DEFAULT_REMIND_TPL)
|
|
346
|
+
out = out.replace(/\{\{#(\w+)\}\}([\s\S]*?)\{\{\/\1\}\}/g, (m, key, body) => {
|
|
347
|
+
const v = vars[key]
|
|
348
|
+
return (v === undefined || v === null || String(v).trim() === '') ? '' : body
|
|
349
|
+
})
|
|
350
|
+
const kept = []
|
|
351
|
+
for (const line of out.split('\n')) {
|
|
352
|
+
const names = [...line.matchAll(/\{(\w+)\}/g)].map(x => x[1])
|
|
353
|
+
const hasEmpty = names.some((n) => {
|
|
354
|
+
const v = vars[n]
|
|
355
|
+
return v === undefined || v === null || String(v).trim() === ''
|
|
356
|
+
})
|
|
357
|
+
if (names.length && hasEmpty) continue
|
|
358
|
+
kept.push(line.replace(/\{(\w+)\}/g, (m, n) => (vars[n] === undefined || vars[n] === null ? '' : String(vars[n]))))
|
|
359
|
+
}
|
|
360
|
+
return kept.join('\n').replace(/\n{3,}/g, '\n\n').trim()
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
exports._test = { parseTimes, todayDay, parseDays, dayMatches, parseScheduleBlocks, parseScheduleRows, summarizeRows, computeFires, renderText, renderPushTemplate, hmToMin, parseActivities, expandInstances, findNextInstance, computeReminders, renderRemindTemplate, DEFAULT_REMIND_TPL, buildMd, buildTextFromMd }
|
|
246
364
|
|
|
247
365
|
// ===== 配置 Schema =====
|
|
248
366
|
|
|
@@ -267,18 +385,35 @@ const TaskItem = Schema.object({
|
|
|
267
385
|
})
|
|
268
386
|
|
|
269
387
|
const Config = Schema.object({
|
|
388
|
+
activities: Schema.string()
|
|
389
|
+
.role('textarea', { rows: [8, 20] })
|
|
390
|
+
.default('')
|
|
391
|
+
.description('📅 剑灵活动表(全部活动写这里,一份表管所有提醒)。格式:[活动名] 提前:N分钟,下面每行 = 星期 开始-结束 地点:xxx。填了它就走活动表模式(旧的 tasks 任务模式自动停用,避免重复发)'),
|
|
392
|
+
remindTemplate: Schema.string()
|
|
393
|
+
.role('textarea', { rows: [6, 14] })
|
|
394
|
+
.default('')
|
|
395
|
+
.description('提醒文案模板(留空用默认)。变量:{activity} {advance} {start} {end} {place} {next} {nextStart} {nextEnd} {date} {week};含空变量的整行会自动省略'),
|
|
396
|
+
remindMarkdown: Schema.boolean()
|
|
397
|
+
.default(true)
|
|
398
|
+
.description('活动提醒是否用 QQ官方 Markdown 卡片发送(仅官机生效,失败自动回退纯文本)'),
|
|
399
|
+
globalGroups: Schema.array(Schema.string())
|
|
400
|
+
.default([])
|
|
401
|
+
.description('额外固定推送群(可选):这些群无需订阅也会收到活动提醒(留空即可,一般用群内"订阅"命令)'),
|
|
270
402
|
tasks: Schema.dict(TaskItem)
|
|
271
403
|
.default({})
|
|
272
|
-
.description('📋
|
|
404
|
+
.description('📋 旧版推送任务表格(活动表 activities 为空时生效;每个任务 = 一个日程大文本框 schedule:星期/时间/内容)'),
|
|
273
405
|
pushSecret: Schema.string()
|
|
274
406
|
.default('')
|
|
275
407
|
.description('🔑 HTTP 推送接口密钥:外部系统调用 POST /ll-schedule/push 时须携带相同 secret(留空则接口拒绝所有推送)'),
|
|
276
408
|
unsubAllNotice: Schema.string()
|
|
277
|
-
.default('
|
|
278
|
-
.description('管理员用 schedule.unsuball
|
|
409
|
+
.default('本群已取消剑灵提醒推送')
|
|
410
|
+
.description('管理员用 schedule.unsuball 取消全部订阅时,发到每个被取消群的提示语(留空则不发送提示)'),
|
|
411
|
+
permDeniedHint: Schema.string()
|
|
412
|
+
.default('❌ 权限不足,请联系李浅浅,或成为群主/管理员')
|
|
413
|
+
.description('权限不足时的提示语(管理命令/订阅命令都会用它;可改成你自己的联系方式)'),
|
|
279
414
|
subscribeAuthority: Schema.number()
|
|
280
415
|
.min(1).max(4).default(1)
|
|
281
|
-
.description('
|
|
416
|
+
.description('执行订阅命令所需的最低权限等级(默认 1 = 所有人都能订阅。如需限制:先在控制台给指定账号提权,再把这里调高)'),
|
|
282
417
|
subscribeAdminBypass: Schema.boolean()
|
|
283
418
|
.default(true)
|
|
284
419
|
.description('群主/群管理可无视上面的权限等级直接订阅(从消息自带的成员角色判断,无需申请接口权限)'),
|
|
@@ -287,7 +422,7 @@ const Config = Schema.object({
|
|
|
287
422
|
.description('任务未指定群也没有群订阅时,发送给该机器人所有已知群'),
|
|
288
423
|
verbose: Schema.boolean()
|
|
289
424
|
.default(false)
|
|
290
|
-
.description('
|
|
425
|
+
.description('调试:控制台打印每次发送详情(一般不用开)'),
|
|
291
426
|
})
|
|
292
427
|
|
|
293
428
|
exports.Config = Config
|
|
@@ -331,6 +466,8 @@ function apply(ctx, config) {
|
|
|
331
466
|
}
|
|
332
467
|
|
|
333
468
|
// ===== 群订阅 =====
|
|
469
|
+
// 现在订阅是全局的:订阅一次 = 收全部剑灵提醒(存在特殊任务名 ALL 下)
|
|
470
|
+
const ALL = '__all__'
|
|
334
471
|
const subs = new Map()
|
|
335
472
|
|
|
336
473
|
function getTaskSubs(taskName) {
|
|
@@ -342,6 +479,46 @@ function apply(ctx, config) {
|
|
|
342
479
|
return taskSubs
|
|
343
480
|
}
|
|
344
481
|
|
|
482
|
+
// 把已有订阅(老的按任务订阅)自动升级为「全部订阅」:按群去重,不重不漏
|
|
483
|
+
async function migrateToAll() {
|
|
484
|
+
if (!ctx.database) return
|
|
485
|
+
try {
|
|
486
|
+
const rows = await ctx.database.get('ll_schedule_subs', {})
|
|
487
|
+
const groupMap = new Map()
|
|
488
|
+
for (const r of rows) {
|
|
489
|
+
if (r.taskName === ALL) continue
|
|
490
|
+
groupMap.set(`${r.botSid}:${r.guildId}`, { botSid: r.botSid, guildId: r.guildId })
|
|
491
|
+
}
|
|
492
|
+
if (!groupMap.size) {
|
|
493
|
+
// 没有老记录:清理内存里可能残留的老键
|
|
494
|
+
for (const k of [...subs.keys()]) if (k !== ALL) subs.delete(k)
|
|
495
|
+
return
|
|
496
|
+
}
|
|
497
|
+
const allSubs = getTaskSubs(ALL)
|
|
498
|
+
const now = Date.now()
|
|
499
|
+
const toInsert = []
|
|
500
|
+
for (const [key, g] of groupMap) {
|
|
501
|
+
if (allSubs.has(key)) continue
|
|
502
|
+
const entry = { botSid: g.botSid, guildId: g.guildId, createdAt: now }
|
|
503
|
+
allSubs.set(key, entry)
|
|
504
|
+
toInsert.push({ taskName: ALL, ...entry })
|
|
505
|
+
}
|
|
506
|
+
if (toInsert.length) {
|
|
507
|
+
await ctx.database.upsert('ll_schedule_subs', toInsert, ['taskName', 'botSid', 'guildId'])
|
|
508
|
+
}
|
|
509
|
+
// 删除老记录 + 清理内存老键
|
|
510
|
+
for (const r of rows) {
|
|
511
|
+
if (r.taskName === ALL) continue
|
|
512
|
+
await removeSub(r.taskName, r.botSid, r.guildId)
|
|
513
|
+
}
|
|
514
|
+
for (const k of [...subs.keys()]) if (k !== ALL) subs.delete(k)
|
|
515
|
+
logger.info(`[schedule] 订阅已升级为「全部订阅」:${groupMap.size} 个群(其中新增 ${toInsert.length})`)
|
|
516
|
+
}
|
|
517
|
+
catch (e) {
|
|
518
|
+
logger.warn('[schedule] 订阅升级失败:', e)
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
|
|
345
522
|
async function loadSubs() {
|
|
346
523
|
if (!ctx.database) return
|
|
347
524
|
try {
|
|
@@ -517,14 +694,21 @@ function apply(ctx, config) {
|
|
|
517
694
|
if (!bots.length) return
|
|
518
695
|
|
|
519
696
|
const groups = (task.groups && task.groups.length > 0) ? task.groups : null
|
|
520
|
-
|
|
697
|
+
// 订阅目标 = 该任务的订阅群 ∪ 订阅了"全部提醒"的群(去重)
|
|
698
|
+
const allSubsMap = subs.get(ALL)
|
|
521
699
|
// 测试模式(skipSubs)时忽略所有订阅群,只发 groups(即当前测试群)
|
|
522
700
|
const useSubs = !opts.skipSubs
|
|
523
701
|
|
|
524
702
|
for (const bot of bots) {
|
|
525
|
-
const subTargets =
|
|
526
|
-
|
|
527
|
-
|
|
703
|
+
const subTargets = []
|
|
704
|
+
if (useSubs) {
|
|
705
|
+
for (const m of [subs.get(name), allSubsMap]) {
|
|
706
|
+
if (!m) continue
|
|
707
|
+
for (const s of m.values()) {
|
|
708
|
+
if (s.botSid === bot.sid && !subTargets.includes(s.guildId)) subTargets.push(s.guildId)
|
|
709
|
+
}
|
|
710
|
+
}
|
|
711
|
+
}
|
|
528
712
|
|
|
529
713
|
if (groups || subTargets.length) {
|
|
530
714
|
for (const rawTarget of groups || []) {
|
|
@@ -625,6 +809,73 @@ function apply(ctx, config) {
|
|
|
625
809
|
logger.info('[schedule] HTTP 推送接口已注册: POST /ll-schedule/push')
|
|
626
810
|
}
|
|
627
811
|
|
|
812
|
+
// ===== 活动提醒(新:活动表模式)=====
|
|
813
|
+
|
|
814
|
+
// 发给所有"订阅了全部提醒"的群(+ 额外固定群)
|
|
815
|
+
async function sendToSubscribers(text, { markdown, image } = {}) {
|
|
816
|
+
if (!text) return 0
|
|
817
|
+
const allSubs = subs.get(ALL)
|
|
818
|
+
const fixed = Array.isArray(config.globalGroups) ? config.globalGroups : []
|
|
819
|
+
let bots = (ctx.bots || []).filter(b => b.online)
|
|
820
|
+
if (!bots.length) return 0
|
|
821
|
+
let sent = 0
|
|
822
|
+
for (const bot of bots) {
|
|
823
|
+
const list = []
|
|
824
|
+
if (allSubs) {
|
|
825
|
+
for (const t of allSubs.values()) {
|
|
826
|
+
if (t.botSid === bot.sid && !list.includes(t.guildId)) list.push(t.guildId)
|
|
827
|
+
}
|
|
828
|
+
}
|
|
829
|
+
for (const g of fixed) {
|
|
830
|
+
const gs = String(g).trim()
|
|
831
|
+
if (gs && !list.includes(gs)) list.push(gs)
|
|
832
|
+
}
|
|
833
|
+
// 一个订阅群都没有时,按配置兜底发给所有已知群
|
|
834
|
+
if (!list.length && config.sendAllWhenEmpty) {
|
|
835
|
+
for (const g of knownGroups.values()) {
|
|
836
|
+
if (g.botSid === bot.sid && !list.includes(g.guildId)) list.push(g.guildId)
|
|
837
|
+
}
|
|
838
|
+
}
|
|
839
|
+
for (const target of list) {
|
|
840
|
+
try {
|
|
841
|
+
await sendOne(bot, target, text, { markdown, image })
|
|
842
|
+
sent++
|
|
843
|
+
if (config.verbose) logger.info(`[schedule] 提醒已发 ${bot.sid}:${target}`)
|
|
844
|
+
}
|
|
845
|
+
catch (e) {
|
|
846
|
+
logger.error(`[schedule] 提醒发送失败 ${bot.sid}:${target}: ${e.message}`)
|
|
847
|
+
}
|
|
848
|
+
}
|
|
849
|
+
}
|
|
850
|
+
return sent
|
|
851
|
+
}
|
|
852
|
+
|
|
853
|
+
// 组装提醒模板变量
|
|
854
|
+
function makeRemindVars(inst, next, now, hm) {
|
|
855
|
+
return {
|
|
856
|
+
activity: inst.activity,
|
|
857
|
+
advance: String(inst.advance || 0),
|
|
858
|
+
start: inst.start,
|
|
859
|
+
end: inst.end,
|
|
860
|
+
place: inst.place || '',
|
|
861
|
+
date: todayStr(now),
|
|
862
|
+
time: hm,
|
|
863
|
+
week: '星期' + WEEK_CN[todayDay(now)],
|
|
864
|
+
next: next ? next.activity : '',
|
|
865
|
+
nextStart: next ? next.start : '',
|
|
866
|
+
nextEnd: next ? next.end : '',
|
|
867
|
+
nextPlace: next ? (next.place || '') : '',
|
|
868
|
+
}
|
|
869
|
+
}
|
|
870
|
+
|
|
871
|
+
// 渲染并发送一条活动提醒
|
|
872
|
+
async function sendReminder(inst, next, now, hm) {
|
|
873
|
+
const text = renderRemindTemplate(config.remindTemplate, makeRemindVars(inst, next, now, hm))
|
|
874
|
+
if (!text) return
|
|
875
|
+
logger.info(`[schedule] 活动提醒「${inst.activity}」开始于 ${inst.start}(提前 ${inst.advance} 分钟)`)
|
|
876
|
+
await sendToSubscribers(text, { markdown: !!config.remindMarkdown })
|
|
877
|
+
}
|
|
878
|
+
|
|
628
879
|
async function checkAndSend() {
|
|
629
880
|
const now = new Date()
|
|
630
881
|
const today = todayStr(now)
|
|
@@ -633,6 +884,20 @@ function apply(ctx, config) {
|
|
|
633
884
|
}
|
|
634
885
|
|
|
635
886
|
const hm = nowHM(now)
|
|
887
|
+
|
|
888
|
+
// ① 活动表模式(有新活动表就只跑它,避免和旧任务重复发)
|
|
889
|
+
const activities = parseActivities(config.activities)
|
|
890
|
+
if (activities.length) {
|
|
891
|
+
const reminds = computeReminders(activities, now, hm)
|
|
892
|
+
for (const r of reminds) {
|
|
893
|
+
if (firedToday[r.key]) continue
|
|
894
|
+
firedToday[r.key] = true
|
|
895
|
+
await sendReminder(r.inst, r.next, now, hm)
|
|
896
|
+
}
|
|
897
|
+
return
|
|
898
|
+
}
|
|
899
|
+
|
|
900
|
+
// ② 旧任务模式
|
|
636
901
|
const fires = computeFires(config.tasks, now, hm)
|
|
637
902
|
|
|
638
903
|
for (const f of fires) {
|
|
@@ -664,14 +929,29 @@ function apply(ctx, config) {
|
|
|
664
929
|
}
|
|
665
930
|
|
|
666
931
|
function formatTaskList() {
|
|
932
|
+
// 活动表模式
|
|
933
|
+
const acts = parseActivities(config.activities)
|
|
934
|
+
if (acts.length) {
|
|
935
|
+
const lines = ['📅 活动表(订阅后全部活动都会提前提醒):', '']
|
|
936
|
+
for (const a of acts) {
|
|
937
|
+
lines.push(`【${a.name}】提前 ${a.advance} 分钟`)
|
|
938
|
+
for (const s of a.slots) {
|
|
939
|
+
lines.push(` · ${s.wd} ${s.start}-${s.end}${s.place ? ' 地点:' + s.place : ''}`)
|
|
940
|
+
}
|
|
941
|
+
}
|
|
942
|
+
const subCount = (subs.get(ALL) || new Map()).size
|
|
943
|
+
lines.push('', `已订阅的群:${subCount} 个(发送"订阅列表"查看)`)
|
|
944
|
+
return lines.join('\n')
|
|
945
|
+
}
|
|
946
|
+
// 旧任务模式
|
|
667
947
|
const tasks = config.tasks || {}
|
|
668
948
|
const keys = Object.keys(tasks)
|
|
669
|
-
if (!keys.length) return '
|
|
949
|
+
if (!keys.length) return '暂无活动\n请在插件设置页的 activities 活动表里添加活动'
|
|
670
950
|
return keys.map(k => {
|
|
671
951
|
const t = tasks[k]
|
|
672
952
|
const s = t.enabled === false ? '⛔' : '✅'
|
|
673
953
|
const staticGroups = (t.groups || []).join(',')
|
|
674
|
-
const subCount = (subs.get(
|
|
954
|
+
const subCount = (subs.get(ALL) || new Map()).size
|
|
675
955
|
const target = staticGroups ? staticGroups : (subCount ? `订阅${subCount}群` : '所有已知群')
|
|
676
956
|
const rows = parseScheduleRows(t)
|
|
677
957
|
const lines = rows.map((r, i) => {
|
|
@@ -683,51 +963,73 @@ function apply(ctx, config) {
|
|
|
683
963
|
}).join('\n')
|
|
684
964
|
}
|
|
685
965
|
|
|
686
|
-
ctx.command('schedule', '
|
|
966
|
+
ctx.command('schedule', '定时提醒管理')
|
|
687
967
|
.action(() => {
|
|
688
968
|
logger.info('[schedule] 命令被调用: schedule')
|
|
689
|
-
return '⚙️
|
|
690
|
-
|
|
691
|
-
|
|
969
|
+
return '⚙️ 活动表在插件设置页的 activities 里配置\n' +
|
|
970
|
+
'群内发"订阅"即可让本群收到全部提醒\n' +
|
|
971
|
+
'命令:订阅 | 取消订阅 | 订阅列表 | schedule.list | schedule.groups | schedule.test'
|
|
692
972
|
})
|
|
693
973
|
|
|
694
|
-
ctx.command('schedule.list', '
|
|
974
|
+
ctx.command('schedule.list', '查看活动表')
|
|
695
975
|
.action(() => {
|
|
696
976
|
logger.info('[schedule] 命令被调用: schedule.list')
|
|
697
977
|
return formatTaskList()
|
|
698
978
|
})
|
|
699
979
|
|
|
700
|
-
ctx.command('schedule.groups', '
|
|
980
|
+
ctx.command('schedule.groups', '查看机器人已知的群')
|
|
701
981
|
.action(async () => {
|
|
702
982
|
logger.info('[schedule] 命令被调用: schedule.groups')
|
|
703
983
|
return await listKnownGroups()
|
|
704
984
|
})
|
|
705
985
|
|
|
706
|
-
|
|
986
|
+
// 活动表模式下:把某个活动的提醒测试发到当前群;返回 null 表示当前不是活动表模式
|
|
987
|
+
async function testActivityToGroup(session, name) {
|
|
988
|
+
const acts = parseActivities(config.activities)
|
|
989
|
+
if (!acts.length) return null
|
|
990
|
+
const act = name
|
|
991
|
+
? (acts.find(a => a.name === name) || acts.find(a => a.name.includes(name)))
|
|
992
|
+
: acts[0]
|
|
993
|
+
if (!act) return `未找到活动 "${name}",可用 schedule.list 查看活动表`
|
|
994
|
+
const instances = expandInstances(acts)
|
|
995
|
+
const inst = instances.find(i => i.activity === act.name)
|
|
996
|
+
const now = new Date()
|
|
997
|
+
const text = renderRemindTemplate(config.remindTemplate, makeRemindVars(inst, findNextInstance(instances, inst), now, nowHM(now)))
|
|
998
|
+
const bot = (ctx.bots || []).find(b => b.sid === session.bot?.sid)
|
|
999
|
+
if (!bot || !session.guildId) return '❌ 只能在群里测试'
|
|
1000
|
+
await sendOne(bot, String(session.guildId), text, { markdown: !!config.remindMarkdown })
|
|
1001
|
+
return `✅ 已向本群测试发送「${act.name}」的提醒`
|
|
1002
|
+
}
|
|
1003
|
+
|
|
1004
|
+
ctx.command('schedule.test [name:text]', '测试发送提醒(发到当前群)')
|
|
707
1005
|
.action(async ({ session }, name) => {
|
|
708
|
-
logger.info(`[schedule] 命令被调用: schedule.test name=${name}`)
|
|
1006
|
+
logger.info(`[schedule] 命令被调用: schedule.test name=${name || '(第一个)'}`)
|
|
1007
|
+
const actResult = await testActivityToGroup(session, name)
|
|
1008
|
+
if (actResult !== null) return actResult
|
|
1009
|
+
// 旧任务模式
|
|
709
1010
|
const tasks = config.tasks || {}
|
|
710
1011
|
const task = tasks[name]
|
|
711
|
-
if (!task) return `未找到任务 "${name}",可用 schedule.list
|
|
1012
|
+
if (!task) return `未找到任务 "${name}",可用 schedule.list 查看`
|
|
712
1013
|
const testTask = { ...task }
|
|
713
1014
|
if (!testTask.groups || !testTask.groups.length) {
|
|
714
1015
|
testTask.groups = session.guildId ? [String(session.guildId)] : []
|
|
715
1016
|
}
|
|
716
|
-
// 优先发"今天会命中的行",否则发第一条(保证测试总能发出内容)
|
|
717
1017
|
const rows = parseScheduleRows(task)
|
|
718
1018
|
const day = todayDay()
|
|
719
1019
|
const hit = rows.find(r => dayMatches(parseDays(r.wd), day))
|
|
720
1020
|
const sendText = hit ? (hit.text || '') : (rows.length ? (rows[0].text || '') : '')
|
|
721
|
-
await sendTask(name, testTask, { text: sendText, markdown: task.markdown, onlyBotSid: session.bot?.sid })
|
|
722
|
-
return `✅
|
|
1021
|
+
await sendTask(name, testTask, { text: sendText, markdown: task.markdown, onlyBotSid: session.bot?.sid, skipSubs: true })
|
|
1022
|
+
return `✅ 已向本群测试发送任务 "${name}"`
|
|
723
1023
|
})
|
|
724
1024
|
|
|
725
|
-
ctx.command('schedule.testcur
|
|
1025
|
+
ctx.command('schedule.testcur [name:text]', '只向当前群测试发送提醒(不发订阅群)')
|
|
726
1026
|
.action(async ({ session }, name) => {
|
|
727
|
-
logger.info(`[schedule] 命令被调用: schedule.testcur name=${name}`)
|
|
1027
|
+
logger.info(`[schedule] 命令被调用: schedule.testcur name=${name || '(第一个)'}`)
|
|
1028
|
+
const actResult = await testActivityToGroup(session, name)
|
|
1029
|
+
if (actResult !== null) return actResult
|
|
728
1030
|
const tasks = config.tasks || {}
|
|
729
1031
|
const task = tasks[name]
|
|
730
|
-
if (!task) return `未找到任务 "${name}",可用 schedule.list
|
|
1032
|
+
if (!task) return `未找到任务 "${name}",可用 schedule.list 查看`
|
|
731
1033
|
if (!session.guildId) return '❌ 只能在群里使用'
|
|
732
1034
|
const testTask = { ...task, groups: [String(session.guildId)] }
|
|
733
1035
|
const rows = parseScheduleRows(task)
|
|
@@ -761,124 +1063,110 @@ function apply(ctx, config) {
|
|
|
761
1063
|
}
|
|
762
1064
|
|
|
763
1065
|
function permDeniedHint() {
|
|
764
|
-
return '❌
|
|
765
|
-
(config.subscribeAdminBypass ? ',或为本群群主/群管理' : '')
|
|
1066
|
+
return config.permDeniedHint || '❌ 权限不足,请联系李浅浅,或成为群主/管理员'
|
|
766
1067
|
}
|
|
767
1068
|
|
|
768
|
-
//
|
|
769
|
-
function
|
|
1069
|
+
// 当前活动/任务的概览清单文本
|
|
1070
|
+
function activityOverview() {
|
|
1071
|
+
const acts = parseActivities(config.activities)
|
|
1072
|
+
if (acts.length) {
|
|
1073
|
+
return acts.map(a => `【${a.name}】${a.slots.map(s => `${s.wd} ${s.start}-${s.end}`).join('、')}`).join('\n')
|
|
1074
|
+
}
|
|
770
1075
|
const tasks = config.tasks || {}
|
|
771
1076
|
const names = Object.keys(tasks).filter(k => tasks[k] && tasks[k].enabled !== false)
|
|
772
|
-
if (
|
|
1077
|
+
if (names.length) {
|
|
1078
|
+
return names.map(n => `【${n}】${summarizeRows(parseScheduleRows(tasks[n]))}`).join('\n')
|
|
1079
|
+
}
|
|
1080
|
+
return '(暂无活动,请在插件设置页的 activities 活动表里添加)'
|
|
1081
|
+
}
|
|
1082
|
+
|
|
1083
|
+
function isSubscribed(session) {
|
|
773
1084
|
const key = (session.bot && session.guildId) ? `${session.bot.sid}:${session.guildId}` : null
|
|
774
|
-
const
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
lines
|
|
1085
|
+
const allSubs = subs.get(ALL)
|
|
1086
|
+
return !!(key && allSubs && allSubs.has(key))
|
|
1087
|
+
}
|
|
1088
|
+
|
|
1089
|
+
// 列出当前有哪些提醒可以订阅(含本群是否已订阅)
|
|
1090
|
+
function listSubscribable(session) {
|
|
1091
|
+
const lines = ['📋 剑灵提醒(订阅一次,全部活动都会收到):', '', activityOverview()]
|
|
1092
|
+
if (session.bot && session.guildId) {
|
|
1093
|
+
lines.push('', isSubscribed(session) ? '✅ 本群已订阅(发送「取消订阅」可退订)' : '发送「订阅」即可让本群收到全部提醒')
|
|
1094
|
+
}
|
|
781
1095
|
return lines.join('\n')
|
|
782
1096
|
}
|
|
783
1097
|
|
|
784
|
-
//
|
|
1098
|
+
// 列出本群订阅状态
|
|
785
1099
|
function listMySubs(session) {
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
for (const [tn, m] of subs) {
|
|
789
|
-
if (m.has(key)) mine.push(tn)
|
|
790
|
-
}
|
|
791
|
-
if (!mine.length) return '本群还没有订阅任何推送\n发送「订阅」可查看有哪些可以订阅'
|
|
792
|
-
return '📋 本群已订阅:\n' + mine.map((n, i) => `${i + 1}. ${n}`).join('\n') +
|
|
793
|
-
'\n\n发送「取消订阅 任务名」可取消'
|
|
1100
|
+
if (!isSubscribed(session)) return '本群还没有订阅提醒\n发送「订阅」即可订阅全部活动提醒'
|
|
1101
|
+
return '📋 本群已订阅全部剑灵提醒:\n\n' + activityOverview() + '\n\n发送「取消订阅」可退订'
|
|
794
1102
|
}
|
|
795
1103
|
|
|
796
|
-
async function subscribeCurrentGroup(session
|
|
797
|
-
logger.info(
|
|
1104
|
+
async function subscribeCurrentGroup(session) {
|
|
1105
|
+
logger.info('[schedule] 命令被调用: 订阅')
|
|
798
1106
|
if (!session.guildId || !session.bot) return '❌ 订阅只能在群聊里使用'
|
|
799
|
-
// 不带任务名 → 直接列出可以订阅什么
|
|
800
|
-
if (!taskName) return listSubscribable(session)
|
|
801
|
-
const tasks = config.tasks || {}
|
|
802
|
-
if (!tasks[taskName]) return `未找到任务 "${taskName}"\n发送「订阅」可查看可以订阅的列表`
|
|
803
|
-
if (tasks[taskName].enabled === false) return `任务 "${taskName}" 已停用,暂时不能订阅`
|
|
804
|
-
const name = taskName
|
|
805
|
-
const task = tasks[name]
|
|
806
1107
|
const key = `${session.bot.sid}:${session.guildId}`
|
|
807
|
-
const
|
|
808
|
-
if (
|
|
1108
|
+
const allSubs = getTaskSubs(ALL)
|
|
1109
|
+
if (allSubs.has(key)) {
|
|
1110
|
+
return `ℹ️ 本群已经订阅了剑灵提醒(全部活动都会收到)\n\n${activityOverview()}`
|
|
1111
|
+
}
|
|
809
1112
|
const entry = { botSid: session.bot.sid, guildId: session.guildId, createdAt: Date.now() }
|
|
810
|
-
|
|
811
|
-
persistSub(
|
|
812
|
-
logger.info(`[schedule] 群 ${session.guildId}
|
|
813
|
-
|
|
814
|
-
return `✅ 本群已订阅「${name}」\n时间:${summarizeRows(rows)}`
|
|
1113
|
+
allSubs.set(key, entry)
|
|
1114
|
+
persistSub(ALL, entry)
|
|
1115
|
+
logger.info(`[schedule] 群 ${session.guildId} 已订阅全部提醒`)
|
|
1116
|
+
return `✅ 本群已订阅剑灵提醒(以后所有活动都会提前提醒,新活动也自动包含)\n\n${activityOverview()}`
|
|
815
1117
|
}
|
|
816
1118
|
|
|
817
|
-
async function unsubscribeCurrentGroup(session
|
|
818
|
-
logger.info(
|
|
1119
|
+
async function unsubscribeCurrentGroup(session) {
|
|
1120
|
+
logger.info('[schedule] 命令被调用: 取消订阅')
|
|
819
1121
|
if (!session.guildId || !session.bot) return '❌ 取消订阅只能在群聊里使用'
|
|
820
|
-
// 不带任务名 → 列出本群订阅了哪些
|
|
821
|
-
if (!taskName) return listMySubs(session)
|
|
822
|
-
const taskSubs = subs.get(taskName)
|
|
823
1122
|
const key = `${session.bot.sid}:${session.guildId}`
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
1123
|
+
const allSubs = subs.get(ALL)
|
|
1124
|
+
if (!allSubs || !allSubs.has(key)) return 'ℹ️ 本群还没有订阅提醒'
|
|
1125
|
+
allSubs.delete(key)
|
|
1126
|
+
removeSub(ALL, session.bot.sid, session.guildId)
|
|
1127
|
+
logger.info(`[schedule] 群 ${session.guildId} 已取消订阅全部提醒`)
|
|
1128
|
+
return '✅ 本群已取消全部剑灵提醒'
|
|
829
1129
|
}
|
|
830
1130
|
|
|
831
|
-
ctx.command('订阅 [taskName:text]', '
|
|
1131
|
+
ctx.command('订阅 [taskName:text]', '订阅全部剑灵提醒')
|
|
832
1132
|
.action(async ({ session }, taskName) => {
|
|
833
1133
|
if (!(await checkSubscribePerm(session))) return permDeniedHint()
|
|
834
|
-
|
|
1134
|
+
// 兼容旧用法「订阅 任务名」:现在一律按"订阅全部"处理
|
|
1135
|
+
return await subscribeCurrentGroup(session)
|
|
835
1136
|
})
|
|
836
1137
|
|
|
837
|
-
ctx.command('取消订阅 [taskName:text]', '
|
|
1138
|
+
ctx.command('取消订阅 [taskName:text]', '取消本群的全部剑灵提醒')
|
|
838
1139
|
.action(async ({ session }, taskName) => {
|
|
839
1140
|
if (!(await checkSubscribePerm(session))) return permDeniedHint()
|
|
840
1141
|
return await unsubscribeCurrentGroup(session, taskName)
|
|
841
1142
|
})
|
|
842
1143
|
|
|
843
|
-
ctx.command('订阅列表', '
|
|
1144
|
+
ctx.command('订阅列表', '查看订阅了提醒的群')
|
|
844
1145
|
.action(async () => {
|
|
845
1146
|
logger.info('[schedule] 命令被调用: 订阅列表')
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
const name = bot ? await resolveGroupName(bot, s.guildId) : ''
|
|
854
|
-
lines.push(` · ${name || '未知群名'} (${s.guildId})`)
|
|
855
|
-
}
|
|
1147
|
+
const allSubs = subs.get(ALL)
|
|
1148
|
+
if (!allSubs || !allSubs.size) return '暂无任何群订阅。群内发"订阅"即可订阅全部提醒'
|
|
1149
|
+
const lines = [`📋 已订阅提醒的群(共 ${allSubs.size} 个):`]
|
|
1150
|
+
for (const s of allSubs.values()) {
|
|
1151
|
+
const bot = ctx.bots.find(b => b.sid === s.botSid)
|
|
1152
|
+
const name = bot ? await resolveGroupName(bot, s.guildId) : ''
|
|
1153
|
+
lines.push(` · ${name || '未知群名'} (${s.guildId})`)
|
|
856
1154
|
}
|
|
857
|
-
return
|
|
1155
|
+
return lines.join('\n')
|
|
858
1156
|
})
|
|
859
1157
|
|
|
860
|
-
//
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
const
|
|
866
|
-
|
|
867
|
-
if (!
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
const targets = []
|
|
872
|
-
if (all) {
|
|
873
|
-
for (const [tn, m] of subs) {
|
|
874
|
-
for (const e of m.values()) targets.push({ taskName: tn, botSid: e.botSid, guildId: e.guildId })
|
|
875
|
-
}
|
|
876
|
-
}
|
|
877
|
-
else {
|
|
878
|
-
const m = subs.get(name)
|
|
879
|
-
if (m) for (const e of m.values()) targets.push({ taskName: name, botSid: e.botSid, guildId: e.guildId })
|
|
880
|
-
}
|
|
881
|
-
if (!targets.length) return all ? 'ℹ️ 当前没有任何群订阅' : `ℹ️ 任务 "${name}" 当前没有任何群订阅`
|
|
1158
|
+
// 管理员:取消所有群的提醒订阅(并通知每个被取消的群)
|
|
1159
|
+
ctx.command('schedule.unsuball', '取消所有群的提醒订阅(管理员)')
|
|
1160
|
+
.action(async ({ session }) => {
|
|
1161
|
+
logger.info('[schedule] 命令被调用: schedule.unsuball')
|
|
1162
|
+
// 权限:权限等级 ≥ 3,或(开启群主绕过时)本群群主/群管理
|
|
1163
|
+
const isAdmin = (session.user?.authority >= 3) ||
|
|
1164
|
+
(config.subscribeAdminBypass && await isGroupAdmin(session))
|
|
1165
|
+
if (!isAdmin) return permDeniedHint()
|
|
1166
|
+
|
|
1167
|
+
const allSubs = subs.get(ALL)
|
|
1168
|
+
if (!allSubs || !allSubs.size) return 'ℹ️ 当前没有任何群订阅'
|
|
1169
|
+
const targets = [...allSubs.values()].map(e => ({ botSid: e.botSid, guildId: e.guildId }))
|
|
882
1170
|
|
|
883
1171
|
// 逐群发送"已取消"提示
|
|
884
1172
|
let notified = 0
|
|
@@ -886,7 +1174,7 @@ function apply(ctx, config) {
|
|
|
886
1174
|
for (const t of targets) {
|
|
887
1175
|
const bot = (ctx.bots || []).find(b => b.sid === t.botSid)
|
|
888
1176
|
if (!bot || !bot.online) continue
|
|
889
|
-
const text = String(config.unsubAllNotice).split('{task}').join(
|
|
1177
|
+
const text = String(config.unsubAllNotice).split('{task}').join('剑灵提醒')
|
|
890
1178
|
try {
|
|
891
1179
|
await bot.sendMessage(t.guildId, [text])
|
|
892
1180
|
notified++
|
|
@@ -898,24 +1186,18 @@ function apply(ctx, config) {
|
|
|
898
1186
|
}
|
|
899
1187
|
|
|
900
1188
|
// 清空订阅(内存 + 数据库)
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
}
|
|
1189
|
+
allSubs.clear()
|
|
1190
|
+
for (const k of [...subs.keys()]) if (k !== ALL) subs.delete(k)
|
|
1191
|
+
if (ctx.database) {
|
|
1192
|
+
try {
|
|
1193
|
+
await ctx.database.remove('ll_schedule_subs', {})
|
|
1194
|
+
}
|
|
1195
|
+
catch (e) {
|
|
1196
|
+
logger.warn('[schedule] 清空订阅表失败:', e)
|
|
910
1197
|
}
|
|
911
1198
|
}
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
if (m) m.clear()
|
|
915
|
-
for (const t of targets) await removeSub(t.taskName, t.botSid, t.guildId)
|
|
916
|
-
}
|
|
917
|
-
logger.info(`[schedule] 已取消 ${targets.length} 个群订阅(通知 ${notified} 个群)${all ? '(全部任务)' : `任务 "${name}"`}`)
|
|
918
|
-
return `✅ 已取消 ${targets.length} 个群订阅${all ? '(全部任务)' : `(任务「${name}」)`}\n已向 ${notified} 个群发送取消提示`
|
|
1199
|
+
logger.info(`[schedule] 已取消 ${targets.length} 个群的订阅(通知 ${notified} 个群)`)
|
|
1200
|
+
return `✅ 已取消 ${targets.length} 个群的订阅\n已向 ${notified} 个群发送取消提示`
|
|
919
1201
|
})
|
|
920
1202
|
|
|
921
1203
|
ctx.command('我的ID', '查看自己的账号信息(openid/群ID/角色)')
|
|
@@ -933,7 +1215,9 @@ function apply(ctx, config) {
|
|
|
933
1215
|
logger.info('[schedule] 命令已注册: schedule / schedule.list / schedule.groups / schedule.test / ' +
|
|
934
1216
|
`"订阅" / "取消订阅" / 订阅列表 / 我的ID`)
|
|
935
1217
|
|
|
936
|
-
Promise.all([loadKnownGroups(), loadSubs()])
|
|
1218
|
+
Promise.all([loadKnownGroups(), loadSubs()])
|
|
1219
|
+
.then(() => migrateToAll())
|
|
1220
|
+
.then(() => checkAndSend())
|
|
937
1221
|
}
|
|
938
1222
|
|
|
939
1223
|
exports.apply = apply
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "koishi-plugin-ll-schedule",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.7.0",
|
|
4
4
|
"description": "Scheduled message sender with weekly slots (per-day time & content), QQ official markdown card support",
|
|
5
5
|
"main": "index.js",
|
|
6
6
|
"files": [
|
|
@@ -30,3 +30,5 @@
|
|
|
30
30
|
|
|
31
31
|
|
|
32
32
|
|
|
33
|
+
|
|
34
|
+
|