koishi-plugin-ll-schedule 1.1.0 → 1.3.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.
Files changed (2) hide show
  1. package/index.js +113 -205
  2. package/package.json +3 -1
package/index.js CHANGED
@@ -1,88 +1,72 @@
1
1
  'use strict'
2
2
 
3
3
  // koishi-plugin-ll-schedule
4
- // 定时发送插件:设置时间自动向指定群发送文字
4
+ // 定时提醒插件:到点自动向订阅群发送"提醒"(文字/图片/QQ官方Markdown卡片)
5
5
  // v1.0.6:一个任务支持多个时间(逗号分隔),群内"订阅"关键词自动注册推送群
6
- // v1.1.0:新增"排班表 slots"(按星期设置不同时间/内容)+ QQ官方Markdown卡片 + 模板变量
6
+ // v1.1.0:新增排班表 slots + QQ 官方 Markdown 卡片 + 模板变量
7
+ // v1.2.0:设置页重做 —— 任务 = "时间段列表,每行 = 星期 + 时间 + 内容",删掉零散子字段,更直观
8
+ // (向后兼容:旧 time/text、旧 slots(days) 配置仍能识别)
7
9
 
8
10
  const { Schema, h } = require('koishi')
9
11
 
10
12
  exports.name = 'll-schedule'
11
13
 
12
- exports.usage = `定时发送插件:设置时间自动向指定群发送文字/卡片。
14
+ exports.usage = `定时提醒插件:到时间自动向订阅了该任务的群发送提醒。每个任务 = 一张"时间段列表",点 + 添加一行,每行填 星期 / 时间 / 内容。
13
15
 
14
- ## 快速上手(推荐用法)
16
+ ## 快速上手
15
17
 
