koishi-plugin-ll-schedule 1.0.9 → 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 +305 -106
- package/package.json +6 -5
package/index.js
CHANGED
|
@@ -1,54 +1,236 @@
|
|
|
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 卡片 + 模板变量
|
|
7
|
+
// v1.2.0:设置页重做 —— 任务 = "时间段列表,每行 = 星期 + 时间 + 内容",删掉零散子字段,更直观
|
|
8
|
+
// (向后兼容:旧 time/text、旧 slots(days) 配置仍能识别)
|
|
6
9
|
|
|
7
10
|
const { Schema, h } = require('koishi')
|
|
8
11
|
|
|
9
12
|
exports.name = 'll-schedule'
|
|
10
13
|
|
|
11
|
-
exports.usage =
|
|
14
|
+
exports.usage = `定时提醒插件:到时间自动向订阅了该任务的群发送提醒。每个任务 = 一张"时间段列表",点 + 添加一行,每行填 星期 / 时间 / 内容。
|
|
12
15
|
|
|
13
|
-
##
|
|
16
|
+
## 快速上手
|
|
14
17
|
|
|
15
|
-
1. 在插件设置页的 \`tasks\`
|
|
16
|
-
2.
|
|
17
|
-
3.
|
|
18
|
+
1. 在插件设置页的 \`tasks\` 里**添加任务**(键=任务名),在任务里 **添加时间段**:每行填 星期(留空=每天)/ 时间 / 内容
|
|
19
|
+
2. 在任意群 @机器人 发:\`订阅 任务名\` → 该群加入此任务的订阅列表
|
|
20
|
+
3. 到时间,插件自动把该时间段的内容发给所有订阅群
|
|
18
21
|
|
|
19
|
-
##
|
|
22
|
+
## 每个时间段三格子
|
|
20
23
|
|
|
21
|
-
-
|
|
22
|
-
- \`
|
|
23
|
-
- \`
|
|
24
|
-
|
|
24
|
+
- **星期**:留空 = 每天;写 \`周一\` \`周六\`...、\`工作日\`(周一到周五)、\`周末\`(周六日)、或 \`1,3,5\` 数字
|
|
25
|
+
- **时间**:\`HH:MM\`,可填多个用逗号分隔,如 \`12:20, 18:20, 21:20\`(这些时间点都发同一段内容)
|
|
26
|
+
- **内容**:到点发送的话;支持 Markdown 语法和模板变量 \`{date}\` \`{time}\` \`{week}\` \`{task}\`
|
|
27
|
+
|
|
28
|
+
## 示例:每天定时提醒
|
|
29
|
+
|
|
30
|
+
任务「轻功」,添加 1 行: 星期=空(每天) 时间=\`12:20, 18:20, 21:20\` 内容=\`⏰ 轻功时间到了!\`
|
|
31
|
+
→ 每天这三个点都发这句。
|
|
32
|
+
|
|
33
|
+
## 示例:按天/按时段发不同内容(如世界BOSS)
|
|
34
|
+
|
|
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刷新!仙幻岛 → 水月平原
|
|
46
|
+
\`\`\`
|
|
47
|
+
每行对应一个时间段,内容各不相同。
|
|
48
|
+
|
|
49
|
+
## Markdown 卡片(官方QQ机器人)
|
|
50
|
+
|
|
51
|
+
任务勾选 \`markdown\` 后,会用 QQ 官方 Markdown 卡片发送(仅官机生效,失败自动回退纯文本;其他平台始终纯文本)。内容里可用标题/加粗/引用/链接/图片,模板变量自动替换。任务填 \`image\` 图片 URL 也会随消息发出(markdown 模式自动嵌入)。
|
|
52
|
+
|
|
53
|
+
## 其他配置
|
|
54
|
+
|
|
55
|
+
- \`groups\`:手动指定目标群(推荐留空,用群内"订阅"命令自动添加)
|
|
56
|
+
- \`enabled\`:是否启用该任务
|
|
25
57
|
|
|
26
58
|
## 群内命令
|
|
27
59
|
|
|
28
|
-
- \`订阅 [任务名]
|
|
60
|
+
- \`订阅 [任务名]\`:当前群订阅指定任务
|
|
29
61
|
- \`取消订阅 [任务名]\`:当前群取消订阅
|
|
30
62
|
- \`订阅列表\`:查看所有任务的订阅群
|
|
31
|
-
- \`schedule.list
|
|
63
|
+
- \`schedule.list\`:查看任务列表(含每段时间段)
|
|
32
64
|
- \`schedule.test <任务名>\`:立即测试发送
|
|
33
|
-
- \`lls
|
|
34
|
-
|
|
35
|
-
订阅/取消订阅所需权限等级在设置页 \`subscribeAuthority\` 调整(默认 1=所有人都能订阅)。\`subscribeAdminBypass\`(默认开启):群主/群管理可无视权限等级直接订阅——插件从群消息自带的成员角色判断(\`owner\`/\`admin\`),**不需要申请接口权限**。想验证自己角色可发 \`我的ID\`。
|
|
65
|
+
- \`lls.*\`:以上命令的备用入口
|
|
36
66
|
|
|
37
67
|
## 官机(官方QQ机器人)说明
|
|
38
68
|
|
|
39
|
-
|
|
69
|
+
官方平台没有群号只有 openid——用群内"订阅"命令即可,插件自动记录当前群 openid。手动填 \`groups\` 时用 \`schedule.groups\` 查看已知群 openid。官机主动发群消息有平台频率限制,且机器人需在群里。
|
|
40
70
|
`
|
|
41
71
|
|
|
42
|
-
|
|
72
|
+
// ===== 纯函数(可单测) =====
|
|
73
|
+
|
|
74
|
+
// 解析时间:支持多个时间,逗号/顿号/空格分隔,如 "09:00, 21:30"
|
|
75
|
+
function parseTimes(timeStr) {
|
|
76
|
+
if (!timeStr) return []
|
|
77
|
+
return String(timeStr).split(/[,,、;;\s]+/)
|
|
78
|
+
.map(s => s.trim())
|
|
79
|
+
.filter(s => /^\d{1,2}:\d{2}$/.test(s))
|
|
80
|
+
.map(s => (s.length === 4 ? '0' + s : s))
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// 今天星期几(1=周一 ... 7=周日)
|
|
84
|
+
function todayDay(date) {
|
|
85
|
+
const d = date || new Date()
|
|
86
|
+
const day = d.getDay()
|
|
87
|
+
return day === 0 ? 7 : day
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const WEEK_CN = ['', '一', '二', '三', '四', '五', '六', '日']
|
|
91
|
+
|
|
92
|
+
// 解析星期配置:返回 Set(1..7) 或 null(null = 每天)
|
|
93
|
+
// 支持:留空/每天/每日、1-7 数字逗号分隔、范围 1-5、中文 一,三,五 / 星期X / 周X / 每周X、工作日、周末
|
|
94
|
+
function parseDays(daysStr) {
|
|
95
|
+
if (daysStr === undefined || daysStr === null) return null
|
|
96
|
+
const s = String(daysStr).trim()
|
|
97
|
+
if (!s) return null
|
|
98
|
+
const alias = { '每天': null, '每日': null, '天天': null, '工作日': new Set([1, 2, 3, 4, 5]), '周末': new Set([6, 7]), '休息日': new Set([6, 7]) }
|
|
99
|
+
if (Object.prototype.hasOwnProperty.call(alias, s)) return alias[s]
|
|
100
|
+
const set = new Set()
|
|
101
|
+
const cnMap = { '一': 1, '二': 2, '三': 3, '四': 4, '五': 5, '六': 6, '日': 7, '天': 7 }
|
|
102
|
+
for (const part of s.split(/[,,、;;\s]+/)) {
|
|
103
|
+
const p = part.trim()
|
|
104
|
+
if (!p) continue
|
|
105
|
+
const range = p.match(/^(\d{1,2})\s*[-~]\s*(\d{1,2})$/)
|
|
106
|
+
if (range) {
|
|
107
|
+
for (let i = parseInt(range[1], 10); i <= parseInt(range[2], 10); i++) {
|
|
108
|
+
if (i >= 1 && i <= 7) set.add(i)
|
|
109
|
+
}
|
|
110
|
+
continue
|
|
111
|
+
}
|
|
112
|
+
const cn = p.match(/^(?:每)?(?:星期|周|礼拜)?([一二三四五六日天])$/)
|
|
113
|
+
if (cn) {
|
|
114
|
+
set.add(cnMap[cn[1]])
|
|
115
|
+
continue
|
|
116
|
+
}
|
|
117
|
+
const num = p.match(/^([1-7])$/)
|
|
118
|
+
if (num) {
|
|
119
|
+
set.add(parseInt(num[1], 10))
|
|
120
|
+
continue
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
return set.size ? set : null
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// 某天是否命中排班(null=每天 恒命中)
|
|
127
|
+
function dayMatches(daysSet, day) {
|
|
128
|
+
return !daysSet || daysSet.has(day)
|
|
129
|
+
}
|
|
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
|
+
|
|
151
|
+
// 计算"现在这一刻该发什么":返回 [{ name, key, text, markdown, image }]
|
|
152
|
+
function computeFires(tasks, date, hm) {
|
|
153
|
+
const day = todayDay(date)
|
|
154
|
+
const fires = []
|
|
155
|
+
for (const [name, task] of Object.entries(tasks || {})) {
|
|
156
|
+
if (!task || task.enabled === false) continue
|
|
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)
|
|
161
|
+
if (!timeList.includes(hm)) continue
|
|
162
|
+
fires.push({
|
|
163
|
+
name,
|
|
164
|
+
key: `${name}@${hm}@${row.idx}`,
|
|
165
|
+
text: row.text || '',
|
|
166
|
+
markdown: !!task.markdown,
|
|
167
|
+
image: task.image,
|
|
168
|
+
})
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
return fires
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// 模板变量渲染:{date} {time} {week} {task}
|
|
175
|
+
function renderText(text, ctx) {
|
|
176
|
+
return String(text || '')
|
|
177
|
+
.replace(/\{date\}/g, ctx.date)
|
|
178
|
+
.replace(/\{time\}/g, ctx.time)
|
|
179
|
+
.replace(/\{week\}/g, ctx.week)
|
|
180
|
+
.replace(/\{task\}/g, ctx.task)
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// 组装 markdown:文本 + 图片
|
|
184
|
+
function buildMd(text, image) {
|
|
185
|
+
let md = String(text || '').trim()
|
|
186
|
+
if (image) md += (md ? '\n\n' : '') + ``
|
|
187
|
+
return md
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// markdown 转纯文本(发送时兜底)
|
|
191
|
+
function buildTextFromMd(md) {
|
|
192
|
+
return String(md || '')
|
|
193
|
+
.replace(/<qqbot-at-user[^>]*\/>/g, '')
|
|
194
|
+
.replace(/^#{1,6}\s*/gm, '')
|
|
195
|
+
.replace(/\*\*([^*]+)\*\*/g, '$1')
|
|
196
|
+
.replace(/\*([^*]+)\*/g, '$1')
|
|
197
|
+
.replace(/`([^`]+)`/g, '$1')
|
|
198
|
+
.replace(/!\[([^\]]*)\]\(([^)]+)\)/g, '[$1]($2)')
|
|
199
|
+
.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '$1: $2')
|
|
200
|
+
.replace(/^>\s?/gm, '')
|
|
201
|
+
.replace(/\n{3,}/g, '\n\n')
|
|
202
|
+
.trim()
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
exports._test = { parseTimes, todayDay, parseDays, dayMatches, parseScheduleRows, computeFires, renderText, buildMd, buildTextFromMd }
|
|
206
|
+
|
|
207
|
+
// ===== 配置 Schema =====
|
|
208
|
+
|
|
209
|
+
const SlotItem = Schema.object({
|
|
210
|
+
wd: Schema.string()
|
|
211
|
+
.default('')
|
|
212
|
+
.description('星期:留空=每天;周一/周二...;工作日(周一到五)/周末(周六日);或 1,3,5 / 1-5'),
|
|
43
213
|
time: Schema.string()
|
|
44
214
|
.required()
|
|
45
|
-
.description('
|
|
215
|
+
.description('时间(24小时制 HH:MM),可多个用逗号分隔,如 12:20, 18:20, 21:20'),
|
|
216
|
+
text: Schema.string()
|
|
217
|
+
.default('')
|
|
218
|
+
.description('内容:到点发送的话,支持 Markdown 和模板变量 {date} {time} {week} {task}'),
|
|
219
|
+
})
|
|
220
|
+
|
|
221
|
+
const TaskItem = Schema.object({
|
|
222
|
+
slots: Schema.array(SlotItem)
|
|
223
|
+
.default([])
|
|
224
|
+
.description('📅 时间段列表:每行 = 星期 + 时间 + 内容,点 + 可添加多行;每行内容各自独立'),
|
|
46
225
|
groups: Schema.array(Schema.string())
|
|
47
226
|
.default([])
|
|
48
227
|
.description('手动指定的目标群 ID(可留空;推荐用群内"订阅"命令自动添加,无需手动填 openid)'),
|
|
49
|
-
|
|
228
|
+
markdown: Schema.boolean()
|
|
229
|
+
.default(false)
|
|
230
|
+
.description('用 QQ官方 Markdown 卡片发送(仅官方QQ机器人生效,失败自动回退纯文本;其他平台始终纯文本)'),
|
|
231
|
+
image: Schema.string()
|
|
50
232
|
.default('')
|
|
51
|
-
.description('
|
|
233
|
+
.description('图片 URL(可选):随内容一起发送(markdown 模式自动嵌入为图片)'),
|
|
52
234
|
enabled: Schema.boolean()
|
|
53
235
|
.default(true)
|
|
54
236
|
.description('是否启用'),
|
|
@@ -57,7 +239,7 @@ const TaskItem = Schema.object({
|
|
|
57
239
|
const Config = Schema.object({
|
|
58
240
|
tasks: Schema.dict(TaskItem)
|
|
59
241
|
.default({})
|
|
60
|
-
.description('📋
|
|
242
|
+
.description('📋 推送任务表格(键=任务名;每个任务 = 时间段列表:星期/时间/内容)'),
|
|
61
243
|
subscribeKeyword: Schema.string()
|
|
62
244
|
.default('订阅')
|
|
63
245
|
.description('群内订阅推送的关键词(如"订阅 任务名",只输关键词则订阅唯一启用的任务)'),
|
|
@@ -81,19 +263,17 @@ const Config = Schema.object({
|
|
|
81
263
|
exports.Config = Config
|
|
82
264
|
|
|
83
265
|
// 需要数据库:持久化学习到的群(重启不丢)
|
|
84
|
-
// 必须声明 required:确保插件在数据库就绪后才启动,模型表能正常注册建表
|
|
85
266
|
exports.inject = { required: ['database'] }
|
|
86
267
|
|
|
87
268
|
function apply(ctx, config) {
|
|
88
269
|
const logger = ctx.logger('ll-schedule')
|
|
89
270
|
|
|
90
|
-
//
|
|
271
|
+
// 记录当天已发送的,防止同一分钟重复发
|
|
91
272
|
let firedToday = {}
|
|
92
273
|
|
|
93
274
|
// ===== 已知群(从收到的消息中学习) =====
|
|
94
|
-
// key: `${botSid}:${guildId}` -> { botSid, guildId, platform, learnedAt }
|
|
95
275
|
const knownGroups = new Map()
|
|
96
|
-
const groupNameCache = new Map()
|
|
276
|
+
const groupNameCache = new Map()
|
|
97
277
|
const TTL = 60 * 60 * 1000
|
|
98
278
|
|
|
99
279
|
function cacheGet(map, key) {
|
|
@@ -104,7 +284,7 @@ function apply(ctx, config) {
|
|
|
104
284
|
map.set(key, { v: value, t: Date.now() })
|
|
105
285
|
}
|
|
106
286
|
|
|
107
|
-
//
|
|
287
|
+
// 数据库模型
|
|
108
288
|
if (ctx.database) {
|
|
109
289
|
ctx.model.extend('ll_schedule_groups', {
|
|
110
290
|
botSid: 'string',
|
|
@@ -112,7 +292,6 @@ function apply(ctx, config) {
|
|
|
112
292
|
platform: 'string',
|
|
113
293
|
learnedAt: 'unsigned',
|
|
114
294
|
}, { primary: ['botSid', 'guildId'] })
|
|
115
|
-
// 群订阅表:taskName + botSid + guildId(群内"订阅"命令注册的推送目标)
|
|
116
295
|
ctx.model.extend('ll_schedule_subs', {
|
|
117
296
|
taskName: 'string',
|
|
118
297
|
botSid: 'string',
|
|
@@ -121,8 +300,7 @@ function apply(ctx, config) {
|
|
|
121
300
|
}, { primary: ['taskName', 'botSid', 'guildId'] })
|
|
122
301
|
}
|
|
123
302
|
|
|
124
|
-
// =====
|
|
125
|
-
// subs: Map<taskName, Map<`${botSid}:${guildId}`, { botSid, guildId, createdAt }>>
|
|
303
|
+
// ===== 群订阅 =====
|
|
126
304
|
const subs = new Map()
|
|
127
305
|
|
|
128
306
|
function getTaskSubs(taskName) {
|
|
@@ -236,76 +414,82 @@ function apply(ctx, config) {
|
|
|
236
414
|
return ''
|
|
237
415
|
}
|
|
238
416
|
|
|
239
|
-
// 从消息事件学习群
|
|
240
417
|
ctx.on('message', (session) => {
|
|
241
418
|
learnGroup(session)
|
|
242
419
|
})
|
|
243
420
|
|
|
244
|
-
function nowHM() {
|
|
245
|
-
const d = new Date()
|
|
421
|
+
function nowHM(date) {
|
|
422
|
+
const d = date || new Date()
|
|
246
423
|
return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`
|
|
247
424
|
}
|
|
248
425
|
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
if (!timeStr) return []
|
|
252
|
-
return String(timeStr).split(/[,,、;;\s]+/)
|
|
253
|
-
.map(s => s.trim())
|
|
254
|
-
.filter(s => /^\d{1,2}:\d{2}$/.test(s))
|
|
255
|
-
.map(s => (s.length === 4 ? '0' + s : s))
|
|
256
|
-
}
|
|
257
|
-
|
|
258
|
-
function todayStr() {
|
|
259
|
-
const d = new Date()
|
|
426
|
+
function todayStr(date) {
|
|
427
|
+
const d = date || new Date()
|
|
260
428
|
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
|
|
261
429
|
}
|
|
262
430
|
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
// 跨天重置
|
|
266
|
-
if (firedToday.date !== today) {
|
|
267
|
-
firedToday = { date: today }
|
|
268
|
-
}
|
|
431
|
+
// ===== 发送 =====
|
|
432
|
+
const KNOWN_IGNORE_CODES = new Set([11293, 40034101, 40034105, 304101, 304102])
|
|
269
433
|
|
|
270
|
-
|
|
271
|
-
const
|
|
434
|
+
function errCode(e) {
|
|
435
|
+
const d = e && e.response && e.response.data
|
|
436
|
+
if (!d) return undefined
|
|
437
|
+
return d.code !== undefined ? d.code : d.err_code
|
|
438
|
+
}
|
|
272
439
|
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
// 今天这个时间点已经发过这个任务 → 跳过
|
|
278
|
-
const fireKey = `${name}@${hm}`
|
|
279
|
-
if (firedToday[fireKey]) continue
|
|
280
|
-
firedToday[fireKey] = true
|
|
440
|
+
function isKnownIgnoreError(e) {
|
|
441
|
+
const code = errCode(e)
|
|
442
|
+
return typeof code === 'number' && KNOWN_IGNORE_CODES.has(code)
|
|
443
|
+
}
|
|
281
444
|
|
|
282
|
-
|
|
283
|
-
|
|
445
|
+
async function sendOne(bot, target, text, { markdown, image }) {
|
|
446
|
+
if (markdown && bot.platform === 'qq' && bot.internal && typeof bot.internal.sendMessage === 'function') {
|
|
447
|
+
const md = buildMd(text, image)
|
|
448
|
+
const plain = buildTextFromMd(md) || ' '
|
|
449
|
+
try {
|
|
450
|
+
await bot.internal.sendMessage(target, { msg_type: 2, msg_seq: 0, content: plain, markdown: { content: md } })
|
|
451
|
+
return true
|
|
452
|
+
}
|
|
453
|
+
catch (e) {
|
|
454
|
+
if (isKnownIgnoreError(e)) {
|
|
455
|
+
if (config.verbose) logger.debug(`[schedule] 主动消息受限 (code=${errCode(e)}),跳过 ${bot.sid}:${target}`)
|
|
456
|
+
return false
|
|
457
|
+
}
|
|
458
|
+
logger.warn(`[schedule] markdown 发送失败,回退纯文本 (${bot.sid}:${target}):`, e.message || e)
|
|
459
|
+
}
|
|
284
460
|
}
|
|
461
|
+
const parts = []
|
|
462
|
+
if (text) parts.push(text)
|
|
463
|
+
if (image) parts.push(h.image(image))
|
|
464
|
+
if (!parts.length) return false
|
|
465
|
+
await bot.sendMessage(target, parts)
|
|
466
|
+
return true
|
|
285
467
|
}
|
|
286
468
|
|
|
287
|
-
async function sendTask(name, task,
|
|
288
|
-
|
|
289
|
-
const
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
469
|
+
async function sendTask(name, task, opts = {}) {
|
|
470
|
+
const now = new Date()
|
|
471
|
+
const text = opts.text !== undefined ? opts.text : (task.text || '')
|
|
472
|
+
const markdown = opts.markdown !== undefined ? !!opts.markdown : !!task.markdown
|
|
473
|
+
const image = task.image
|
|
474
|
+
const rendered = renderText(text, {
|
|
475
|
+
date: todayStr(now),
|
|
476
|
+
time: nowHM(now),
|
|
477
|
+
week: '星期' + WEEK_CN[todayDay(now)],
|
|
478
|
+
task: name,
|
|
479
|
+
})
|
|
480
|
+
if (!rendered && !image) {
|
|
296
481
|
logger.warn(`[schedule] 任务 "${name}" 没有文字也没有图片,跳过`)
|
|
297
482
|
return
|
|
298
483
|
}
|
|
299
484
|
|
|
300
485
|
let bots = (ctx.bots || []).filter(b => b.online)
|
|
301
|
-
if (onlyBotSid) bots = bots.filter(b => b.sid === onlyBotSid)
|
|
486
|
+
if (opts.onlyBotSid) bots = bots.filter(b => b.sid === opts.onlyBotSid)
|
|
302
487
|
if (!bots.length) return
|
|
303
488
|
|
|
304
489
|
const groups = (task.groups && task.groups.length > 0) ? task.groups : null
|
|
305
490
|
const taskSubs = subs.get(name)
|
|
306
491
|
|
|
307
492
|
for (const bot of bots) {
|
|
308
|
-
// 该任务下、该机器人订阅的群(群内"订阅"命令注册的)
|
|
309
493
|
const subTargets = taskSubs
|
|
310
494
|
? [...taskSubs.values()].filter(s => s.botSid === bot.sid).map(s => s.guildId)
|
|
311
495
|
: []
|
|
@@ -314,13 +498,12 @@ function apply(ctx, config) {
|
|
|
314
498
|
for (const rawTarget of groups || []) {
|
|
315
499
|
const target = String(rawTarget).trim()
|
|
316
500
|
if (!target) continue
|
|
317
|
-
// 官机:群 ID 是 openid;数字群号在官机上无法直接使用
|
|
318
501
|
if (bot.platform === 'qq' && /^\d+$/.test(target)) {
|
|
319
502
|
logger.warn(`[schedule] 任务 "${name}":官方QQ机器人不能用数字群号 "${target}" 发送,请用群内"订阅"命令或 schedule.groups 查看 openid`)
|
|
320
503
|
continue
|
|
321
504
|
}
|
|
322
505
|
try {
|
|
323
|
-
await bot
|
|
506
|
+
await sendOne(bot, target, rendered, { markdown, image })
|
|
324
507
|
if (config.verbose) logger.info(`[schedule] 已发送任务 "${name}" 到 ${bot.sid}:${target}`)
|
|
325
508
|
}
|
|
326
509
|
catch (e) {
|
|
@@ -329,7 +512,7 @@ function apply(ctx, config) {
|
|
|
329
512
|
}
|
|
330
513
|
for (const target of subTargets) {
|
|
331
514
|
try {
|
|
332
|
-
await bot
|
|
515
|
+
await sendOne(bot, target, rendered, { markdown, image })
|
|
333
516
|
if (config.verbose) logger.info(`[schedule] 已发送任务 "${name}" 到订阅群 ${bot.sid}:${target}`)
|
|
334
517
|
}
|
|
335
518
|
catch (e) {
|
|
@@ -338,7 +521,6 @@ function apply(ctx, config) {
|
|
|
338
521
|
}
|
|
339
522
|
}
|
|
340
523
|
else if (config.sendAllWhenEmpty) {
|
|
341
|
-
// 未指定群也没有订阅 → 发送给该 bot 所有已知群
|
|
342
524
|
const targets = [...knownGroups.values()].filter(g => g.botSid === bot.sid)
|
|
343
525
|
if (!targets.length) {
|
|
344
526
|
logger.warn(`[schedule] 任务 "${name}" 未指定群且机器人 ${bot.sid} 暂无已知群(先让机器人收过该群消息,或用 schedule.groups 查看)`)
|
|
@@ -346,7 +528,7 @@ function apply(ctx, config) {
|
|
|
346
528
|
}
|
|
347
529
|
for (const g of targets) {
|
|
348
530
|
try {
|
|
349
|
-
await bot
|
|
531
|
+
await sendOne(bot, g.guildId, rendered, { markdown, image })
|
|
350
532
|
if (config.verbose) logger.info(`[schedule] 已发送任务 "${name}" 到 ${bot.sid}:${g.guildId}`)
|
|
351
533
|
}
|
|
352
534
|
catch (e) {
|
|
@@ -360,7 +542,27 @@ function apply(ctx, config) {
|
|
|
360
542
|
}
|
|
361
543
|
}
|
|
362
544
|
|
|
363
|
-
|
|
545
|
+
async function checkAndSend() {
|
|
546
|
+
const now = new Date()
|
|
547
|
+
const today = todayStr(now)
|
|
548
|
+
if (firedToday.date !== today) {
|
|
549
|
+
firedToday = { date: today }
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
const hm = nowHM(now)
|
|
553
|
+
const fires = computeFires(config.tasks, now, hm)
|
|
554
|
+
|
|
555
|
+
for (const f of fires) {
|
|
556
|
+
if (firedToday[f.key]) continue
|
|
557
|
+
firedToday[f.key] = true
|
|
558
|
+
|
|
559
|
+
logger.info(`[schedule] 触发任务 "${f.name}" @ ${hm}`)
|
|
560
|
+
const task = (config.tasks || {})[f.name]
|
|
561
|
+
if (!task) continue
|
|
562
|
+
await sendTask(f.name, task, { text: f.text, markdown: f.markdown })
|
|
563
|
+
}
|
|
564
|
+
}
|
|
565
|
+
|
|
364
566
|
const timer = setInterval(checkAndSend, 20000)
|
|
365
567
|
|
|
366
568
|
ctx.on('dispose', () => clearInterval(timer))
|
|
@@ -381,24 +583,27 @@ function apply(ctx, config) {
|
|
|
381
583
|
function formatTaskList() {
|
|
382
584
|
const tasks = config.tasks || {}
|
|
383
585
|
const keys = Object.keys(tasks)
|
|
384
|
-
if (!keys.length) return '暂无定时任务\n请在插件设置页的 tasks
|
|
586
|
+
if (!keys.length) return '暂无定时任务\n请在插件设置页的 tasks 表格中添加任务'
|
|
385
587
|
return keys.map(k => {
|
|
386
588
|
const t = tasks[k]
|
|
387
589
|
const s = t.enabled === false ? '⛔' : '✅'
|
|
388
|
-
const content = [t.text && '文字'].filter(Boolean).join('+') || '(空)'
|
|
389
590
|
const staticGroups = (t.groups || []).join(',')
|
|
390
591
|
const subCount = (subs.get(k) || new Map()).size
|
|
391
592
|
const target = staticGroups ? staticGroups : (subCount ? `订阅${subCount}群` : '所有已知群')
|
|
392
|
-
const
|
|
393
|
-
const
|
|
394
|
-
|
|
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')
|
|
395
600
|
}).join('\n')
|
|
396
601
|
}
|
|
397
602
|
|
|
398
603
|
ctx.command('schedule', '定时发送管理')
|
|
399
604
|
.action(() => {
|
|
400
605
|
logger.info('[schedule] 命令被调用: schedule')
|
|
401
|
-
return '⚙️ 管理:插件设置页 → ll-schedule → tasks
|
|
606
|
+
return '⚙️ 管理:插件设置页 → ll-schedule → tasks 添加任务和时间段(星期/时间/内容)\n' +
|
|
402
607
|
`群内发"${config.subscribeKeyword} 任务名"即可让本群订阅推送\n` +
|
|
403
608
|
`命令:${config.subscribeKeyword} | ${config.unsubscribeKeyword} | 订阅列表 | schedule.list | schedule.groups | schedule.test <任务名>`
|
|
404
609
|
})
|
|
@@ -421,19 +626,20 @@ function apply(ctx, config) {
|
|
|
421
626
|
const tasks = config.tasks || {}
|
|
422
627
|
const task = tasks[name]
|
|
423
628
|
if (!task) return `未找到任务 "${name}",可用 schedule.list 查看任务名`
|
|
424
|
-
// 测试时:任务没配群也没订阅就发到当前群
|
|
425
629
|
const testTask = { ...task }
|
|
426
630
|
if (!testTask.groups || !testTask.groups.length) {
|
|
427
631
|
testTask.groups = session.guildId ? [String(session.guildId)] : []
|
|
428
632
|
}
|
|
429
|
-
|
|
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 })
|
|
430
639
|
return `✅ 已发送任务 "${name}"`
|
|
431
640
|
})
|
|
432
641
|
|
|
433
642
|
// ===== 群内订阅命令 =====
|
|
434
|
-
// 判断发送者是否群主/群管理:
|
|
435
|
-
// 1) 优先用消息事件自带的成员角色(官机/OneBot 群消息都会带,零 API 调用,不受接口权限限制)
|
|
436
|
-
// 2) 兜底调 getGuildMember(需要接口权限,拿不到就忽略)
|
|
437
643
|
async function isGroupAdmin(session) {
|
|
438
644
|
if (!session.guildId || !session.userId) return false
|
|
439
645
|
const role = session.author?.roles?.[0]?.id || session.event?.member?.roles?.[0]?.id
|
|
@@ -460,7 +666,6 @@ function apply(ctx, config) {
|
|
|
460
666
|
(config.subscribeAdminBypass ? ',或为本群群主/群管理' : '')
|
|
461
667
|
}
|
|
462
668
|
|
|
463
|
-
// 解析订阅命令的任务名:明确指定 → 用它;不填且只有一个启用任务 → 用它;否则返回提示
|
|
464
669
|
function resolveSubscribeTaskName(taskName) {
|
|
465
670
|
const tasks = config.tasks || {}
|
|
466
671
|
if (taskName) {
|
|
@@ -486,8 +691,8 @@ function apply(ctx, config) {
|
|
|
486
691
|
taskSubs.set(key, entry)
|
|
487
692
|
persistSub(name, entry)
|
|
488
693
|
logger.info(`[schedule] 群 ${session.guildId} 已订阅任务 "${name}"`)
|
|
489
|
-
const
|
|
490
|
-
return `✅ 本群已加入任务 "${name}" 的推送列表\n
|
|
694
|
+
const rows = parseScheduleRows(task)
|
|
695
|
+
return `✅ 本群已加入任务 "${name}" 的推送列表\n时间段:${rows.length} 个(schedule.list 查看详情)`
|
|
491
696
|
}
|
|
492
697
|
|
|
493
698
|
async function unsubscribeCurrentGroup(session, taskName) {
|
|
@@ -567,11 +772,7 @@ function apply(ctx, config) {
|
|
|
567
772
|
logger.info('[schedule] 命令被调用: 我的ID')
|
|
568
773
|
const role = session.author?.roles?.[0]?.id || session.event?.member?.roles?.[0]?.id
|
|
569
774
|
const roleName = role === 'owner' ? '群主' : role === 'admin' ? '群管理' : role === 'member' ? '普通成员' : (role || '未知')
|
|
570
|
-
const lines = [
|
|
571
|
-
'👤 我的账号信息:',
|
|
572
|
-
`平台:${session.platform}`,
|
|
573
|
-
`用户ID:${session.userId}`,
|
|
574
|
-
]
|
|
775
|
+
const lines = ['👤 我的账号信息:', `平台:${session.platform}`, `用户ID:${session.userId}`]
|
|
575
776
|
if (session.guildId) lines.push(`当前群ID:${session.guildId}`)
|
|
576
777
|
if (session.guildId) lines.push(`本群角色:${roleName}`)
|
|
577
778
|
if (session.channelId && session.channelId !== session.guildId) lines.push(`频道ID:${session.channelId}`)
|
|
@@ -583,18 +784,13 @@ function apply(ctx, config) {
|
|
|
583
784
|
logger.info('[schedule] 命令被调用: lls.me')
|
|
584
785
|
const role = session.author?.roles?.[0]?.id || session.event?.member?.roles?.[0]?.id
|
|
585
786
|
const roleName = role === 'owner' ? '群主' : role === 'admin' ? '群管理' : role === 'member' ? '普通成员' : (role || '未知')
|
|
586
|
-
const lines = [
|
|
587
|
-
'👤 我的账号信息:',
|
|
588
|
-
`平台:${session.platform}`,
|
|
589
|
-
`用户ID:${session.userId}`,
|
|
590
|
-
]
|
|
787
|
+
const lines = ['👤 我的账号信息:', `平台:${session.platform}`, `用户ID:${session.userId}`]
|
|
591
788
|
if (session.guildId) lines.push(`当前群ID:${session.guildId}`)
|
|
592
789
|
if (session.guildId) lines.push(`本群角色:${roleName}`)
|
|
593
790
|
if (session.channelId && session.channelId !== session.guildId) lines.push(`频道ID:${session.channelId}`)
|
|
594
791
|
return lines.join('\n')
|
|
595
792
|
})
|
|
596
793
|
|
|
597
|
-
// 备用命令入口(避免与其他插件的 schedule 命令冲突)
|
|
598
794
|
ctx.command('lls', 'll-schedule 管理(备用入口)')
|
|
599
795
|
.action(() => {
|
|
600
796
|
logger.info('[schedule] 命令被调用: lls')
|
|
@@ -623,14 +819,17 @@ function apply(ctx, config) {
|
|
|
623
819
|
if (!testTask.groups || !testTask.groups.length) {
|
|
624
820
|
testTask.groups = session.guildId ? [String(session.guildId)] : []
|
|
625
821
|
}
|
|
626
|
-
|
|
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 })
|
|
627
827
|
return `✅ 已发送任务 "${name}"`
|
|
628
828
|
})
|
|
629
829
|
|
|
630
830
|
logger.info('[schedule] 命令已注册: schedule / schedule.list / schedule.groups / schedule.test / ' +
|
|
631
831
|
`"${config.subscribeKeyword}" / "${config.unsubscribeKeyword}" / 订阅列表 / lls.*`)
|
|
632
832
|
|
|
633
|
-
// 启动时:加载已知群 + 订阅 + 立即检查一次
|
|
634
833
|
Promise.all([loadKnownGroups(), loadSubs()]).then(() => checkAndSend())
|
|
635
834
|
}
|
|
636
835
|
|
package/package.json
CHANGED
|
@@ -1,20 +1,21 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "koishi-plugin-ll-schedule",
|
|
3
|
-
"version": "1.0
|
|
4
|
-
"description": "Scheduled message sender with
|
|
3
|
+
"version": "1.2.0",
|
|
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": [
|
|
7
7
|
"index.js"
|
|
8
8
|
],
|
|
9
9
|
"license": "MIT",
|
|
10
|
-
"keywords": ["koishi", "koishi-plugin", "schedule", "timer", "定时"],
|
|
10
|
+
"keywords": ["koishi", "koishi-plugin", "schedule", "timer", "定时", "markdown", "排班"],
|
|
11
11
|
"peerDependencies": {
|
|
12
12
|
"koishi": "^4.18.0"
|
|
13
13
|
},
|
|
14
14
|
"koishi": {
|
|
15
15
|
"description": {
|
|
16
|
-
"zh": "
|
|
17
|
-
"en": "Scheduled message sender. Auto-send text/image to groups at set times, works with official QQ bot."
|
|
16
|
+
"zh": "定时发送插件:设置时间自动发送文字/图片/QQ官方Markdown卡片到指定群,支持排班表(按星期设置不同时间与内容),兼容官方QQ机器人(官机)。",
|
|
17
|
+
"en": "Scheduled message sender. Auto-send text/image/markdown card to groups at set times, with weekly slots for per-day schedule, works with official QQ bot."
|
|
18
18
|
}
|
|
19
19
|
}
|
|
20
20
|
}
|
|
21
|
+
|