koishi-plugin-ll-schedule 1.0.3 → 1.0.4

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 +237 -50
  2. package/package.json +1 -1
package/index.js CHANGED
@@ -2,9 +2,7 @@
2
2
 
3
3
  // koishi-plugin-ll-schedule
4
4
  // 定时发送插件:设置时间自动向指定群发送文字/图片
5
- // v1.0.1:兼容官方QQ机器人(官机)
6
- // 官机的群 ID 是 openid(不是数字群号),本插件通过"学习"机器人收到的群消息来记录群,
7
- // 用 schedule.groups 命令查看已知群并填入 tasks.groups;留空则发送给所有已知群。
5
+ // v1.0.4:群内订阅(发关键词即可让本群加入推送列表,官机自动用 openid,无需手动填群号)
8
6
 
9
7
  const { Schema, h } = require('koishi')
10
8
 
@@ -12,30 +10,34 @@ exports.name = 'll-schedule'
12
10
 
13
11
  exports.usage = `定时发送插件:设置时间自动向指定群发送文字/图片。
14
12
 
15
- ## 任务配置
13
+ ## 快速上手(推荐用法)
14
+
15
+ 1. 在插件设置页的 \`tasks\` 表格中添加任务:**键=任务名**,设置 \`time\`(发送时间)和 \`text\`/\`image\`(推送内容)
16
+ 2. 在任意群里 @机器人 发:\`订阅 任务名\` → 这个群就加入了该任务的推送列表(自动记录,重启不丢)
17
+ 3. 到点后插件自动把内容推送到所有订阅了该任务的群
18
+
19
+ ## 群内命令
20
+
21
+ - \`订阅 [任务名]\`:当前群订阅指定任务的推送(不填任务名时,只有一个启用任务则自动选它)
22
+ - \`取消订阅 [任务名]\`:当前群取消订阅
23
+ - \`订阅列表\`:查看所有任务的订阅群
24
+ - \`schedule.list\`:查看任务列表
25
+ - \`schedule.test <任务名>\`:立即测试发送
26
+ - \`lls.*\`:以上命令的备用入口(lls.sub / lls.unsub / lls.sublist / lls.list / lls.test)
27
+
28
+ 订阅/取消订阅的权限等级可在设置页 \`subscribeAuthority\` 调整(默认 2=群管理)。
16
29
 
17
- 在插件设置页的 \`tasks\` 表格中添加任务(键=任务名),每项:
30
+ ## 任务配置
18
31
 
19
32
  - \`time\`:发送时间,HH:MM 格式(24小时制)
20
- - \`groups\`:目标群列表(**留空=发送给所有已知群**)
33
+ - \`groups\`:手动指定的目标群列表(**推荐留空**,用群内"订阅"命令添加更省事;留空且无订阅 = 发给所有已知群)
21
34
  - \`text\`:文字内容(可留空)
22
35
  - \`image\`:图片 URL(可留空)
23
36
  - \`enabled\`:是否启用
24
37
 
25
38
  ## 官机(官方QQ机器人)说明
26
39
 
27
- 官方平台没有"群号",只有 openid。机器人收到过消息的群会被自动记录:
28
-
29
- - 命令 \`schedule.groups\` 查看所有已知群(显示群名和 openid)
30
- - 把 openid 填进任务的 \`groups\` 里即可
31
- - 注意:官机主动发群消息有平台频率限制,且机器人需在群里
32
-
33
- ## 命令
34
-
35
- - \`schedule\` 帮助
36
- - \`schedule.list\` 查看任务
37
- - \`schedule.groups\` 查看机器人已知的群(官机显示 openid)
38
- - \`schedule.test <任务名>\` 立即测试发送
40
+ 官方平台没有"群号",只有 openid——用群内"订阅"命令即可,插件会自动记录当前群的 openid,无需手动填写。手动填 \`groups\` 时可用 \`schedule.groups\` 查看已知群的 openid。官机主动发群消息有平台频率限制,且机器人需在群里。
39
41
  `