16
- 1. 在插件设置页的 \`tasks\` 表格中添加任务:**键=任务名**,设置 \`time\`(发送时间,**可多个**)和 \`text\`(推送内容)
17
- 2. 在任意群里 @机器人 发:\`订阅 任务名\` → 这个群就加入了该任务的推送列表(自动记录,重启不丢)
18
- 3. 到点后插件自动把内容推送到所有订阅了该任务的群
18
+ 1. 在插件设置页的 \`tasks\` 里**添加任务**(键=任务名),在任务里 **添加时间段**:每行填 星期(留空=每天)/ 时间 / 内容
19
+ 2. 在任意群 @机器人 发:\`订阅 任务名\` → 该群加入此任务的订阅列表
20
+ 3. 到时间,插件自动把该时间段的内容发给所有订阅群
19
21
 
20
- ## 排班表 slots(按星期设置不同的时间/内容)
22
+ ## 每个时间段三格子
21
23
 
22
- 每个任务除了简单的 \`time\` + \`text\`,还可以填一张**排班表 \`slots\`**。每行 = 星期 + 时间 + 内容,互不影响:
24
+ - **星期**:留空 = 每天;写 \`周一\` \`周六\`...、\`工作日\`(周一到周五)、\`周末\`(周六日)、或 \`1,3,5\` 数字
25
+ - **时间**:\`HH:MM\`,可填多个用逗号分隔,如 \`12:20, 18:20, 21:20\`(这些时间点都发同一段内容)
26
+ - **内容**:到点发送的话;支持 Markdown 语法和模板变量 \`{date}\` \`{time}\` \`{week}\` \`{task}\`
23
27
 
24
- \`\`\`yaml
25
- tasks:
26
- 每日推送:
27
- slots:
28
- - time: "12:20, 18:20, 21:20" # 时间(可多个,逗号分隔)
29
- days: "" # 星期:留空=每天;1,2,3=周一~周三;工作日;周末;一,三,五
30
- text: "这段内容在设定的星期和时间发送"
31
- - time: "20:55" # 同一任务可加任意多行
32
- days: "工作日"
33
- text: "工作日晚上说的话"
34
- - time: "14:55, 20:55"
35
- days: "周末"
36
- text: "周末说的话(周六日各发两次)"
37
- \`\`\`
38
-
39
- - \`days\` 填写规则:留空 = **每天**;数字 \`1\`~\`7\`(1=周一,逗号分隔如 \`1,3,5\`);中文 \`一,三,五\`;快捷词 \`工作日\`(周一~周五)/ \`周末\`(周六日);也支持范围 \`1-5\`
40
- - **任务一旦填了 slots,就完全以 slots 为准**,旧的 \`time\`/\`text\` 自动忽略(不会重复发);不填 slots 则行为和旧版完全一样
41
- - 以后要加时段/改内容,直接增删改表格行即可,群订阅不用重新弄
42
-
43
- ## Markdown 卡片(官方QQ机器人)
44
-
45
- 任务或排班行勾选 \`markdown\` 后,推送会用 **QQ官方 Markdown 卡片**发送(仅官机生效;失败自动回退纯文本,其他平台始终纯文本)。支持标题、加粗、引用、链接、图片等:
28
+ ## 示例:每天定时提醒
46
29
 
47
- \`\`\`markdown
48
- # 🎉 每日公告
30
+ 任务「轻功」,添加 1 行: 星期=空(每天) 时间=\`12:20, 18:20, 21:20\` 内容=\`⏰ 轻功时间到了!\`
31
+ → 每天这三个点都发这句。
49
32
 
50
- > 今天是 **{date}**({week}),现在是 {time}
33
+ ## 示例:按天/按时段发不同内容(如世界BOSS)
51
34
 
52
- [官网点此访问](https://example.com)
53
- ![配图](https://example.com/pic.jpg)
35
+ 任务「世界BOSS」,添加多行:
54
36
  \`\`\`
37
+ 周一 21:00 世界BOSS刷新!仙幻岛 → 白青山脉
38
+ 周二 21:00 世界BOSS刷新!仙幻岛 → 大漠
39
+ 周三 21:00 世界BOSS刷新!仙幻岛 → 御龙林
40
+ 周四 21:00 世界BOSS刷新!仙幻岛 → 水月平原
41
+ 周五 21:00 世界BOSS刷新!白青山脉 → 大漠
42
+ 周六 15:00 世界BOSS刷新!仙幻岛 → 白青山脉
43
+ 周六 21:00 世界BOSS刷新!仙幻岛 → 御龙林
44
+ 周日 15:00 世界BOSS刷新!仙幻岛 → 大漠
45
+ 周日 21:00 世界BOSS刷新!仙幻岛 → 水月平原
46
+ \`\`\`
47
+ 每行对应一个时间段,内容各不相同。
48
+
49
+ ## Markdown 卡片(官方QQ机器人)
55
50
 
56
- \`text\` 中可用模板变量(发送时自动替换):
57
- - \`{date}\`:今天日期(如 2026-08-23)
58
- - \`{time}\`:当前时间(如 12:20)
59
- - \`{week}\`:今天星期(如 星期三)
60
- - \`{task}\`:任务名
51
+ 任务勾选 \`markdown\` 后,会用 QQ 官方 Markdown 卡片发送(仅官机生效,失败自动回退纯文本;其他平台始终纯文本)。内容里可用标题/加粗/引用/链接/图片(图片直接写在内容里,如 \`![配图](https://...)\`),模板变量自动替换。
61
52
 
62
- ## 任务配置
53
+ ## 其他配置
63
54
 
64
- - \`time\`:发送时间(24小时制 HH:MM),**可填多个,用逗号分隔**,如 \`09:00, 21:30\`(一条内容会在每个时间点各发一次)
65
- - \`text\`:推送内容(文字,可留空;支持 Markdown 语法和模板变量)
66
- - \`image\`:图片 URL(可选,随文本一起发送;markdown 模式自动嵌入为图片)
67
- - \`markdown\`:是否用 QQ官方 Markdown 卡片发送(默认关闭)
68
- - \`slots\`:排班表(可选,见上方说明;填了就以排班表为准)
69
- - \`groups\`:手动指定的目标群列表(**推荐留空**,用群内"订阅"命令添加更省事;留空且无订阅 = 发给所有已知群)
70
- - \`enabled\`:是否启用
55
+ - \`groups\`:手动指定目标群(推荐留空,用群内"订阅"命令自动添加)
56
+ - \`enabled\`:是否启用该任务
71
57
 
72
58
  ## 群内命令
73
59
 
74
- - \`订阅 [任务名]\`:当前群订阅指定任务的推送(不填任务名时,只有一个启用任务则自动选它)
60
+ - \`订阅 [任务名]\`:当前群订阅指定任务
75
61
  - \`取消订阅 [任务名]\`:当前群取消订阅
76
62
  - \`订阅列表\`:查看所有任务的订阅群
77
- - \`schedule.list\`:查看任务列表(含排班表详情)
63
+ - \`schedule.list\`:查看任务列表(含每段时间段)
78
64
  - \`schedule.test <任务名>\`:立即测试发送
79
- - \`lls.*\`:以上命令的备用入口(lls.sub / lls.unsub / lls.sublist / lls.list / lls.test)
80
-
81
- 订阅/取消订阅所需权限等级在设置页 \`subscribeAuthority\` 调整(默认 1=所有人都能订阅)。\`subscribeAdminBypass\`(默认开启):群主/群管理可无视权限等级直接订阅——插件从群消息自带的成员角色判断(\`owner\`/\`admin\`),**不需要申请接口权限**。想验证自己角色可发 \`我的ID\`。
65
+ - \`lls.*\`:以上命令的备用入口
82
66
 
83
67
  ## 官机(官方QQ机器人)说明
84
68
 
85
- 官方平台没有"群号",只有 openid——用群内"订阅"命令即可,插件会自动记录当前群的 openid,无需手动填写。手动填 \`groups\` 时可用 \`schedule.groups\` 查看已知群的 openid。官机主动发群消息有平台频率限制,且机器人需在群里。
69
+ 官方平台没有群号只有 openid——用群内"订阅"命令即可,插件自动记录当前群 openid。手动填 \`groups\` 时用 \`schedule.groups\` 查看已知群 openid。官机主动发群消息有平台频率限制,且机器人需在群里。
86
70
  `
