koishi-plugin-ll-schedule 1.6.3 → 1.7.1

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 (2) hide show
  1. package/index.js +417 -216
  2. 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 = `定时提醒:每个任务一个大文本框(schedule)。每段 = 一行"星期 时间",下面几行是内容。
14
+ exports.usage = `剑灵活动提醒:全部活动写在 activities 活动表里,群里发「订阅」即可收到所有活动的提前提醒。
15
15
 
16
- 例:
17
- 周一 21:00
18
- # 世界BOSS马上刷新啦!
19
- > 地点1:仙幻岛
20
- > 地点2:白青山脉
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,46 +230,164 @@ function buildTextFromMd(md) {
242
230
  .trim()
243
231
  }
244
232
 
245
- exports._test = { parseTimes, todayDay, parseDays, dayMatches, parseScheduleBlocks, parseScheduleRows, summarizeRows, computeFires, renderText, renderPushTemplate, buildMd, buildTextFromMd }
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
 
249
- const TaskItem = Schema.object({
250
- schedule: Schema.string()
251
- .role('textarea', { rows: [6, 16] })
367
+ const Config = Schema.object({
368
+ activities: Schema.string()
369
+ .role('textarea', { rows: [8, 20] })
252
370
  .default('')
253
- .description('📅 日程:多行文本框,直接写整张日程。每个时间段分两段:第一行写 星期 时间,下面几行写内容(可多行 markdown)。示例见 usage。'),
254
- groups: Schema.array(Schema.string())
255
- .default([])
256
- .description('手动指定的目标群 ID(可留空;推荐用群内"订阅"命令自动添加,无需手动填 openid)'),
257
- markdown: Schema.boolean()
258
- .default(false)
259
- .description('用 QQ官方 Markdown 卡片发送(仅官方QQ机器人生效,失败自动回退纯文本;其他平台始终纯文本)'),
260
- pushTemplate: Schema.string()
261
- .role('textarea', { rows: [3, 8] })
371
+ .description('📅 剑灵活动表(全部活动写这里,一份表管所有提醒)。格式:[活动名] 提前:N分钟,下面每行 = 星期 开始-结束 地点:xxx'),
372
+ remindTemplate: Schema.string()
373
+ .role('textarea', { rows: [6, 14] })
262
374
  .default('')
263
- .description('接口推送模板(可选):HTTP 接口推送时套用的卡片模板。变量 {place1} {place2} 等 = 推送方传入的字段;留空用默认模板。多行 markdown,例见 usage'),
264
- enabled: Schema.boolean()
375
+ .description('提醒文案模板(留空用默认)。变量:{activity} {advance} {start} {end} {place} {next} {nextStart} {nextEnd} {date} {week};含空变量的整行会自动省略'),
376
+ remindMarkdown: Schema.boolean()
265
377
  .default(true)
266
- .description('是否启用'),
267
- })
268
-
269
- const Config = Schema.object({
270
- tasks: Schema.dict(TaskItem)
271
- .default({})
272
- .description('📋 推送任务表格(键=任务名;每个任务 = 一个日程大文本框 schedule:星期/时间/内容)'),
273
- pushSecret: Schema.string()
274
- .default('')
275
- .description('🔑 HTTP 推送接口密钥:外部系统调用 POST /ll-schedule/push 时须携带相同 secret(留空则接口拒绝所有推送)'),
378
+ .description('活动提醒是否用 QQ官方 Markdown 卡片发送(仅官机生效,失败自动回退纯文本)'),
379
+ globalGroups: Schema.array(Schema.string())
380
+ .default([])
381
+ .description('额外固定推送群(可选):这些群无需订阅也会收到活动提醒(留空即可,一般用群内"订阅"命令)'),
276
382
  unsubAllNotice: Schema.string()
277
- .default('本群已取消「{task}」的推送')
278
- .description('管理员用 schedule.unsuball 取消全部订阅时,发到每个被取消群的提示语({task}=任务名;留空则不发送提示)'),
383
+ .default('本群已取消剑灵提醒推送')
384
+ .description('管理员用 schedule.unsuball 取消全部订阅时,发到每个被取消群的提示语(留空则不发送提示)'),
279
385
  permDeniedHint: Schema.string()
280
386
  .default('❌ 权限不足,请联系李浅浅,或成为群主/管理员')
281
387
  .description('权限不足时的提示语(管理命令/订阅命令都会用它;可改成你自己的联系方式)'),
282
388
  subscribeAuthority: Schema.number()
283
389
  .min(1).max(4).default(1)
284
- .description('执行订阅命令所需的最低权限等级(Koishi 用户权限等级,与QQ群主/管理员身份无关;默认 1=所有人都能订阅。如需限制,先在控制台"用户管理"给指定账号提权,再调高此值)'),
390
+ .description('执行订阅命令所需的最低权限等级(默认 1 = 所有人都能订阅。如需限制:先在控制台给指定账号提权,再把这里调高)'),
285
391
  subscribeAdminBypass: Schema.boolean()
286
392
  .default(true)
287
393
  .description('群主/群管理可无视上面的权限等级直接订阅(从消息自带的成员角色判断,无需申请接口权限)'),
@@ -290,7 +396,7 @@ const Config = Schema.object({
290
396
  .description('任务未指定群也没有群订阅时,发送给该机器人所有已知群'),
291
397
  verbose: Schema.boolean()
292
398
  .default(false)
293
- .description('调试:控制台打印每次发送详情'),
399
+ .description('调试:控制台打印每次发送详情(一般不用开)'),
294
400
  })
295
401
 
296
402
  exports.Config = Config
@@ -334,6 +440,8 @@ function apply(ctx, config) {
334
440
  }
335
441
 
336
442
  // ===== 群订阅 =====
443
+ // 现在订阅是全局的:订阅一次 = 收全部剑灵提醒(存在特殊任务名 ALL 下)
444
+ const ALL = '__all__'
337
445
  const subs = new Map()
338
446
 
339
447
  function getTaskSubs(taskName) {
@@ -345,6 +453,46 @@ function apply(ctx, config) {
345
453
  return taskSubs
346
454
  }
347
455
 
456
+ // 把已有订阅(老的按任务订阅)自动升级为「全部订阅」:按群去重,不重不漏
457
+ async function migrateToAll() {
458
+ if (!ctx.database) return
459
+ try {
460
+ const rows = await ctx.database.get('ll_schedule_subs', {})
461
+ const groupMap = new Map()
462
+ for (const r of rows) {
463
+ if (r.taskName === ALL) continue
464
+ groupMap.set(`${r.botSid}:${r.guildId}`, { botSid: r.botSid, guildId: r.guildId })
465
+ }
466
+ if (!groupMap.size) {
467
+ // 没有老记录:清理内存里可能残留的老键
468
+ for (const k of [...subs.keys()]) if (k !== ALL) subs.delete(k)
469
+ return
470
+ }
471
+ const allSubs = getTaskSubs(ALL)
472
+ const now = Date.now()
473
+ const toInsert = []
474
+ for (const [key, g] of groupMap) {
475
+ if (allSubs.has(key)) continue
476
+ const entry = { botSid: g.botSid, guildId: g.guildId, createdAt: now }
477
+ allSubs.set(key, entry)
478
+ toInsert.push({ taskName: ALL, ...entry })
479
+ }
480
+ if (toInsert.length) {
481
+ await ctx.database.upsert('ll_schedule_subs', toInsert, ['taskName', 'botSid', 'guildId'])
482
+ }
483
+ // 删除老记录 + 清理内存老键
484
+ for (const r of rows) {
485
+ if (r.taskName === ALL) continue
486
+ await removeSub(r.taskName, r.botSid, r.guildId)
487
+ }
488
+ for (const k of [...subs.keys()]) if (k !== ALL) subs.delete(k)
489
+ logger.info(`[schedule] 订阅已升级为「全部订阅」:${groupMap.size} 个群(其中新增 ${toInsert.length})`)
490
+ }
491
+ catch (e) {
492
+ logger.warn('[schedule] 订阅升级失败:', e)
493
+ }
494
+ }
495
+
348
496
  async function loadSubs() {
349
497
  if (!ctx.database) return
350
498
  try {
@@ -520,14 +668,21 @@ function apply(ctx, config) {
520
668
  if (!bots.length) return
521
669
 
522
670
  const groups = (task.groups && task.groups.length > 0) ? task.groups : null
523
- const taskSubs = subs.get(name)
671
+ // 订阅目标 = 该任务的订阅群 ∪ 订阅了"全部提醒"的群(去重)
672
+ const allSubsMap = subs.get(ALL)
524
673
  // 测试模式(skipSubs)时忽略所有订阅群,只发 groups(即当前测试群)
525
674
  const useSubs = !opts.skipSubs
526
675
 
527
676
  for (const bot of bots) {
528
- const subTargets = (useSubs && taskSubs)
529
- ? [...taskSubs.values()].filter(s => s.botSid === bot.sid).map(s => s.guildId)
530
- : []
677
+ const subTargets = []
678
+ if (useSubs) {
679
+ for (const m of [subs.get(name), allSubsMap]) {
680
+ if (!m) continue
681
+ for (const s of m.values()) {
682
+ if (s.botSid === bot.sid && !subTargets.includes(s.guildId)) subTargets.push(s.guildId)
683
+ }
684
+ }
685
+ }
531
686
 
532
687
  if (groups || subTargets.length) {
533
688
  for (const rawTarget of groups || []) {
@@ -577,55 +732,71 @@ function apply(ctx, config) {
577
732
  }
578
733
  }
579
734
 
580
- // ===== HTTP 推送接口:外部系统 POST 数据 → 转为卡片发给该任务订阅群 =====
581
- if (ctx.router) {
582
- ctx.router.post('/ll-schedule/push', async (ctx) => {
583
- const respond = (status, body) => { ctx.status = status; ctx.body = body }
584
- let body = null
585
- try {
586
- body = (typeof ctx.request.body === 'string') ? JSON.parse(ctx.request.body) : (ctx.request.body || {})
587
- }
588
- catch (e) {
589
- return respond(400, { ok: false, error: 'invalid json body' })
590
- }
591
- // 密钥校验(未配置密钥则一律拒绝)
592
- if (!config.pushSecret) return respond(403, { ok: false, error: 'pushSecret not configured' })
593
- if (body.secret !== config.pushSecret) return respond(403, { ok: false, error: 'invalid secret' })
594
-
595
- const taskName = String(body.task || '')
596
- const task = (config.tasks || {})[taskName]
597
- if (!taskName || !task) return respond(404, { ok: false, error: `task "${taskName}" not found` })
598
-
599
- // 除 secret/task/delay 外的字段都作为模板变量(place1/place2/time/...)
600
- const data = { ...body }
601
- delete data.secret
602
- delete data.task
603
- delete data.delay
604
- const now = new Date()
605
- const rendered = renderPushTemplate(task.pushTemplate, data, {
606
- date: todayStr(now),
607
- time: nowHM(now),
608
- week: '星期' + WEEK_CN[todayDay(now)],
609
- task: taskName,
610
- })
611
- if (!rendered.trim()) return respond(400, { ok: false, error: 'empty content' })
735
+ // ===== 活动提醒(活动表模式)=====
612
736
 
613
- const delay = Math.max(0, parseInt(body.delay, 10) || 0)
614
- const push = async () => {
615
- // 只发给订阅群(含手动 groups),不发全量已知群
616
- await sendTask(taskName, task, { text: rendered, markdown: task.markdown })
737
+ // 发给所有"订阅了全部提醒"的群(+ 额外固定群)
738
+ async function sendToSubscribers(text, { markdown, image } = {}) {
739
+ if (!text) return 0
740
+ const allSubs = subs.get(ALL)
741
+ const fixed = Array.isArray(config.globalGroups) ? config.globalGroups : []
742
+ let bots = (ctx.bots || []).filter(b => b.online)
743
+ if (!bots.length) return 0
744
+ let sent = 0
745
+ for (const bot of bots) {
746
+ const list = []
747
+ if (allSubs) {
748
+ for (const t of allSubs.values()) {
749
+ if (t.botSid === bot.sid && !list.includes(t.guildId)) list.push(t.guildId)
750
+ }
617
751
  }
618
- if (delay > 0) {
619
- setTimeout(push, delay * 60 * 1000)
620
- logger.info(`[schedule] 接口已接收任务 "${taskName}",${delay} 分钟后推送`)
752
+ for (const g of fixed) {
753
+ const gs = String(g).trim()
754
+ if (gs && !list.includes(gs)) list.push(gs)
621
755
  }
622
- else {
623
- await push()
624
- logger.info(`[schedule] 接口已推送任务 "${taskName}"`)
756
+ // 一个订阅群都没有时,按配置兜底发给所有已知群
757
+ if (!list.length && config.sendAllWhenEmpty) {
758
+ for (const g of knownGroups.values()) {
759
+ if (g.botSid === bot.sid && !list.includes(g.guildId)) list.push(g.guildId)
760
+ }
625
761
  }
626
- return respond(200, { ok: true, delay, task: taskName })
627
- })
628
- logger.info('[schedule] HTTP 推送接口已注册: POST /ll-schedule/push')
762
+ for (const target of list) {
763
+ try {
764
+ await sendOne(bot, target, text, { markdown, image })
765
+ sent++
766
+ if (config.verbose) logger.info(`[schedule] 提醒已发 ${bot.sid}:${target}`)
767
+ }
768
+ catch (e) {
769
+ logger.error(`[schedule] 提醒发送失败 ${bot.sid}:${target}: ${e.message}`)
770
+ }
771
+ }
772
+ }
773
+ return sent
774
+ }
775
+
776
+ // 组装提醒模板变量
777
+ function makeRemindVars(inst, next, now, hm) {
778
+ return {
779
+ activity: inst.activity,
780
+ advance: String(inst.advance || 0),
781
+ start: inst.start,
782
+ end: inst.end,
783
+ place: inst.place || '',
784
+ date: todayStr(now),
785
+ time: hm,
786
+ week: '星期' + WEEK_CN[todayDay(now)],
787
+ next: next ? next.activity : '',
788
+ nextStart: next ? next.start : '',
789
+ nextEnd: next ? next.end : '',
790
+ nextPlace: next ? (next.place || '') : '',
791
+ }
792
+ }
793
+
794
+ // 渲染并发送一条活动提醒
795
+ async function sendReminder(inst, next, now, hm) {
796
+ const text = renderRemindTemplate(config.remindTemplate, makeRemindVars(inst, next, now, hm))
797
+ if (!text) return
798
+ logger.info(`[schedule] 活动提醒「${inst.activity}」开始于 ${inst.start}(提前 ${inst.advance} 分钟)`)
799
+ await sendToSubscribers(text, { markdown: !!config.remindMarkdown })
629
800
  }
630
801
 
631
802
  async function checkAndSend() {
@@ -636,6 +807,20 @@ function apply(ctx, config) {
636
807
  }
637
808
 
638
809
  const hm = nowHM(now)
810
+
811
+ // ① 活动表模式(有新活动表就只跑它,避免和旧任务重复发)
812
+ const activities = parseActivities(config.activities)
813
+ if (activities.length) {
814
+ const reminds = computeReminders(activities, now, hm)
815
+ for (const r of reminds) {
816
+ if (firedToday[r.key]) continue
817
+ firedToday[r.key] = true
818
+ await sendReminder(r.inst, r.next, now, hm)
819
+ }
820
+ return
821
+ }
822
+
823
+ // ② 旧任务模式
639
824
  const fires = computeFires(config.tasks, now, hm)
640
825
 
641
826
  for (const f of fires) {
@@ -667,14 +852,29 @@ function apply(ctx, config) {
667
852
  }
668
853
 
669
854
  function formatTaskList() {
855
+ // 活动表模式
856
+ const acts = parseActivities(config.activities)
857
+ if (acts.length) {
858
+ const lines = ['📅 活动表(订阅后全部活动都会提前提醒):', '']
859
+ for (const a of acts) {
860
+ lines.push(`【${a.name}】提前 ${a.advance} 分钟`)
861
+ for (const s of a.slots) {
862
+ lines.push(` · ${s.wd} ${s.start}-${s.end}${s.place ? ' 地点:' + s.place : ''}`)
863
+ }
864
+ }
865
+ const subCount = (subs.get(ALL) || new Map()).size
866
+ lines.push('', `已订阅的群:${subCount} 个(发送"订阅列表"查看)`)
867
+ return lines.join('\n')
868
+ }
869
+ // 旧任务模式
670
870
  const tasks = config.tasks || {}
671
871
  const keys = Object.keys(tasks)
672
- if (!keys.length) return '暂无定时任务\n请在插件设置页的 tasks 表格中添加任务'
872
+ if (!keys.length) return '暂无活动\n请在插件设置页的 activities 活动表里添加活动'
673
873
  return keys.map(k => {
674
874
  const t = tasks[k]
675
875
  const s = t.enabled === false ? '⛔' : '✅'
676
876
  const staticGroups = (t.groups || []).join(',')
677
- const subCount = (subs.get(k) || new Map()).size
877
+ const subCount = (subs.get(ALL) || new Map()).size
678
878
  const target = staticGroups ? staticGroups : (subCount ? `订阅${subCount}群` : '所有已知群')
679
879
  const rows = parseScheduleRows(t)
680
880
  const lines = rows.map((r, i) => {
@@ -686,51 +886,73 @@ function apply(ctx, config) {
686
886
  }).join('\n')
687
887
  }
688
888
 
689
- ctx.command('schedule', '定时发送管理')
889
+ ctx.command('schedule', '定时提醒管理')
690
890
  .action(() => {
691
891
  logger.info('[schedule] 命令被调用: schedule')
692
- return '⚙️ 管理:插件设置页 → ll-schedule → tasks 添加任务和时间段(星期/时间/内容)\n' +
693
- `群内发"订阅 任务名"即可让本群订阅推送\n` +
694
- `命令:订阅 | 取消订阅 | 订阅列表 | schedule.list | schedule.groups | schedule.test <任务名>`
892
+ return '⚙️ 活动表在插件设置页的 activities 里配置\n' +
893
+ '群内发"订阅"即可让本群收到全部提醒\n' +
894
+ '命令:订阅 | 取消订阅 | 订阅列表 | schedule.list | schedule.groups | schedule.test'
695
895
  })
696
896
 
697
- ctx.command('schedule.list', '查看定时任务')
897
+ ctx.command('schedule.list', '查看活动表')
698
898
  .action(() => {
699
899
  logger.info('[schedule] 命令被调用: schedule.list')
700
900
  return formatTaskList()
701
901
  })
702
902
 
703
- ctx.command('schedule.groups', '查看机器人已知的群(官机显示 openid)')
903
+ ctx.command('schedule.groups', '查看机器人已知的群')
704
904
  .action(async () => {
705
905
  logger.info('[schedule] 命令被调用: schedule.groups')
706
906
  return await listKnownGroups()
707
907
  })
708
908
 
709
- ctx.command('schedule.test <name>', '立即测试发送指定任务')
909
+ // 活动表模式下:把某个活动的提醒测试发到当前群;返回 null 表示当前不是活动表模式
910
+ async function testActivityToGroup(session, name) {
911
+ const acts = parseActivities(config.activities)
912
+ if (!acts.length) return null
913
+ const act = name
914
+ ? (acts.find(a => a.name === name) || acts.find(a => a.name.includes(name)))
915
+ : acts[0]
916
+ if (!act) return `未找到活动 "${name}",可用 schedule.list 查看活动表`
917
+ const instances = expandInstances(acts)
918
+ const inst = instances.find(i => i.activity === act.name)
919
+ const now = new Date()
920
+ const text = renderRemindTemplate(config.remindTemplate, makeRemindVars(inst, findNextInstance(instances, inst), now, nowHM(now)))
921
+ const bot = (ctx.bots || []).find(b => b.sid === session.bot?.sid)
922
+ if (!bot || !session.guildId) return '❌ 只能在群里测试'
923
+ await sendOne(bot, String(session.guildId), text, { markdown: !!config.remindMarkdown })
924
+ return `✅ 已向本群测试发送「${act.name}」的提醒`
925
+ }
926
+
927
+ ctx.command('schedule.test [name:text]', '测试发送提醒(发到当前群)')
710
928
  .action(async ({ session }, name) => {
711
- logger.info(`[schedule] 命令被调用: schedule.test name=${name}`)
929
+ logger.info(`[schedule] 命令被调用: schedule.test name=${name || '(第一个)'}`)
930
+ const actResult = await testActivityToGroup(session, name)
931
+ if (actResult !== null) return actResult
932
+ // 旧任务模式
712
933
  const tasks = config.tasks || {}
713
934
  const task = tasks[name]
714
- if (!task) return `未找到任务 "${name}",可用 schedule.list 查看任务名`
935
+ if (!task) return `未找到任务 "${name}",可用 schedule.list 查看`
715
936
  const testTask = { ...task }
716
937
  if (!testTask.groups || !testTask.groups.length) {
717
938
  testTask.groups = session.guildId ? [String(session.guildId)] : []
718
939
  }
719
- // 优先发"今天会命中的行",否则发第一条(保证测试总能发出内容)
720
940
  const rows = parseScheduleRows(task)
721
941
  const day = todayDay()
722
942
  const hit = rows.find(r => dayMatches(parseDays(r.wd), day))
723
943
  const sendText = hit ? (hit.text || '') : (rows.length ? (rows[0].text || '') : '')
724
- await sendTask(name, testTask, { text: sendText, markdown: task.markdown, onlyBotSid: session.bot?.sid })
725
- return `✅ 已发送任务 "${name}"`
944
+ await sendTask(name, testTask, { text: sendText, markdown: task.markdown, onlyBotSid: session.bot?.sid, skipSubs: true })
945
+ return `✅ 已向本群测试发送任务 "${name}"`
726
946
  })
727
947
 
728
- ctx.command('schedule.testcur <name>', '只向当前群测试发送(不发订阅群)')
948
+ ctx.command('schedule.testcur [name:text]', '只向当前群测试发送提醒(不发订阅群)')
729
949
  .action(async ({ session }, name) => {
730
- logger.info(`[schedule] 命令被调用: schedule.testcur name=${name}`)
950
+ logger.info(`[schedule] 命令被调用: schedule.testcur name=${name || '(第一个)'}`)
951
+ const actResult = await testActivityToGroup(session, name)
952
+ if (actResult !== null) return actResult
731
953
  const tasks = config.tasks || {}
732
954
  const task = tasks[name]
733
- if (!task) return `未找到任务 "${name}",可用 schedule.list 查看任务名`
955
+ if (!task) return `未找到任务 "${name}",可用 schedule.list 查看`
734
956
  if (!session.guildId) return '❌ 只能在群里使用'
735
957
  const testTask = { ...task, groups: [String(session.guildId)] }
736
958
  const rows = parseScheduleRows(task)
@@ -767,124 +989,107 @@ function apply(ctx, config) {
767
989
  return config.permDeniedHint || '❌ 权限不足,请联系李浅浅,或成为群主/管理员'
768
990
  }
769
991
 
770
- // 列出所有可订阅的任务(含时间概览 + 本群是否已订阅)
771
- function listSubscribable(session) {
992
+ // 当前活动/任务的概览清单文本
993
+ function activityOverview() {
994
+ const acts = parseActivities(config.activities)
995
+ if (acts.length) {
996
+ return acts.map(a => `【${a.name}】${a.slots.map(s => `${s.wd} ${s.start}-${s.end}`).join('、')}`).join('\n')
997
+ }
772
998
  const tasks = config.tasks || {}
773
999
  const names = Object.keys(tasks).filter(k => tasks[k] && tasks[k].enabled !== false)
774
- if (!names.length) return '暂无任何可订阅的推送(请在插件设置页的 tasks 里添加任务)'
1000
+ if (names.length) {
1001
+ return names.map(n => `【${n}】${summarizeRows(parseScheduleRows(tasks[n]))}`).join('\n')
1002
+ }
1003
+ return '(暂无活动,请在插件设置页的 activities 活动表里添加)'
1004
+ }
1005
+
1006
+ function isSubscribed(session) {
775
1007
  const key = (session.bot && session.guildId) ? `${session.bot.sid}:${session.guildId}` : null
776
- const lines = ['📋 可以订阅的推送:', '']
777
- names.forEach((n, i) => {
778
- const rows = parseScheduleRows(tasks[n])
779
- const subbed = (key && subs.get(n) && subs.get(n).has(key)) ? ' ✅本群已订阅' : ''
780
- lines.push(`${i + 1}. ${n} —— ${summarizeRows(rows)}${subbed}`)
781
- })
782
- lines.push('', `发送「订阅 任务名」即可订阅,例如:订阅 ${names[0]}`)
1008
+ const allSubs = subs.get(ALL)
1009
+ return !!(key && allSubs && allSubs.has(key))
1010
+ }
1011
+
1012
+ // 列出当前有哪些提醒可以订阅(含本群是否已订阅)
1013
+ function listSubscribable(session) {
1014
+ const lines = ['📋 剑灵提醒(订阅一次,全部活动都会收到):', '', activityOverview()]
1015
+ if (session.bot && session.guildId) {
1016
+ lines.push('', isSubscribed(session) ? '✅ 本群已订阅(发送「取消订阅」可退订)' : '发送「订阅」即可让本群收到全部提醒')
1017
+ }
783
1018
  return lines.join('\n')
784
1019
  }
785
1020
 
786
- // 列出本群已订阅的任务
1021
+ // 列出本群订阅状态
787
1022
  function listMySubs(session) {
788
- const key = `${session.bot.sid}:${session.guildId}`
789
- const mine = []
790
- for (const [tn, m] of subs) {
791
- if (m.has(key)) mine.push(tn)
792
- }
793
- if (!mine.length) return '本群还没有订阅任何推送\n发送「订阅」可查看有哪些可以订阅'
794
- return '📋 本群已订阅:\n' + mine.map((n, i) => `${i + 1}. ${n}`).join('\n') +
795
- '\n\n发送「取消订阅 任务名」可取消'
1023
+ if (!isSubscribed(session)) return '本群还没有订阅提醒\n发送「订阅」即可订阅全部活动提醒'
1024
+ return '📋 本群已订阅全部剑灵提醒:\n\n' + activityOverview() + '\n\n发送「取消订阅」可退订'
796
1025
  }
797
1026
 
798
- async function subscribeCurrentGroup(session, taskName) {
799
- logger.info(`[schedule] 命令被调用: 订阅 taskName=${taskName || '(列表)'}`)
1027
+ async function subscribeCurrentGroup(session) {
1028
+ logger.info('[schedule] 命令被调用: 订阅')
800
1029
  if (!session.guildId || !session.bot) return '❌ 订阅只能在群聊里使用'
801
- // 不带任务名 → 直接列出可以订阅什么
802
- if (!taskName) return listSubscribable(session)
803
- const tasks = config.tasks || {}
804
- if (!tasks[taskName]) return `未找到任务 "${taskName}"\n发送「订阅」可查看可以订阅的列表`
805
- if (tasks[taskName].enabled === false) return `任务 "${taskName}" 已停用,暂时不能订阅`
806
- const name = taskName
807
- const task = tasks[name]
808
1030
  const key = `${session.bot.sid}:${session.guildId}`
809
- const taskSubs = getTaskSubs(name)
810
- if (taskSubs.has(key)) return `ℹ️ 本群已在任务 "${name}" 的推送列表里`
1031
+ const allSubs = getTaskSubs(ALL)
1032
+ if (allSubs.has(key)) {
1033
+ return `ℹ️ 本群已经订阅了剑灵提醒(全部活动都会收到)\n\n${activityOverview()}`
1034
+ }
811
1035
  const entry = { botSid: session.bot.sid, guildId: session.guildId, createdAt: Date.now() }
812
- taskSubs.set(key, entry)
813
- persistSub(name, entry)
814
- logger.info(`[schedule] 群 ${session.guildId} 已订阅任务 "${name}"`)
815
- const rows = parseScheduleRows(task)
816
- return `✅ 本群已订阅「${name}」\n时间:${summarizeRows(rows)}`
1036
+ allSubs.set(key, entry)
1037
+ persistSub(ALL, entry)
1038
+ logger.info(`[schedule] 群 ${session.guildId} 已订阅全部提醒`)
1039
+ return `✅ 本群已订阅剑灵提醒(以后所有活动都会提前提醒,新活动也自动包含)\n\n${activityOverview()}`
817
1040
  }
818
1041
 
819
- async function unsubscribeCurrentGroup(session, taskName) {
820
- logger.info(`[schedule] 命令被调用: 取消订阅 taskName=${taskName || '(列表)'}`)
1042
+ async function unsubscribeCurrentGroup(session) {
1043
+ logger.info('[schedule] 命令被调用: 取消订阅')
821
1044
  if (!session.guildId || !session.bot) return '❌ 取消订阅只能在群聊里使用'
822
- // 不带任务名 → 列出本群订阅了哪些
823
- if (!taskName) return listMySubs(session)
824
- const taskSubs = subs.get(taskName)
825
1045
  const key = `${session.bot.sid}:${session.guildId}`
826
- if (!taskSubs || !taskSubs.has(key)) return `ℹ️ 本群没有订阅「${taskName}」\n发送「取消订阅」可查看本群已订阅的推送`
827
- taskSubs.delete(key)
828
- removeSub(taskName, session.bot.sid, session.guildId)
829
- logger.info(`[schedule] 群 ${session.guildId} 已取消订阅任务 "${taskName}"`)
830
- return `✅ 本群已取消订阅「${taskName}」`
1046
+ const allSubs = subs.get(ALL)
1047
+ if (!allSubs || !allSubs.has(key)) return 'ℹ️ 本群还没有订阅提醒'
1048
+ allSubs.delete(key)
1049
+ removeSub(ALL, session.bot.sid, session.guildId)
1050
+ logger.info(`[schedule] 群 ${session.guildId} 已取消订阅全部提醒`)
1051
+ return '✅ 本群已取消全部剑灵提醒'
831
1052
  }
832
1053
 
833
- ctx.command('订阅 [taskName:text]', '让当前群订阅指定任务的推送')
1054
+ ctx.command('订阅 [taskName:text]', '订阅全部剑灵提醒')
834
1055
  .action(async ({ session }, taskName) => {
835
1056
  if (!(await checkSubscribePerm(session))) return permDeniedHint()
836
- return await subscribeCurrentGroup(session, taskName)
1057
+ // 兼容旧用法「订阅 任务名」:现在一律按"订阅全部"处理
1058
+ return await subscribeCurrentGroup(session)
837
1059
  })
838
1060
 
839
- ctx.command('取消订阅 [taskName:text]', '让当前群取消订阅指定任务的推送')
1061
+ ctx.command('取消订阅 [taskName:text]', '取消本群的全部剑灵提醒')
840
1062
  .action(async ({ session }, taskName) => {
841
1063
  if (!(await checkSubscribePerm(session))) return permDeniedHint()
842
1064
  return await unsubscribeCurrentGroup(session, taskName)
843
1065
  })
844
1066
 
845
- ctx.command('订阅列表', '查看所有任务的订阅群')
1067
+ ctx.command('订阅列表', '查看订阅了提醒的群')
846
1068
  .action(async () => {
847
1069
  logger.info('[schedule] 命令被调用: 订阅列表')
848
- if (!subs.size) return '暂无任何群订阅。群内发"订阅 任务名"即可订阅'
849
- const lines = []
850
- for (const [taskName, taskSubs] of subs) {
851
- const entries = [...taskSubs.values()]
852
- lines.push(`【${taskName}】${entries.length} 个群`)
853
- for (const s of entries) {
854
- const bot = ctx.bots.find(b => b.sid === s.botSid)
855
- const name = bot ? await resolveGroupName(bot, s.guildId) : ''
856
- lines.push(` · ${name || '未知群名'} (${s.guildId})`)
857
- }
1070
+ const allSubs = subs.get(ALL)
1071
+ if (!allSubs || !allSubs.size) return '暂无任何群订阅。群内发"订阅"即可订阅全部提醒'
1072
+ const lines = [`📋 已订阅提醒的群(共 ${allSubs.size} 个):`]
1073
+ for (const s of allSubs.values()) {
1074
+ const bot = ctx.bots.find(b => b.sid === s.botSid)
1075
+ const name = bot ? await resolveGroupName(bot, s.guildId) : ''
1076
+ lines.push(` · ${name || '未知群名'} (${s.guildId})`)
858
1077
  }
859
- return '📋 订阅列表:\n' + lines.join('\n')
1078
+ return lines.join('\n')
860
1079
  })
861
1080
 
862
- // 管理员:取消指定任务的全部群订阅(并通知每个被取消的群)
863
- // 任务名可填 all / * 表示清空所有任务的全部订阅
864
- ctx.command('schedule.unsuball <name>', '取消某任务的全部群订阅(管理员;填 all 清空全部)')
865
- .action(async ({ session }, name) => {
866
- logger.info(`[schedule] 命令被调用: schedule.unsuball name=${name}`)
867
- // 权限:Koishi 权限 ≥ 3,或(开启群主绕过时)本群群主/群管理
1081
+ // 管理员:取消所有群的提醒订阅(并通知每个被取消的群)
1082
+ ctx.command('schedule.unsuball', '取消所有群的提醒订阅(管理员)')
1083
+ .action(async ({ session }) => {
1084
+ logger.info('[schedule] 命令被调用: schedule.unsuball')
1085
+ // 权限:权限等级 ≥ 3,或(开启群主绕过时)本群群主/群管理
868
1086
  const isAdmin = (session.user?.authority >= 3) ||
869
1087
  (config.subscribeAdminBypass && await isGroupAdmin(session))
870
1088
  if (!isAdmin) return permDeniedHint()
871
- const tasks = config.tasks || {}
872
- const all = name === 'all' || name === '*'
873
- if (!all && (!name || !tasks[name])) {
874
- return `未找到任务 "${name}",可用 schedule.list 查看任务名(也可以填 all 清空全部任务的订阅)`
875
- }
876
- // 收集要取消的订阅
877
- const targets = []
878
- if (all) {
879
- for (const [tn, m] of subs) {
880
- for (const e of m.values()) targets.push({ taskName: tn, botSid: e.botSid, guildId: e.guildId })
881
- }
882
- }
883
- else {
884
- const m = subs.get(name)
885
- if (m) for (const e of m.values()) targets.push({ taskName: name, botSid: e.botSid, guildId: e.guildId })
886
- }
887
- if (!targets.length) return all ? 'ℹ️ 当前没有任何群订阅' : `ℹ️ 任务 "${name}" 当前没有任何群订阅`
1089
+
1090
+ const allSubs = subs.get(ALL)
1091
+ if (!allSubs || !allSubs.size) return 'ℹ️ 当前没有任何群订阅'
1092
+ const targets = [...allSubs.values()].map(e => ({ botSid: e.botSid, guildId: e.guildId }))
888
1093
 
889
1094
  // 逐群发送"已取消"提示
890
1095
  let notified = 0
@@ -892,7 +1097,7 @@ function apply(ctx, config) {
892
1097
  for (const t of targets) {
893
1098
  const bot = (ctx.bots || []).find(b => b.sid === t.botSid)
894
1099
  if (!bot || !bot.online) continue
895
- const text = String(config.unsubAllNotice).split('{task}').join(t.taskName)
1100
+ const text = String(config.unsubAllNotice).split('{task}').join('剑灵提醒')
896
1101
  try {
897
1102
  await bot.sendMessage(t.guildId, [text])
898
1103
  notified++
@@ -904,24 +1109,18 @@ function apply(ctx, config) {
904
1109
  }
905
1110
 
906
1111
  // 清空订阅(内存 + 数据库)
907
- if (all) {
908
- for (const m of subs.values()) m.clear()
909
- if (ctx.database) {
910
- try {
911
- await ctx.database.remove('ll_schedule_subs', {})
912
- }
913
- catch (e) {
914
- logger.warn('[schedule] 清空订阅表失败:', e)
915
- }
1112
+ allSubs.clear()
1113
+ for (const k of [...subs.keys()]) if (k !== ALL) subs.delete(k)
1114
+ if (ctx.database) {
1115
+ try {
1116
+ await ctx.database.remove('ll_schedule_subs', {})
1117
+ }
1118
+ catch (e) {
1119
+ logger.warn('[schedule] 清空订阅表失败:', e)
916
1120
  }
917
1121
  }
918
- else {
919
- const m = subs.get(name)
920
- if (m) m.clear()
921
- for (const t of targets) await removeSub(t.taskName, t.botSid, t.guildId)
922
- }
923
- logger.info(`[schedule] 已取消 ${targets.length} 个群订阅(通知 ${notified} 个群)${all ? '(全部任务)' : `任务 "${name}"`}`)
924
- return `✅ 已取消 ${targets.length} 个群订阅${all ? '(全部任务)' : `(任务「${name}」)`}\n已向 ${notified} 个群发送取消提示`
1122
+ logger.info(`[schedule] 已取消 ${targets.length} 个群的订阅(通知 ${notified} 个群)`)
1123
+ return `✅ 已取消 ${targets.length} 个群的订阅\n已向 ${notified} 个群发送取消提示`
925
1124
  })
926
1125
 
927
1126
  ctx.command('我的ID', '查看自己的账号信息(openid/群ID/角色)')
@@ -939,7 +1138,9 @@ function apply(ctx, config) {
939
1138
  logger.info('[schedule] 命令已注册: schedule / schedule.list / schedule.groups / schedule.test / ' +
940
1139
  `"订阅" / "取消订阅" / 订阅列表 / 我的ID`)
941
1140
 
942
- Promise.all([loadKnownGroups(), loadSubs()]).then(() => checkAndSend())
1141
+ Promise.all([loadKnownGroups(), loadSubs()])
1142
+ .then(() => migrateToAll())
1143
+ .then(() => checkAndSend())
943
1144
  }
944
1145
 
945
1146
  exports.apply = apply
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "koishi-plugin-ll-schedule",
3
- "version": "1.6.3",
3
+ "version": "1.7.1",
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": [
@@ -31,3 +31,5 @@
31
31
 
32
32
 
33
33
 
34
+
35
+