koishi-plugin-ll-schedule 1.1.0 → 1.2.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 +109 -192
- package/package.json +2 -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
|
|
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\`
|
|
17
|
-
2.
|
|
18
|
-
3.
|
|
18
|
+
1. 在插件设置页的 \`tasks\` 里**添加任务**(键=任务名),在任务里 **添加时间段**:每行填 星期(留空=每天)/ 时间 / 内容
|
|
19
|
+
2. 在任意群 @机器人 发:\`订阅 任务名\` → 该群加入此任务的订阅列表
|
|
20
|
+
3. 到时间,插件自动把该时间段的内容发给所有订阅群
|
|
19
21
|
|
|
20
|
-
##
|
|
22
|
+
## 每个时间段三格子
|
|
21
23
|
|
|
22
|
-
|
|
24
|
+
- **星期**:留空 = 每天;写 \`周一\` \`周六\`...、\`工作日\`(周一到周五)、\`周末\`(周六日)、或 \`1,3,5\` 数字
|
|
25
|
+
- **时间**:\`HH:MM\`,可填多个用逗号分隔,如 \`12:20, 18:20, 21:20\`(这些时间点都发同一段内容)
|
|
26
|
+
- **内容**:到点发送的话;支持 Markdown 语法和模板变量 \`{date}\` \`{time}\` \`{week}\` \`{task}\`
|
|
23
27
|
|
|
24
|
-
|
|
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
|
-
|
|
48
|
-
|
|
30
|
+
任务「轻功」,添加 1 行: 星期=空(每天) 时间=\`12:20, 18:20, 21:20\` 内容=\`⏰ 轻功时间到了!\`
|
|
31
|
+
→ 每天这三个点都发这句。
|
|
49
32
|
|
|
50
|
-
|
|
33
|
+
## 示例:按天/按时段发不同内容(如世界BOSS)
|
|
51
34
|
|
|
52
|
-
|
|
53
|
-
|
|
35
|
+
任务「世界BOSS」,添加多行:
|
|
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刷新!仙幻岛 → 水月平原
|
|
54
46
|
\`\`\`
|
|
47
|
+
每行对应一个时间段,内容各不相同。
|
|
55
48
|
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
- \`{week}\`:今天星期(如 星期三)
|
|
60
|
-
- \`{task}\`:任务名
|
|
49
|
+
## Markdown 卡片(官方QQ机器人)
|
|
50
|
+
|
|
51
|
+
任务勾选 \`markdown\` 后,会用 QQ 官方 Markdown 卡片发送(仅官机生效,失败自动回退纯文本;其他平台始终纯文本)。内容里可用标题/加粗/引用/链接/图片,模板变量自动替换。任务填 \`image\` 图片 URL 也会随消息发出(markdown 模式自动嵌入)。
|
|
61
52
|
|
|
62
|
-
##
|
|
53
|
+
## 其他配置
|
|
63
54
|
|
|
64
|
-
- \`
|
|
65
|
-
- \`
|
|
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
|
|
80
|
-
|
|
81
|
-
订阅/取消订阅所需权限等级在设置页 \`subscribeAuthority\` 调整(默认 1=所有人都能订阅)。\`subscribeAdminBypass\`(默认开启):群主/群管理可无视权限等级直接订阅——插件从群消息自带的成员角色判断(\`owner\`/\`admin\`),**不需要申请接口权限**。想验证自己角色可发 \`我的ID\`。
|
|
65
|
+
- \`lls.*\`:以上命令的备用入口
|
|
82
66
|
|
|
83
67
|
## 官机(官方QQ机器人)说明
|
|
84
68
|
|
|
85
|
-
|
|
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
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
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:
|
|
164
|
+
key: `${name}@${hm}@${row.idx}`,
|
|
165
|
+
text: row.text || '',
|
|
176
166
|
markdown: !!task.markdown,
|
|
177
167
|
image: task.image,
|
|
178
168
|
})
|
|
@@ -212,52 +202,44 @@ 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
|
|
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('
|
|
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('
|
|
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
|
-
|
|
235
|
-
.
|
|
236
|
-
.description('
|
|
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机器人生效,失败自动回退纯文本;其他平台始终纯文本)'),
|
|
231
|
+
image: Schema.string()
|
|
232
|
+
.default('')
|
|
233
|
+
.description('图片 URL(可选):随内容一起发送(markdown 模式自动嵌入为图片)'),
|
|
249
234
|
enabled: Schema.boolean()
|
|
250
235
|
.default(true)
|
|
251
236
|
.description('是否启用'),
|
|
252
|
-
slots: Schema.array(TimeSlot)
|
|
253
|
-
.default([])
|
|
254
|
-
.description('📅 排班表(可选):每行=星期+时间+内容,按星期设置不同时段/内容;一旦填写就完全以排班表为准,上方 time/text 自动忽略'),
|
|
255
237
|
})
|
|
256
238
|
|
|
257
239
|
const Config = Schema.object({
|
|
258
240
|
tasks: Schema.dict(TaskItem)
|
|
259
241
|
.default({})
|
|
260
|
-
.description('📋
|
|
242
|
+
.description('📋 推送任务表格(键=任务名;每个任务 = 时间段列表:星期/时间/内容)'),
|
|
261
243
|
subscribeKeyword: Schema.string()
|
|
262
244
|
.default('订阅')
|
|
263
245
|
.description('群内订阅推送的关键词(如"订阅 任务名",只输关键词则订阅唯一启用的任务)'),
|
|
@@ -281,19 +263,17 @@ const Config = Schema.object({
|
|
|
281
263
|
exports.Config = Config
|
|
282
264
|
|
|
283
265
|
// 需要数据库:持久化学习到的群(重启不丢)
|
|
284
|
-
// 必须声明 required:确保插件在数据库就绪后才启动,模型表能正常注册建表
|
|
285
266
|
exports.inject = { required: ['database'] }
|
|
286
267
|
|
|
287
268
|
function apply(ctx, config) {
|
|
288
269
|
const logger = ctx.logger('ll-schedule')
|
|
289
270
|
|
|
290
|
-
//
|
|
271
|
+
// 记录当天已发送的,防止同一分钟重复发
|
|
291
272
|
let firedToday = {}
|
|
292
273
|
|
|
293
274
|
// ===== 已知群(从收到的消息中学习) =====
|
|
294
|
-
// key: `${botSid}:${guildId}` -> { botSid, guildId, platform, learnedAt }
|
|
295
275
|
const knownGroups = new Map()
|
|
296
|
-
const groupNameCache = new Map()
|
|
276
|
+
const groupNameCache = new Map()
|
|
297
277
|
const TTL = 60 * 60 * 1000
|
|
298
278
|
|
|
299
279
|
function cacheGet(map, key) {
|
|
@@ -304,7 +284,7 @@ function apply(ctx, config) {
|
|
|
304
284
|
map.set(key, { v: value, t: Date.now() })
|
|
305
285
|
}
|
|
306
286
|
|
|
307
|
-
//
|
|
287
|
+
// 数据库模型
|
|
308
288
|
if (ctx.database) {
|
|
309
289
|
ctx.model.extend('ll_schedule_groups', {
|
|
310
290
|
botSid: 'string',
|
|
@@ -312,7 +292,6 @@ function apply(ctx, config) {
|
|
|
312
292
|
platform: 'string',
|
|
313
293
|
learnedAt: 'unsigned',
|
|
314
294
|
}, { primary: ['botSid', 'guildId'] })
|
|
315
|
-
// 群订阅表:taskName + botSid + guildId(群内"订阅"命令注册的推送目标)
|
|
316
295
|
ctx.model.extend('ll_schedule_subs', {
|
|
317
296
|
taskName: 'string',
|
|
318
297
|
botSid: 'string',
|
|
@@ -321,8 +300,7 @@ function apply(ctx, config) {
|
|
|
321
300
|
}, { primary: ['taskName', 'botSid', 'guildId'] })
|
|
322
301
|
}
|
|
323
302
|
|
|
324
|
-
// =====
|
|
325
|
-
// subs: Map<taskName, Map<`${botSid}:${guildId}`, { botSid, guildId, createdAt }>>
|
|
303
|
+
// ===== 群订阅 =====
|
|
326
304
|
const subs = new Map()
|
|
327
305
|
|
|
328
306
|
function getTaskSubs(taskName) {
|
|
@@ -436,7 +414,6 @@ function apply(ctx, config) {
|
|
|
436
414
|
return ''
|
|
437
415
|
}
|
|
438
416
|
|
|
439
|
-
// 从消息事件学习群
|
|
440
417
|
ctx.on('message', (session) => {
|
|
441
418
|
learnGroup(session)
|
|
442
419
|
})
|
|
@@ -452,7 +429,6 @@ function apply(ctx, config) {
|
|
|
452
429
|
}
|
|
453
430
|
|
|
454
431
|
// ===== 发送 =====
|
|
455
|
-
// 官机 markdown 发送时忽略的已知错误码(主动消息受限等)
|
|
456
432
|
const KNOWN_IGNORE_CODES = new Set([11293, 40034101, 40034105, 304101, 304102])
|
|
457
433
|
|
|
458
434
|
function errCode(e) {
|
|
@@ -466,7 +442,6 @@ function apply(ctx, config) {
|
|
|
466
442
|
return typeof code === 'number' && KNOWN_IGNORE_CODES.has(code)
|
|
467
443
|
}
|
|
468
444
|
|
|
469
|
-
// 向一个目标发送一段内容;markdown 模式仅官机生效,失败自动回退纯文本
|
|
470
445
|
async function sendOne(bot, target, text, { markdown, image }) {
|
|
471
446
|
if (markdown && bot.platform === 'qq' && bot.internal && typeof bot.internal.sendMessage === 'function') {
|
|
472
447
|
const md = buildMd(text, image)
|
|
@@ -515,7 +490,6 @@ function apply(ctx, config) {
|
|
|
515
490
|
const taskSubs = subs.get(name)
|
|
516
491
|
|
|
517
492
|
for (const bot of bots) {
|
|
518
|
-
// 该任务下、该机器人订阅的群(群内"订阅"命令注册的)
|
|
519
493
|
const subTargets = taskSubs
|
|
520
494
|
? [...taskSubs.values()].filter(s => s.botSid === bot.sid).map(s => s.guildId)
|
|
521
495
|
: []
|
|
@@ -524,7 +498,6 @@ function apply(ctx, config) {
|
|
|
524
498
|
for (const rawTarget of groups || []) {
|
|
525
499
|
const target = String(rawTarget).trim()
|
|
526
500
|
if (!target) continue
|
|
527
|
-
// 官机:群 ID 是 openid;数字群号在官机上无法直接使用
|
|
528
501
|
if (bot.platform === 'qq' && /^\d+$/.test(target)) {
|
|
529
502
|
logger.warn(`[schedule] 任务 "${name}":官方QQ机器人不能用数字群号 "${target}" 发送,请用群内"订阅"命令或 schedule.groups 查看 openid`)
|
|
530
503
|
continue
|
|
@@ -548,7 +521,6 @@ function apply(ctx, config) {
|
|
|
548
521
|
}
|
|
549
522
|
}
|
|
550
523
|
else if (config.sendAllWhenEmpty) {
|
|
551
|
-
// 未指定群也没有订阅 → 发送给该 bot 所有已知群
|
|
552
524
|
const targets = [...knownGroups.values()].filter(g => g.botSid === bot.sid)
|
|
553
525
|
if (!targets.length) {
|
|
554
526
|
logger.warn(`[schedule] 任务 "${name}" 未指定群且机器人 ${bot.sid} 暂无已知群(先让机器人收过该群消息,或用 schedule.groups 查看)`)
|
|
@@ -573,7 +545,6 @@ function apply(ctx, config) {
|
|
|
573
545
|
async function checkAndSend() {
|
|
574
546
|
const now = new Date()
|
|
575
547
|
const today = todayStr(now)
|
|
576
|
-
// 跨天重置
|
|
577
548
|
if (firedToday.date !== today) {
|
|
578
549
|
firedToday = { date: today }
|
|
579
550
|
}
|
|
@@ -582,7 +553,6 @@ function apply(ctx, config) {
|
|
|
582
553
|
const fires = computeFires(config.tasks, now, hm)
|
|
583
554
|
|
|
584
555
|
for (const f of fires) {
|
|
585
|
-
// 今天这个时间点已经发过 → 跳过
|
|
586
556
|
if (firedToday[f.key]) continue
|
|
587
557
|
firedToday[f.key] = true
|
|
588
558
|
|
|
@@ -593,7 +563,6 @@ function apply(ctx, config) {
|
|
|
593
563
|
}
|
|
594
564
|
}
|
|
595
565
|
|
|
596
|
-
// 每 20 秒检查一次(保证同一分钟内能触发)
|
|
597
566
|
const timer = setInterval(checkAndSend, 20000)
|
|
598
567
|
|
|
599
568
|
ctx.on('dispose', () => clearInterval(timer))
|
|
@@ -614,40 +583,27 @@ function apply(ctx, config) {
|
|
|
614
583
|
function formatTaskList() {
|
|
615
584
|
const tasks = config.tasks || {}
|
|
616
585
|
const keys = Object.keys(tasks)
|
|
617
|
-
if (!keys.length) return '暂无定时任务\n请在插件设置页的 tasks
|
|
586
|
+
if (!keys.length) return '暂无定时任务\n请在插件设置页的 tasks 表格中添加任务'
|
|
618
587
|
return keys.map(k => {
|
|
619
588
|
const t = tasks[k]
|
|
620
589
|
const s = t.enabled === false ? '⛔' : '✅'
|
|
621
590
|
const staticGroups = (t.groups || []).join(',')
|
|
622
591
|
const subCount = (subs.get(k) || new Map()).size
|
|
623
592
|
const target = staticGroups ? staticGroups : (subCount ? `订阅${subCount}群` : '所有已知群')
|
|
624
|
-
const
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
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}群` : ''}`
|
|
593
|
+
const rows = parseScheduleRows(t)
|
|
594
|
+
const lines = rows.map((r, i) => {
|
|
595
|
+
const wd = r.wd || '每天'
|
|
596
|
+
return ` ${i + 1}. ${wd} ${r.time} → ${r.text || '(空)'}`
|
|
597
|
+
})
|
|
598
|
+
const head = `${s} ${k} → ${rows.length} 个时间段 → 群:${target}${t.markdown ? ' (md卡片)' : ''}`
|
|
599
|
+
return [head, ...lines].join('\n')
|
|
644
600
|
}).join('\n')
|
|
645
601
|
}
|
|
646
602
|
|
|
647
603
|
ctx.command('schedule', '定时发送管理')
|
|
648
604
|
.action(() => {
|
|
649
605
|
logger.info('[schedule] 命令被调用: schedule')
|
|
650
|
-
return '⚙️ 管理:插件设置页 → ll-schedule → tasks
|
|
606
|
+
return '⚙️ 管理:插件设置页 → ll-schedule → tasks 添加任务和时间段(星期/时间/内容)\n' +
|
|
651
607
|
`群内发"${config.subscribeKeyword} 任务名"即可让本群订阅推送\n` +
|
|
652
608
|
`命令:${config.subscribeKeyword} | ${config.unsubscribeKeyword} | 订阅列表 | schedule.list | schedule.groups | schedule.test <任务名>`
|
|
653
609
|
})
|
|
@@ -670,32 +626,20 @@ function apply(ctx, config) {
|
|
|
670
626
|
const tasks = config.tasks || {}
|
|
671
627
|
const task = tasks[name]
|
|
672
628
|
if (!task) return `未找到任务 "${name}",可用 schedule.list 查看任务名`
|
|
673
|
-
// 测试时:任务没配群也没订阅就发到当前群
|
|
674
629
|
const testTask = { ...task }
|
|
675
630
|
if (!testTask.groups || !testTask.groups.length) {
|
|
676
631
|
testTask.groups = session.guildId ? [String(session.guildId)] : []
|
|
677
632
|
}
|
|
678
|
-
//
|
|
679
|
-
const
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
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 })
|
|
633
|
+
// 发"今天会命中的第一行"的内容,更贴近真实
|
|
634
|
+
const rows = parseScheduleRows(task)
|
|
635
|
+
const day = todayDay()
|
|
636
|
+
const hit = rows.find(r => dayMatches(parseDays(r.wd), day))
|
|
637
|
+
const sendText = hit ? (hit.text || '') : (task.text || '')
|
|
638
|
+
await sendTask(name, testTask, { text: sendText, markdown: task.markdown, onlyBotSid: session.bot?.sid })
|
|
692
639
|
return `✅ 已发送任务 "${name}"`
|
|
693
640
|
})
|
|
694
641
|
|
|
695
642
|
// ===== 群内订阅命令 =====
|
|
696
|
-
// 判断发送者是否群主/群管理:
|
|
697
|
-
// 1) 优先用消息事件自带的成员角色(官机/OneBot 群消息都会带,零 API 调用,不受接口权限限制)
|
|
698
|
-
// 2) 兜底调 getGuildMember(需要接口权限,拿不到就忽略)
|
|
699
643
|
async function isGroupAdmin(session) {
|
|
700
644
|
if (!session.guildId || !session.userId) return false
|
|
701
645
|
const role = session.author?.roles?.[0]?.id || session.event?.member?.roles?.[0]?.id
|
|
@@ -722,7 +666,6 @@ function apply(ctx, config) {
|
|
|
722
666
|
(config.subscribeAdminBypass ? ',或为本群群主/群管理' : '')
|
|
723
667
|
}
|
|
724
668
|
|
|
725
|
-
// 解析订阅命令的任务名:明确指定 → 用它;不填且只有一个启用任务 → 用它;否则返回提示
|
|
726
669
|
function resolveSubscribeTaskName(taskName) {
|
|
727
670
|
const tasks = config.tasks || {}
|
|
728
671
|
if (taskName) {
|
|
@@ -748,16 +691,8 @@ function apply(ctx, config) {
|
|
|
748
691
|
taskSubs.set(key, entry)
|
|
749
692
|
persistSub(name, entry)
|
|
750
693
|
logger.info(`[schedule] 群 ${session.guildId} 已订阅任务 "${name}"`)
|
|
751
|
-
const
|
|
752
|
-
|
|
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 || '(空)'}`
|
|
694
|
+
const rows = parseScheduleRows(task)
|
|
695
|
+
return `✅ 本群已加入任务 "${name}" 的推送列表\n时间段:${rows.length} 个(schedule.list 查看详情)`
|
|
761
696
|
}
|
|
762
697
|
|
|
763
698
|
async function unsubscribeCurrentGroup(session, taskName) {
|
|
@@ -837,11 +772,7 @@ function apply(ctx, config) {
|
|
|
837
772
|
logger.info('[schedule] 命令被调用: 我的ID')
|
|
838
773
|
const role = session.author?.roles?.[0]?.id || session.event?.member?.roles?.[0]?.id
|
|
839
774
|
const roleName = role === 'owner' ? '群主' : role === 'admin' ? '群管理' : role === 'member' ? '普通成员' : (role || '未知')
|
|
840
|
-
const lines = [
|
|
841
|
-
'👤 我的账号信息:',
|
|
842
|
-
`平台:${session.platform}`,
|
|
843
|
-
`用户ID:${session.userId}`,
|
|
844
|
-
]
|
|
775
|
+
const lines = ['👤 我的账号信息:', `平台:${session.platform}`, `用户ID:${session.userId}`]
|
|
845
776
|
if (session.guildId) lines.push(`当前群ID:${session.guildId}`)
|
|
846
777
|
if (session.guildId) lines.push(`本群角色:${roleName}`)
|
|
847
778
|
if (session.channelId && session.channelId !== session.guildId) lines.push(`频道ID:${session.channelId}`)
|
|
@@ -853,18 +784,13 @@ function apply(ctx, config) {
|
|
|
853
784
|
logger.info('[schedule] 命令被调用: lls.me')
|
|
854
785
|
const role = session.author?.roles?.[0]?.id || session.event?.member?.roles?.[0]?.id
|
|
855
786
|
const roleName = role === 'owner' ? '群主' : role === 'admin' ? '群管理' : role === 'member' ? '普通成员' : (role || '未知')
|
|
856
|
-
const lines = [
|
|
857
|
-
'👤 我的账号信息:',
|
|
858
|
-
`平台:${session.platform}`,
|
|
859
|
-
`用户ID:${session.userId}`,
|
|
860
|
-
]
|
|
787
|
+
const lines = ['👤 我的账号信息:', `平台:${session.platform}`, `用户ID:${session.userId}`]
|
|
861
788
|
if (session.guildId) lines.push(`当前群ID:${session.guildId}`)
|
|
862
789
|
if (session.guildId) lines.push(`本群角色:${roleName}`)
|
|
863
790
|
if (session.channelId && session.channelId !== session.guildId) lines.push(`频道ID:${session.channelId}`)
|
|
864
791
|
return lines.join('\n')
|
|
865
792
|
})
|
|
866
793
|
|
|
867
|
-
// 备用命令入口(避免与其他插件的 schedule 命令冲突)
|
|
868
794
|
ctx.command('lls', 'll-schedule 管理(备用入口)')
|
|
869
795
|
.action(() => {
|
|
870
796
|
logger.info('[schedule] 命令被调用: lls')
|
|
@@ -893,26 +819,17 @@ function apply(ctx, config) {
|
|
|
893
819
|
if (!testTask.groups || !testTask.groups.length) {
|
|
894
820
|
testTask.groups = session.guildId ? [String(session.guildId)] : []
|
|
895
821
|
}
|
|
896
|
-
const
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
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 })
|
|
822
|
+
const rows = parseScheduleRows(task)
|
|
823
|
+
const day = todayDay()
|
|
824
|
+
const hit = rows.find(r => dayMatches(parseDays(r.wd), day))
|
|
825
|
+
const sendText = hit ? (hit.text || '') : (task.text || '')
|
|
826
|
+
await sendTask(name, testTask, { text: sendText, markdown: task.markdown, onlyBotSid: session.bot?.sid })
|
|
909
827
|
return `✅ 已发送任务 "${name}"`
|
|
910
828
|
})
|
|
911
829
|
|
|
912
830
|
logger.info('[schedule] 命令已注册: schedule / schedule.list / schedule.groups / schedule.test / ' +
|
|
913
831
|
`"${config.subscribeKeyword}" / "${config.unsubscribeKeyword}" / 订阅列表 / lls.*`)
|
|
914
832
|
|
|
915
|
-
// 启动时:加载已知群 + 订阅 + 立即检查一次
|
|
916
833
|
Promise.all([loadKnownGroups(), loadSubs()]).then(() => checkAndSend())
|
|
917
834
|
}
|
|
918
835
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "koishi-plugin-ll-schedule",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.2.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,4 @@
|
|
|
18
18
|
}
|
|
19
19
|
}
|
|
20
20
|
}
|
|
21
|
+
|