87
71
 
88
72
  // ===== 纯函数(可单测) =====
@@ -106,7 +90,7 @@ function todayDay(date) {
106
90
  const WEEK_CN = ['', '一', '二', '三', '四', '五', '六', '日']
107
91
 
108
92
  // 解析星期配置:返回 Set(1..7) 或 null(null = 每天)
109
- // 支持:留空/每天/每日、1-7 数字逗号分隔、范围 1-5、中文 一,三,五 / 星期X / 周X、工作日、周末
93
+ // 支持:留空/每天/每日、1-7 数字逗号分隔、范围 1-5、中文 一,三,五 / 星期X / 周X / 每周X、工作日、周末
110
94
  function parseDays(daysStr) {
111
95
  if (daysStr === undefined || daysStr === null) return null
112
96
  const s = String(daysStr).trim()
@@ -125,7 +109,7 @@ function parseDays(daysStr) {
125
109
  }
126
110
  continue
127
111
  }
128
- const cn = p.match(/^(?:星期|周|礼拜)?([一二三四五六日天])$/)
112
+ const cn = p.match(/^(?:每)?(?:星期|周|礼拜)?([一二三四五六日天])$/)
129
113
  if (cn) {
130
114
  set.add(cnMap[cn[1]])
131
115
  continue
@@ -144,35 +128,41 @@ function dayMatches(daysSet, day) {
144
128
  return !daysSet || daysSet.has(day)
145
129
  }
146
130
 
131
+ // 把任务的时间段解析成统一行列表:[{ idx, wd(星期), time, text }]
132
+ // 兼容三种写法:新 slots 数组(优先)、旧 time/text(每天)、旧 slots(days)
133
+ function parseScheduleRows(task) {
134
+ if (!task) return []
135
+ if (Array.isArray(task.slots) && task.slots.length) {
136
+ return task.slots
137
+ .map((s, i) => ({
138
+ idx: i,
139
+ wd: s.wd !== undefined ? s.wd : s.days, // 兼容旧 slots 的 days 字段
140
+ time: s.time,
141
+ text: s.text || '',
142
+ }))
143
+ .filter(r => r.time)
144
+ }
145
+ if (task.time) {
146
+ return [{ idx: 0, wd: '', time: task.time, text: task.text || '' }]
147
+ }
148
+ return []
149
+ }
150
+
147
151
  // 计算"现在这一刻该发什么":返回 [{ name, key, text, markdown, image }]
148
- // date 为 Date 对象、hm 为 "HH:MM" 字符串;纯函数便于测试
149
152
  function computeFires(tasks, date, hm) {
150
153
  const day = todayDay(date)
151
154
  const fires = []
152
155
  for (const [name, task] of Object.entries(tasks || {})) {
153
156
  if (!task || task.enabled === false) continue
154
- const slots = Array.isArray(task.slots) && task.slots.length ? task.slots : null
155
- if (slots) {
156
- slots.forEach((slot, idx) => {
157
- if (!slot || !dayMatches(parseDays(slot.days), day)) return
158
- const timeList = parseTimes(slot.time)
159
- if (!timeList.includes(hm)) return
160
- fires.push({
161
- name,
162
- key: `${name}@${hm}@${idx}`,
163
- text: slot.text || '',
164
- markdown: slot.markdown !== undefined ? !!slot.markdown : !!task.markdown,
165
- image: task.image,
166
- })
167
- })
168
- }
169
- else {
170
- const timeList = parseTimes(task.time)
157
+ const rows = parseScheduleRows(task)
158
+ for (const row of rows) {
159
+ if (!dayMatches(parseDays(row.wd), day)) continue
160
+ const timeList = parseTimes(row.time)
171
161
  if (!timeList.includes(hm)) continue
172
162
  fires.push({
173
163
  name,
174
- key: `${name}@${hm}`,
175
- text: task.text || '',
164
+ key: `${name}@${hm}@${row.idx}`,
165
+ text: row.text || '',
176
166
  markdown: !!task.markdown,
177
167
  image: task.image,
178
168
  })
@@ -212,58 +202,41 @@ function buildTextFromMd(md) {
212
202
  .trim()
213
203
  }
214
204
 
215
- exports._test = { parseTimes, todayDay, parseDays, dayMatches, computeFires, renderText, buildMd, buildTextFromMd }
205
+ exports._test = { parseTimes, todayDay, parseDays, dayMatches, parseScheduleRows, computeFires, renderText, buildMd, buildTextFromMd }
216
206
 
217
207
  // ===== 配置 Schema =====
218
208
 
219
- const TimeSlot = Schema.object({
209
+ const SlotItem = Schema.object({
210
+ wd: Schema.string()
211
+ .default('')
212
+ .description('星期:留空=每天;周一/周二...;工作日(周一到五)/周末(周六日);或 1,3,5 / 1-5'),
220
213
  time: Schema.string()
221
214
  .required()
222
- .description('发送时间(24小时制 HH:MM),可多个用逗号分隔,如 09:00, 21:30'),
223
- days: Schema.string()
224
- .default('')
225
- .description('星期几生效:留空=每天;1-7(1=周一)逗号分隔如 1,3,5;快捷词"工作日"(周一~五)/"周末"(周六日);中文"一,三,五"'),
215
+ .description('时间(24小时制 HH:MM),可多个用逗号分隔,如 12:20, 18:20, 21:20'),
226
216
  text: Schema.string()
227
217
  .default('')
228
- .description('该时段发送的内容(文字,可留空;支持 Markdown 语法和模板变量 {date} {time} {week} {task})'),
229
- markdown: Schema.boolean()
230
- .description('用 QQ官方 Markdown 卡片发送(留空则跟随任务级 markdown 设置)'),
218
+ .description('内容:到点发送的话,支持 Markdown 和模板变量 {date} {time} {week} {task}'),
231
219
  })
232
220
 
233
221
  const TaskItem = Schema.object({
234
- time: Schema.string()
235
- .required()
236
- .description('发送时间(24小时制 HH:MM),可填多个用逗号分隔,如 09:00, 21:30;填了下方 slots 排班表时此项自动忽略'),
222
+ slots: Schema.array(SlotItem)
223
+ .default([])
224
+ .description('📅 时间段列表:每行 = 星期 + 时间 + 内容,点 + 可添加多行;每行内容各自独立'),
237
225
  groups: Schema.array(Schema.string())
238
226
  .default([])
239
227
  .description('手动指定的目标群 ID(可留空;推荐用群内"订阅"命令自动添加,无需手动填 openid)'),
240
- text: Schema.string()
241
- .default('')
242
- .description('推送内容(文字,可留空;支持 Markdown 语法和模板变量 {date} {time} {week} {task})'),
243
- image: Schema.string()
244
- .default('')
245
- .description('图片 URL(可选):随文本一起发送(markdown 模式自动嵌入为图片)'),
246
228
  markdown: Schema.boolean()
247
229
  .default(false)
248
230
  .description('用 QQ官方 Markdown 卡片发送(仅官方QQ机器人生效,失败自动回退纯文本;其他平台始终纯文本)'),
249
231
  enabled: Schema.boolean()
250
232
  .default(true)
251
233
  .description('是否启用'),
252
- slots: Schema.array(TimeSlot)
253
- .default([])
254
- .description('📅 排班表(可选):每行=星期+时间+内容,按星期设置不同时段/内容;一旦填写就完全以排班表为准,上方 time/text 自动忽略'),
255
234
  })
256
235
 
257
236
  const Config = Schema.object({
258
237
  tasks: Schema.dict(TaskItem)
259
238
  .default({})
260
- .description('📋 定时任务表格(键=任务名,设置时间和推送内容)'),
261
- subscribeKeyword: Schema.string()
262
- .default('订阅')
263
- .description('群内订阅推送的关键词(如"订阅 任务名",只输关键词则订阅唯一启用的任务)'),
264
- unsubscribeKeyword: Schema.string()
265
- .default('取消订阅')
266
- .description('群内取消订阅的关键词(如"取消订阅 任务名")'),
239
+ .description('📋 推送任务表格(键=任务名;每个任务 = 时间段列表:星期/时间/内容)'),
267
240
  subscribeAuthority: Schema.number()
268
241
  .min(1).max(4).default(1)
269
242
  .description('执行订阅命令所需的最低权限等级(Koishi 用户权限等级,与QQ群主/管理员身份无关;默认 1=所有人都能订阅。如需限制,先在控制台"用户管理"给指定账号提权,再调高此值)'),
@@ -281,19 +254,17 @@ const Config = Schema.object({
281
254
  exports.Config = Config
282
255
 
283
256
  // 需要数据库:持久化学习到的群(重启不丢)
284
- // 必须声明 required:确保插件在数据库就绪后才启动,模型表能正常注册建表
285
257
  exports.inject = { required: ['database'] }
286
258
 
287
259
  function apply(ctx, config) {
288
260
  const logger = ctx.logger('ll-schedule')
289
261
 
290
- // 记录当天已发送的任务,防止同一分钟重复发
262
+ // 记录当天已发送的,防止同一分钟重复发
291
263
  let firedToday = {}
292
264
 
293
265
  // ===== 已知群(从收到的消息中学习) =====
294
- // key: `${botSid}:${guildId}` -> { botSid, guildId, platform, learnedAt }
295
266
  const knownGroups = new Map()
296
- const groupNameCache = new Map() // key: botSid:guildId -> { v, t }
267
+ const groupNameCache = new Map()
297
268
  const TTL = 60 * 60 * 1000
298
269
 
299
270
  function cacheGet(map, key) {
@@ -304,7 +275,7 @@ function apply(ctx, config) {
304
275
  map.set(key, { v: value, t: Date.now() })
305
276
  }
306
277
 
307
- // 数据库模型(可选)
278
+ // 数据库模型
308
279
  if (ctx.database) {
309
280
  ctx.model.extend('ll_schedule_groups', {
310
281
  botSid: 'string',
@@ -312,7 +283,6 @@ function apply(ctx, config) {
312
283
  platform: 'string',
313
284
  learnedAt: 'unsigned',
314
285
  }, { primary: ['botSid', 'guildId'] })
315
- // 群订阅表:taskName + botSid + guildId(群内"订阅"命令注册的推送目标)
316
286
  ctx.model.extend('ll_schedule_subs', {
317
287
  taskName: 'string',
318
288
  botSid: 'string',
@@ -321,8 +291,7 @@ function apply(ctx, config) {
321
291
  }, { primary: ['taskName', 'botSid', 'guildId'] })
322
292
  }
323
293
 
324
- // ===== 群订阅(群内发关键词注册推送目标) =====
325
- // subs: Map<taskName, Map<`${botSid}:${guildId}`, { botSid, guildId, createdAt }>>
294
+ // ===== 群订阅 =====
326
295
  const subs = new Map()
327
296
 
328
297
  function getTaskSubs(taskName) {
@@ -436,7 +405,6 @@ function apply(ctx, config) {
436
405
  return ''
437
406
  }
438
407
 
439
- // 从消息事件学习群
440
408
  ctx.on('message', (session) => {
441
409
  learnGroup(session)
442
410
  })
@@ -452,7 +420,6 @@ function apply(ctx, config) {
452
420
  }
453
421
 
454
422
  // ===== 发送 =====
455
- // 官机 markdown 发送时忽略的已知错误码(主动消息受限等)
456
423
  const KNOWN_IGNORE_CODES = new Set([11293, 40034101, 40034105, 304101, 304102])
457
424
 
458
425
  function errCode(e) {
@@ -466,7 +433,6 @@ function apply(ctx, config) {
466
433
  return typeof code === 'number' && KNOWN_IGNORE_CODES.has(code)
467
434
  }
468
435
 
469
- // 向一个目标发送一段内容;markdown 模式仅官机生效,失败自动回退纯文本
470
436
  async function sendOne(bot, target, text, { markdown, image }) {
471
437
  if (markdown && bot.platform === 'qq' && bot.internal && typeof bot.internal.sendMessage === 'function') {
472
438
  const md = buildMd(text, image)
@@ -515,7 +481,6 @@ function apply(ctx, config) {
515
481
  const taskSubs = subs.get(name)
516
482
 
517
483
  for (const bot of bots) {
518
- // 该任务下、该机器人订阅的群(群内"订阅"命令注册的)
519
484
  const subTargets = taskSubs
520
485
  ? [...taskSubs.values()].filter(s => s.botSid === bot.sid).map(s => s.guildId)
521
486
  : []
@@ -524,7 +489,6 @@ function apply(ctx, config) {
524
489
  for (const rawTarget of groups || []) {
525
490
  const target = String(rawTarget).trim()
526
491
  if (!target) continue
527
- // 官机:群 ID 是 openid;数字群号在官机上无法直接使用
528
492
  if (bot.platform === 'qq' && /^\d+$/.test(target)) {
529
493
  logger.warn(`[schedule] 任务 "${name}":官方QQ机器人不能用数字群号 "${target}" 发送,请用群内"订阅"命令或 schedule.groups 查看 openid`)
530
494
  continue
@@ -548,7 +512,6 @@ function apply(ctx, config) {
548
512
  }
549
513
  }
550
514
  else if (config.sendAllWhenEmpty) {
551
- // 未指定群也没有订阅 → 发送给该 bot 所有已知群
552
515
  const targets = [...knownGroups.values()].filter(g => g.botSid === bot.sid)
553
516
  if (!targets.length) {
554
517
  logger.warn(`[schedule] 任务 "${name}" 未指定群且机器人 ${bot.sid} 暂无已知群(先让机器人收过该群消息,或用 schedule.groups 查看)`)
@@ -573,7 +536,6 @@ function apply(ctx, config) {
573
536
  async function checkAndSend() {
574
537
  const now = new Date()
575
538
  const today = todayStr(now)
576
- // 跨天重置
577
539
  if (firedToday.date !== today) {
578
540
  firedToday = { date: today }
579
541
  }
@@ -582,7 +544,6 @@ function apply(ctx, config) {
582
544
  const fires = computeFires(config.tasks, now, hm)
583
545
 
584
546
  for (const f of fires) {
585
- // 今天这个时间点已经发过 → 跳过
586
547
  if (firedToday[f.key]) continue
587
548
  firedToday[f.key] = true
588
549
 
@@ -593,7 +554,6 @@ function apply(ctx, config) {
593
554
  }
594
555
  }
595
556
 
596
- // 每 20 秒检查一次(保证同一分钟内能触发)
597
557
  const timer = setInterval(checkAndSend, 20000)
598
558
 
599
559
  ctx.on('dispose', () => clearInterval(timer))
@@ -614,42 +574,29 @@ function apply(ctx, config) {
614
574
  function formatTaskList() {
615
575
  const tasks = config.tasks || {}
616
576
  const keys = Object.keys(tasks)
617
- if (!keys.length) return '暂无定时任务\n请在插件设置页的 tasks 表格中添加'
577
+ if (!keys.length) return '暂无定时任务\n请在插件设置页的 tasks 表格中添加任务'
618
578
  return keys.map(k => {
619
579
  const t = tasks[k]
620
580
  const s = t.enabled === false ? '⛔' : '✅'
621
581
  const staticGroups = (t.groups || []).join(',')
622
582
  const subCount = (subs.get(k) || new Map()).size
623
583
  const target = staticGroups ? staticGroups : (subCount ? `订阅${subCount}群` : '所有已知群')
624
- const slots = Array.isArray(t.slots) && t.slots.length ? t.slots : null
625
-
626
- let timeStr, contentStr
627
- if (slots) {
628
- const lines = slots.map((sl, i) => {
629
- const ds = sl.days ? `[${sl.days}]` : '[每天]'
630
- const ts = parseTimes(sl.time).join(',')
631
- return ` ${i + 1}. ${ds} ${ts} → ${sl.text || '(空)'}${sl.markdown || t.markdown ? ' (md卡片)' : ''}`
632
- })
633
- timeStr = `排班表 ${slots.length} 行`
634
- contentStr = lines.join('\n')
635
- }
636
- else {
637
- const times = parseTimes(t.time)
638
- timeStr = times.length ? times.join(', ') : (t.time || '(无时间)')
639
- contentStr = [t.text && '文字', t.image && '图片'].filter(Boolean).join('+') || '(空)'
640
- if (t.markdown) contentStr += ' (md卡片)'
641
- }
642
-
643
- return `${s} ${k} @ ${timeStr} → ${contentStr} → 群:${target}${subCount && staticGroups ? ` +订阅${subCount}群` : ''}`
584
+ const rows = parseScheduleRows(t)
585
+ const lines = rows.map((r, i) => {
586
+ const wd = r.wd || '每天'
587
+ return ` ${i + 1}. ${wd} ${r.time} → ${r.text || '(空)'}`
588
+ })
589
+ const head = `${s} ${k} → ${rows.length} 个时间段 → 群:${target}${t.markdown ? ' (md卡片)' : ''}`
590
+ return [head, ...lines].join('\n')
644
591
  }).join('\n')
645
592
  }
646
593
 
647
594
  ctx.command('schedule', '定时发送管理')
648
595
  .action(() => {
649
596
  logger.info('[schedule] 命令被调用: schedule')
650
- return '⚙️ 管理:插件设置页 → ll-schedule → tasks 表格设置时间和内容\n' +
651
- `群内发"${config.subscribeKeyword} 任务名"即可让本群订阅推送\n` +
652
- `命令:${config.subscribeKeyword} | ${config.unsubscribeKeyword} | 订阅列表 | schedule.list | schedule.groups | schedule.test <任务名>`
597
+ return '⚙️ 管理:插件设置页 → ll-schedule → tasks 添加任务和时间段(星期/时间/内容)\n' +
598
+ `群内发"订阅 任务名"即可让本群订阅推送\n` +
599
+ `命令:订阅 | 取消订阅 | 订阅列表 | schedule.list | schedule.groups | schedule.test <任务名>`
653
600
  })
654
601
 
655
602
  ctx.command('schedule.list', '查看定时任务')
@@ -670,32 +617,20 @@ function apply(ctx, config) {
670
617
  const tasks = config.tasks || {}
671
618
  const task = tasks[name]
672
619
  if (!task) return `未找到任务 "${name}",可用 schedule.list 查看任务名`
673
- // 测试时:任务没配群也没订阅就发到当前群
674
620
  const testTask = { ...task }
675
621
  if (!testTask.groups || !testTask.groups.length) {
676
622
  testTask.groups = session.guildId ? [String(session.guildId)] : []
677
623
  }
678
- // 有排班表时:发"今天会命中的第一行"的内容,更贴近真实
679
- const slots = Array.isArray(task.slots) && task.slots.length ? task.slots : null
680
- let sendText, sendMarkdown
681
- if (slots) {
682
- const day = todayDay()
683
- const hit = slots.find(sl => dayMatches(parseDays(sl.days), day))
684
- sendText = hit ? (hit.text || '') : (task.text || '')
685
- sendMarkdown = hit ? (hit.markdown !== undefined ? !!hit.markdown : !!task.markdown) : !!task.markdown
686
- }
687
- else {
688
- sendText = task.text || ''
689
- sendMarkdown = !!task.markdown
690
- }
691
- await sendTask(name, testTask, { text: sendText, markdown: sendMarkdown, onlyBotSid: session.bot?.sid })
624
+ // 发"今天会命中的第一行"的内容,更贴近真实
625
+ const rows = parseScheduleRows(task)
626
+ const day = todayDay()
627
+ const hit = rows.find(r => dayMatches(parseDays(r.wd), day))
628
+ const sendText = hit ? (hit.text || '') : (task.text || '')
629
+ await sendTask(name, testTask, { text: sendText, markdown: task.markdown, onlyBotSid: session.bot?.sid })
692
630
  return `✅ 已发送任务 "${name}"`
693
631
  })
694
632
 
695
633
  // ===== 群内订阅命令 =====
696
- // 判断发送者是否群主/群管理:
697
- // 1) 优先用消息事件自带的成员角色(官机/OneBot 群消息都会带,零 API 调用,不受接口权限限制)
698
- // 2) 兜底调 getGuildMember(需要接口权限,拿不到就忽略)
699
634
  async function isGroupAdmin(session) {
700
635
  if (!session.guildId || !session.userId) return false
701
636
  const role = session.author?.roles?.[0]?.id || session.event?.member?.roles?.[0]?.id
@@ -722,7 +657,6 @@ function apply(ctx, config) {
722
657
  (config.subscribeAdminBypass ? ',或为本群群主/群管理' : '')
723
658
  }
724
659
 
725
- // 解析订阅命令的任务名:明确指定 → 用它;不填且只有一个启用任务 → 用它;否则返回提示
726
660
  function resolveSubscribeTaskName(taskName) {
727
661
  const tasks = config.tasks || {}
728
662
  if (taskName) {
@@ -748,16 +682,8 @@ function apply(ctx, config) {
748
682
  taskSubs.set(key, entry)
749
683
  persistSub(name, entry)
750
684
  logger.info(`[schedule] 群 ${session.guildId} 已订阅任务 "${name}"`)
751
- const slots = Array.isArray(task.slots) && task.slots.length ? task.slots : null
752
- let timeDesc
753
- if (slots) {
754
- timeDesc = `排班表 ${slots.length} 个时段(schedule.list 查看详情)`
755
- }
756
- else {
757
- const times = parseTimes(task.time)
758
- timeDesc = times.length ? times.join(', ') : '(未设置)'
759
- }
760
- return `✅ 本群已加入任务 "${name}" 的推送列表\n时间:${timeDesc}\n内容:${task.text || '(空)'}`
685
+ const rows = parseScheduleRows(task)
686
+ return `✅ 本群已加入任务 "${name}" 的推送列表\n时间段:${rows.length} 个(schedule.list 查看详情)`
761
687
  }
762
688
 
763
689
  async function unsubscribeCurrentGroup(session, taskName) {
@@ -774,7 +700,7 @@ function apply(ctx, config) {
774
700
  return `✅ 本群已取消订阅任务 "${name}"`
775
701
  }
776
702
 
777
- ctx.command(config.subscribeKeyword + ' [taskName:text]', '让当前群订阅指定任务的推送')
703
+ ctx.command('订阅 [taskName:text]', '让当前群订阅指定任务的推送')
778
704
  .action(async ({ session }, taskName) => {
779
705
  if (!(await checkSubscribePerm(session))) return permDeniedHint()
780
706
  return await subscribeCurrentGroup(session, taskName)
@@ -786,7 +712,7 @@ function apply(ctx, config) {
786
712
  return await subscribeCurrentGroup(session, taskName)
787
713
  })
788
714
 
789
- ctx.command(config.unsubscribeKeyword + ' [taskName:text]', '让当前群取消订阅指定任务的推送')
715
+ ctx.command('取消订阅 [taskName:text]', '让当前群取消订阅指定任务的推送')
790
716
  .action(async ({ session }, taskName) => {
791
717
  if (!(await checkSubscribePerm(session))) return permDeniedHint()
792
718
  return await unsubscribeCurrentGroup(session, taskName)
@@ -801,7 +727,7 @@ function apply(ctx, config) {
801
727
  ctx.command('订阅列表', '查看所有任务的订阅群')
802
728
  .action(async () => {
803
729
  logger.info('[schedule] 命令被调用: 订阅列表')
804
- if (!subs.size) return '暂无任何群订阅。群内发"' + config.subscribeKeyword + ' 任务名"即可订阅'
730
+ if (!subs.size) return '暂无任何群订阅。群内发"订阅 任务名"即可订阅'
805
731
  const lines = []
806
732
  for (const [taskName, taskSubs] of subs) {
807
733
  const entries = [...taskSubs.values()]
@@ -818,7 +744,7 @@ function apply(ctx, config) {
818
744
  ctx.command('lls.sublist', '查看订阅列表(备用入口)')
819
745
  .action(async () => {
820
746
  logger.info('[schedule] 命令被调用: lls.sublist')
821
- if (!subs.size) return '暂无任何群订阅。群内发"' + config.subscribeKeyword + ' 任务名"即可订阅'
747
+ if (!subs.size) return '暂无任何群订阅。群内发"订阅 任务名"即可订阅'
822
748
  const lines = []
823
749
  for (const [taskName, taskSubs] of subs) {
824
750
  const entries = [...taskSubs.values()]
@@ -837,11 +763,7 @@ function apply(ctx, config) {
837
763
  logger.info('[schedule] 命令被调用: 我的ID')
838
764
  const role = session.author?.roles?.[0]?.id || session.event?.member?.roles?.[0]?.id
839
765
  const roleName = role === 'owner' ? '群主' : role === 'admin' ? '群管理' : role === 'member' ? '普通成员' : (role || '未知')
840
- const lines = [
841
- '👤 我的账号信息:',
842
- `平台:${session.platform}`,
843
- `用户ID:${session.userId}`,
844
- ]
766
+ const lines = ['👤 我的账号信息:', `平台:${session.platform}`, `用户ID:${session.userId}`]
845
767
  if (session.guildId) lines.push(`当前群ID:${session.guildId}`)
846
768
  if (session.guildId) lines.push(`本群角色:${roleName}`)
847
769
  if (session.channelId && session.channelId !== session.guildId) lines.push(`频道ID:${session.channelId}`)
@@ -853,18 +775,13 @@ function apply(ctx, config) {
853
775
  logger.info('[schedule] 命令被调用: lls.me')
854
776
  const role = session.author?.roles?.[0]?.id || session.event?.member?.roles?.[0]?.id
855
777
  const roleName = role === 'owner' ? '群主' : role === 'admin' ? '群管理' : role === 'member' ? '普通成员' : (role || '未知')
856
- const lines = [
857
- '👤 我的账号信息:',
858
- `平台:${session.platform}`,
859
- `用户ID:${session.userId}`,
860
- ]
778
+ const lines = ['👤 我的账号信息:', `平台:${session.platform}`, `用户ID:${session.userId}`]
861
779
  if (session.guildId) lines.push(`当前群ID:${session.guildId}`)
862
780
  if (session.guildId) lines.push(`本群角色:${roleName}`)
863
781
  if (session.channelId && session.channelId !== session.guildId) lines.push(`频道ID:${session.channelId}`)
864
782
  return lines.join('\n')
865
783
  })
866
784
 
867
- // 备用命令入口(避免与其他插件的 schedule 命令冲突)
868
785
  ctx.command('lls', 'll-schedule 管理(备用入口)')
869
786
  .action(() => {
870
787
  logger.info('[schedule] 命令被调用: lls')
@@ -893,26 +810,17 @@ function apply(ctx, config) {
893
810
  if (!testTask.groups || !testTask.groups.length) {
894
811
  testTask.groups = session.guildId ? [String(session.guildId)] : []
895
812
  }
896
- const slots = Array.isArray(task.slots) && task.slots.length ? task.slots : null
897
- let sendText, sendMarkdown
898
- if (slots) {
899
- const day = todayDay()
900
- const hit = slots.find(sl => dayMatches(parseDays(sl.days), day))
901
- sendText = hit ? (hit.text || '') : (task.text || '')
902
- sendMarkdown = hit ? (hit.markdown !== undefined ? !!hit.markdown : !!task.markdown) : !!task.markdown
903
- }
904
- else {
905
- sendText = task.text || ''
906
- sendMarkdown = !!task.markdown
907
- }
908
- await sendTask(name, testTask, { text: sendText, markdown: sendMarkdown, onlyBotSid: session.bot?.sid })
813
+ const rows = parseScheduleRows(task)
814
+ const day = todayDay()
815
+ const hit = rows.find(r => dayMatches(parseDays(r.wd), day))
816
+ const sendText = hit ? (hit.text || '') : (task.text || '')
817
+ await sendTask(name, testTask, { text: sendText, markdown: task.markdown, onlyBotSid: session.bot?.sid })
909
818
  return `✅ 已发送任务 "${name}"`
910
819
  })
911
820
 
912
821
  logger.info('[schedule] 命令已注册: schedule / schedule.list / schedule.groups / schedule.test / ' +
913
- `"${config.subscribeKeyword}" / "${config.unsubscribeKeyword}" / 订阅列表 / lls.*`)
822
+ `"订阅" / "取消订阅" / 订阅列表 / lls.*`)
914
823
 
915
- // 启动时:加载已知群 + 订阅 + 立即检查一次
916
824
  Promise.all([loadKnownGroups(), loadSubs()]).then(() => checkAndSend())
917
825
  }
918
826
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "koishi-plugin-ll-schedule",
3
- "version": "1.1.0",
3
+ "version": "1.3.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": [
@@ -18,3 +18,5 @@
18
18
  }
19
19
  }
20
20
  }
21
+
22
+