40
42
 
41
43
  const TaskItem = Schema.object({
@@ -44,7 +46,7 @@ const TaskItem = Schema.object({
44
46
  .description('发送时间,HH:MM 格式(24小时制),如 08:30、21:00'),
45
47
  groups: Schema.array(Schema.string())
46
48
  .default([])
47
- .description('目标群 ID(留空=所有已知群;官机填 openid,OneBot 填数字群号)'),
49
+ .description('手动指定的目标群 ID(可留空;推荐用群内"订阅"命令自动添加,无需手动填 openid)'),
48
50
  text: Schema.string()
49
51
  .default('')
50
52
  .description('文字内容(可留空)'),
@@ -59,10 +61,19 @@ const TaskItem = Schema.object({
59
61
  const Config = Schema.object({
60
62
  tasks: Schema.dict(TaskItem)
61
63
  .default({})
62
- .description('📋 定时任务表格(键=任务名)'),
64
+ .description('📋 定时任务表格(键=任务名,设置时间和推送内容)'),
65
+ subscribeKeyword: Schema.string()
66
+ .default('订阅')
67
+ .description('群内订阅推送的关键词(如"订阅 任务名",只输关键词则订阅唯一启用的任务)'),
68
+ unsubscribeKeyword: Schema.string()
69
+ .default('取消订阅')
70
+ .description('群内取消订阅的关键词(如"取消订阅 任务名")'),
71
+ subscribeAuthority: Schema.number()
72
+ .min(1).max(4).default(2)
73
+ .description('订阅/取消订阅所需权限等级(1=所有人 2=群管理 3=群主 4=管理员)'),
63
74
  sendAllWhenEmpty: Schema.boolean()
64
75
  .default(true)
65
- .description('任务未指定群时,发送给该机器人所有已知群'),
76
+ .description('任务未指定群也没有群订阅时,发送给该机器人所有已知群'),
66
77
  verbose: Schema.boolean()
67
78
  .default(false)
68
79
  .description('调试:控制台打印每次发送详情'),
@@ -102,6 +113,64 @@ function apply(ctx, config) {
102
113
  platform: 'string',
103
114
  learnedAt: 'unsigned',
104
115
  }, { primary: ['botSid', 'guildId'] })
116
+ // 群订阅表:taskName + botSid + guildId(群内"订阅"命令注册的推送目标)
117
+ ctx.model.extend('ll_schedule_subs', {
118
+ taskName: 'string',
119
+ botSid: 'string',
120
+ guildId: 'string',
121
+ createdAt: 'unsigned',
122
+ }, { primary: ['taskName', 'botSid', 'guildId'] })
123
+ }
124
+
125
+ // ===== 群订阅(群内发关键词注册推送目标) =====
126
+ // subs: Map<taskName, Map<`${botSid}:${guildId}`, { botSid, guildId, createdAt }>>
127
+ const subs = new Map()
128
+
129
+ function getTaskSubs(taskName) {
130
+ let taskSubs = subs.get(taskName)
131
+ if (!taskSubs) {
132
+ taskSubs = new Map()
133
+ subs.set(taskName, taskSubs)
134
+ }
135
+ return taskSubs
136
+ }
137
+
138
+ async function loadSubs() {
139
+ if (!ctx.database) return
140
+ try {
141
+ const rows = await ctx.database.get('ll_schedule_subs', {})
142
+ for (const row of rows) {
143
+ getTaskSubs(row.taskName).set(`${row.botSid}:${row.guildId}`, {
144
+ botSid: row.botSid,
145
+ guildId: row.guildId,
146
+ createdAt: row.createdAt,
147
+ })
148
+ }
149
+ if (rows.length) logger.info(`[schedule] 已从数据库加载 ${rows.length} 条群订阅`)
150
+ }
151
+ catch (e) {
152
+ logger.warn('[schedule] 加载群订阅失败:', e)
153
+ }
154
+ }
155
+
156
+ async function persistSub(taskName, entry) {
157
+ if (!ctx.database) return
158
+ try {
159
+ await ctx.database.upsert('ll_schedule_subs', [{ ...entry, taskName }], ['taskName', 'botSid', 'guildId'])
160
+ }
161
+ catch (e) {
162
+ logger.warn('[schedule] 保存群订阅失败:', e)
163
+ }
164
+ }
165
+
166
+ async function removeSub(taskName, botSid, guildId) {
167
+ if (!ctx.database) return
168
+ try {
169
+ await ctx.database.remove('ll_schedule_subs', { taskName, botSid, guildId })
170
+ }
171
+ catch (e) {
172
+ logger.warn('[schedule] 删除群订阅失败:', e)
173
+ }
105
174
  }
106
175
 
107
176
  async function loadKnownGroups() {
@@ -224,15 +293,21 @@ function apply(ctx, config) {
224
293
  if (!bots.length) return
225
294
 
226
295
  const groups = (task.groups && task.groups.length > 0) ? task.groups : null
296
+ const taskSubs = subs.get(name)
227
297
 
228
298
  for (const bot of bots) {
229
- if (groups) {
230
- for (const rawTarget of groups) {
299
+ // 该任务下、该机器人订阅的群(群内"订阅"命令注册的)
300
+ const subTargets = taskSubs
301
+ ? [...taskSubs.values()].filter(s => s.botSid === bot.sid).map(s => s.guildId)
302
+ : []
303
+
304
+ if (groups || subTargets.length) {
305
+ for (const rawTarget of groups || []) {
231
306
  const target = String(rawTarget).trim()
232
307
  if (!target) continue
233
308
  // 官机:群 ID 是 openid;数字群号在官机上无法直接使用
234
309
  if (bot.platform === 'qq' && /^\d+$/.test(target)) {
235
- logger.warn(`[schedule] 任务 "${name}":官方QQ机器人不能用数字群号 "${target}" 发送,请用 schedule.groups 查看 openid 填入`)
310
+ logger.warn(`[schedule] 任务 "${name}":官方QQ机器人不能用数字群号 "${target}" 发送,请用群内"订阅"命令或 schedule.groups 查看 openid`)
236
311
  continue
237
312
  }
238
313
  try {
@@ -243,9 +318,18 @@ function apply(ctx, config) {
243
318
  logger.error(`[schedule] 发送到 ${bot.sid}:${target} 失败: ${e.message}`)
244
319
  }
245
320
  }
321
+ for (const target of subTargets) {
322
+ try {
323
+ await bot.sendMessage(target, parts)
324
+ if (config.verbose) logger.info(`[schedule] 已发送任务 "${name}" 到订阅群 ${bot.sid}:${target}`)
325
+ }
326
+ catch (e) {
327
+ logger.error(`[schedule] 发送到订阅群 ${bot.sid}:${target} 失败: ${e.message}`)
328
+ }
329
+ }
246
330
  }
247
331
  else if (config.sendAllWhenEmpty) {
248
- // 未指定群 → 发送给该 bot 所有已知群
332
+ // 未指定群也没有订阅 → 发送给该 bot 所有已知群
249
333
  const targets = [...knownGroups.values()].filter(g => g.botSid === bot.sid)
250
334
  if (!targets.length) {
251
335
  logger.warn(`[schedule] 任务 "${name}" 未指定群且机器人 ${bot.sid} 暂无已知群(先让机器人收过该群消息,或用 schedule.groups 查看)`)
@@ -285,25 +369,33 @@ function apply(ctx, config) {
285
369
  return '📋 机器人已知群:\n' + lines.join('\n') + '\n(官机的群 ID 是 openid,填入任务 groups 即可)'
286
370
  }
287
371
 
372
+ function formatTaskList() {
373
+ const tasks = config.tasks || {}
374
+ const keys = Object.keys(tasks)
375
+ if (!keys.length) return '暂无定时任务\n请在插件设置页的 tasks 表格中添加'
376
+ return keys.map(k => {
377
+ const t = tasks[k]
378
+ const s = t.enabled === false ? '⛔' : '✅'
379
+ const content = [t.text && '文字', t.image && '图片'].filter(Boolean).join('+') || '(空)'
380
+ const staticGroups = (t.groups || []).join(',')
381
+ const subCount = (subs.get(k) || new Map()).size
382
+ const target = staticGroups ? staticGroups : (subCount ? `订阅${subCount}群` : '所有已知群')
383
+ return `${s} ${k} @ ${t.time} → ${content} → 群:${target}${subCount && staticGroups ? ` +订阅${subCount}群` : ''}`
384
+ }).join('\n')
385
+ }
386
+
288
387
  ctx.command('schedule', '定时发送管理')
289
388
  .action(() => {
290
389
  logger.info('[schedule] 命令被调用: schedule')
291
- return '⚙️ 管理:插件设置页 → ll-schedule → tasks 表格\n' +
292
- '命令:schedule.list 查看任务 | schedule.groups 查看已知群 | schedule.test <任务名> 立即测试发送'
390
+ return '⚙️ 管理:插件设置页 → ll-schedule → tasks 表格设置时间和内容\n' +
391
+ `群内发"${config.subscribeKeyword} 任务名"即可让本群订阅推送\n` +
392
+ `命令:${config.subscribeKeyword} | ${config.unsubscribeKeyword} | 订阅列表 | schedule.list | schedule.groups | schedule.test <任务名>`
293
393
  })
294
394
 
295
395
  ctx.command('schedule.list', '查看定时任务')
296
396
  .action(() => {
297
397
  logger.info('[schedule] 命令被调用: schedule.list')
298
- const tasks = config.tasks || {}
299
- const keys = Object.keys(tasks)
300
- if (!keys.length) return '暂无定时任务\n请在插件设置页的 tasks 表格中添加'
301
- return keys.map(k => {
302
- const t = tasks[k]
303
- const s = t.enabled === false ? '⛔' : '✅'
304
- const content = [t.text && '文字', t.image && '图片'].filter(Boolean).join('+') || '(空)'
305
- return `${s} ${k} @ ${t.time} → ${content} → 群:${(t.groups || []).join(',') || '所有已知群'}`
306
- }).join('\n')
398
+ return formatTaskList()
307
399
  })
308
400
 
309
401
  ctx.command('schedule.groups', '查看机器人已知的群(官机显示 openid)')
@@ -318,7 +410,7 @@ function apply(ctx, config) {
318
410
  const tasks = config.tasks || {}
319
411
  const task = tasks[name]
320
412
  if (!task) return `未找到任务 "${name}",可用 schedule.list 查看任务名`
321
- // 测试时:任务没配群就发到当前群
413
+ // 测试时:任务没配群也没订阅就发到当前群
322
414
  const testTask = { ...task }
323
415
  if (!testTask.groups || !testTask.groups.length) {
324
416
  testTask.groups = session.guildId ? [String(session.guildId)] : []
@@ -327,25 +419,119 @@ function apply(ctx, config) {
327
419
  return `✅ 已发送任务 "${name}"`
328
420
  })
329
421
 
422
+ // ===== 群内订阅命令 =====
423
+ // 解析订阅命令的任务名:明确指定 → 用它;不填且只有一个启用任务 → 用它;否则返回提示
424
+ function resolveSubscribeTaskName(taskName) {
425
+ const tasks = config.tasks || {}
426
+ if (taskName) {
427
+ if (!tasks[taskName]) return { name: null, hint: `未找到任务 "${taskName}",可用"订阅列表"或 lls.list 查看任务名` }
428
+ return { name: taskName, hint: null }
429
+ }
430
+ const names = Object.keys(tasks).filter(k => tasks[k] && tasks[k].enabled !== false)
431
+ if (names.length === 1) return { name: names[0], hint: null }
432
+ if (names.length === 0) return { name: null, hint: '暂无启用的任务,请先在插件设置页的 tasks 表格中添加' }
433
+ return { name: null, hint: `有多个任务,请指定任务名:${names.join(' / ')}` }
434
+ }
435
+
436
+ async function subscribeCurrentGroup(session, taskName) {
437
+ logger.info(`[schedule] 命令被调用: 订阅 taskName=${taskName || '(自动)'}`)
438
+ if (!session.guildId || !session.bot) return '❌ 订阅只能在群聊里使用'
439
+ const { name, hint } = resolveSubscribeTaskName(taskName)
440
+ if (!name) return hint
441
+ const task = config.tasks[name]
442
+ const key = `${session.bot.sid}:${session.guildId}`
443
+ const taskSubs = getTaskSubs(name)
444
+ if (taskSubs.has(key)) return `ℹ️ 本群已在任务 "${name}" 的推送列表里`
445
+ const entry = { botSid: session.bot.sid, guildId: session.guildId, createdAt: Date.now() }
446
+ taskSubs.set(key, entry)
447
+ persistSub(name, entry)
448
+ logger.info(`[schedule] 群 ${session.guildId} 已订阅任务 "${name}"`)
449
+ return `✅ 本群已加入任务 "${name}" 的推送列表\n时间:${task.time}\n内容:${task.text || '(图片)'}`
450
+ }
451
+
452
+ async function unsubscribeCurrentGroup(session, taskName) {
453
+ logger.info(`[schedule] 命令被调用: 取消订阅 taskName=${taskName || '(自动)'}`)
454
+ if (!session.guildId || !session.bot) return '❌ 取消订阅只能在群聊里使用'
455
+ const { name, hint } = resolveSubscribeTaskName(taskName)
456
+ if (!name) return hint
457
+ const taskSubs = subs.get(name)
458
+ const key = `${session.bot.sid}:${session.guildId}`
459
+ if (!taskSubs || !taskSubs.has(key)) return `ℹ️ 本群不在任务 "${name}" 的推送列表里`
460
+ taskSubs.delete(key)
461
+ removeSub(name, session.bot.sid, session.guildId)
462
+ logger.info(`[schedule] 群 ${session.guildId} 已取消订阅任务 "${name}"`)
463
+ return `✅ 本群已取消订阅任务 "${name}"`
464
+ }
465
+
466
+ ctx.command(config.subscribeKeyword + ' [taskName:text]', '让当前群订阅指定任务的推送')
467
+ .authority(config.subscribeAuthority)
468
+ .action(async ({ session }, taskName) => {
469
+ return await subscribeCurrentGroup(session, taskName)
470
+ })
471
+
472
+ ctx.command('lls.sub [taskName:text]', '订阅推送(备用入口)')
473
+ .authority(config.subscribeAuthority)
474
+ .action(async ({ session }, taskName) => {
475
+ return await subscribeCurrentGroup(session, taskName)
476
+ })
477
+
478
+ ctx.command(config.unsubscribeKeyword + ' [taskName:text]', '让当前群取消订阅指定任务的推送')
479
+ .authority(config.subscribeAuthority)
480
+ .action(async ({ session }, taskName) => {
481
+ return await unsubscribeCurrentGroup(session, taskName)
482
+ })
483
+
484
+ ctx.command('lls.unsub [taskName:text]', '取消订阅(备用入口)')
485
+ .authority(config.subscribeAuthority)
486
+ .action(async ({ session }, taskName) => {
487
+ return await unsubscribeCurrentGroup(session, taskName)
488
+ })
489
+
490
+ ctx.command('订阅列表', '查看所有任务的订阅群')
491
+ .action(async () => {
492
+ logger.info('[schedule] 命令被调用: 订阅列表')
493
+ if (!subs.size) return '暂无任何群订阅。群内发"' + config.subscribeKeyword + ' 任务名"即可订阅'
494
+ const lines = []
495
+ for (const [taskName, taskSubs] of subs) {
496
+ const entries = [...taskSubs.values()]
497
+ lines.push(`【${taskName}】${entries.length} 个群`)
498
+ for (const s of entries) {
499
+ const bot = ctx.bots.find(b => b.sid === s.botSid)
500
+ const name = bot ? await resolveGroupName(bot, s.guildId) : ''
501
+ lines.push(` · ${name || '未知群名'} (${s.guildId})`)
502
+ }
503
+ }
504
+ return '📋 订阅列表:\n' + lines.join('\n')
505
+ })
506
+
507
+ ctx.command('lls.sublist', '查看订阅列表(备用入口)')
508
+ .action(async () => {
509
+ logger.info('[schedule] 命令被调用: lls.sublist')
510
+ if (!subs.size) return '暂无任何群订阅。群内发"' + config.subscribeKeyword + ' 任务名"即可订阅'
511
+ const lines = []
512
+ for (const [taskName, taskSubs] of subs) {
513
+ const entries = [...taskSubs.values()]
514
+ lines.push(`【${taskName}】${entries.length} 个群`)
515
+ for (const s of entries) {
516
+ const bot = ctx.bots.find(b => b.sid === s.botSid)
517
+ const name = bot ? await resolveGroupName(bot, s.guildId) : ''
518
+ lines.push(` · ${name || '未知群名'} (${s.guildId})`)
519
+ }
520
+ }
521
+ return '📋 订阅列表:\n' + lines.join('\n')
522
+ })
523
+
330
524
  // 备用命令入口(避免与其他插件的 schedule 命令冲突)
331
525
  ctx.command('lls', 'll-schedule 管理(备用入口)')
332
526
  .action(() => {
333
527
  logger.info('[schedule] 命令被调用: lls')
334
- return '⚙️ 备用入口:lls.list / lls.groups / lls.test <任务名>'
528
+ return `⚙️ 备用入口:lls.list / lls.groups / lls.test <任务名> / lls.sub / lls.unsub / lls.sublist`
335
529
  })
336
530
 
337
531
  ctx.command('lls.list', '查看定时任务(备用入口)')
338
532
  .action(() => {
339
533
  logger.info('[schedule] 命令被调用: lls.list')
340
- const tasks = config.tasks || {}
341
- const keys = Object.keys(tasks)
342
- if (!keys.length) return '暂无定时任务\n请在插件设置页的 tasks 表格中添加'
343
- return keys.map(k => {
344
- const t = tasks[k]
345
- const s = t.enabled === false ? '⛔' : '✅'
346
- const content = [t.text && '文字', t.image && '图片'].filter(Boolean).join('+') || '(空)'
347
- return `${s} ${k} @ ${t.time} → ${content} → 群:${(t.groups || []).join(',') || '所有已知群'}`
348
- }).join('\n')
534
+ return formatTaskList()
349
535
  })
350
536
 
351
537
  ctx.command('lls.groups', '查看机器人已知的群(备用入口,官机显示 openid)')
@@ -368,10 +554,11 @@ function apply(ctx, config) {
368
554
  return `✅ 已发送任务 "${name}"`
369
555
  })
370
556
 
371
- logger.info('[schedule] 命令已注册: schedule / schedule.list / schedule.groups / schedule.test / lls / lls.list / lls.groups / lls.test')
557
+ logger.info('[schedule] 命令已注册: schedule / schedule.list / schedule.groups / schedule.test / ' +
558
+ `"${config.subscribeKeyword}" / "${config.unsubscribeKeyword}" / 订阅列表 / lls.*`)
372
559
 
373
- // 启动时:加载已知群 + 立即检查一次
374
- loadKnownGroups().then(() => checkAndSend())
560
+ // 启动时:加载已知群 + 订阅 + 立即检查一次
561
+ Promise.all([loadKnownGroups(), loadSubs()]).then(() => checkAndSend())
375
562
  }
376
563
 
377
564
  exports.apply = apply
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "koishi-plugin-ll-schedule",
3
- "version": "1.0.3",
3
+ "version": "1.0.4",
4
4
  "description": "Scheduled message sender with text/image support, works with official QQ bot",
5
5
  "main": "index.js",
6
6
  "files": [