koishi-plugin-ll-schedule 1.0.8 → 1.1.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 +387 -63
- package/package.json +5 -5
package/index.js
CHANGED
|
@@ -3,12 +3,13 @@
|
|
|
3
3
|
// koishi-plugin-ll-schedule
|
|
4
4
|
// 定时发送插件:设置时间自动向指定群发送文字
|
|
5
5
|
// v1.0.6:一个任务支持多个时间(逗号分隔),群内"订阅"关键词自动注册推送群
|
|
6
|
+
// v1.1.0:新增"排班表 slots"(按星期设置不同时间/内容)+ QQ官方Markdown卡片 + 模板变量
|
|
6
7
|
|
|
7
8
|
const { Schema, h } = require('koishi')
|
|
8
9
|
|
|
9
10
|
exports.name = 'll-schedule'
|
|
10
11
|
|
|
11
|
-
exports.usage =
|
|
12
|
+
exports.usage = `定时发送插件:设置时间自动向指定群发送文字/卡片。
|
|
12
13
|
|
|
13
14
|
## 快速上手(推荐用法)
|
|
14
15
|
|
|
@@ -16,10 +17,55 @@ exports.usage = `定时发送插件:设置时间自动向指定群发送文字
|
|
|
16
17
|
2. 在任意群里 @机器人 发:\`订阅 任务名\` → 这个群就加入了该任务的推送列表(自动记录,重启不丢)
|
|
17
18
|
3. 到点后插件自动把内容推送到所有订阅了该任务的群
|
|
18
19
|
|
|
20
|
+
## 排班表 slots(按星期设置不同的时间/内容)
|
|
21
|
+
|
|
22
|
+
每个任务除了简单的 \`time\` + \`text\`,还可以填一张**排班表 \`slots\`**。每行 = 星期 + 时间 + 内容,互不影响:
|
|
23
|
+
|
|
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 卡片**发送(仅官机生效;失败自动回退纯文本,其他平台始终纯文本)。支持标题、加粗、引用、链接、图片等:
|
|
46
|
+
|
|
47
|
+
\`\`\`markdown
|
|
48
|
+
# 🎉 每日公告
|
|
49
|
+
|
|
50
|
+
> 今天是 **{date}**({week}),现在是 {time}
|
|
51
|
+
|
|
52
|
+
[官网点此访问](https://example.com)
|
|
53
|
+

|
|
54
|
+
\`\`\`
|
|
55
|
+
|
|
56
|
+
\`text\` 中可用模板变量(发送时自动替换):
|
|
57
|
+
- \`{date}\`:今天日期(如 2026-08-23)
|
|
58
|
+
- \`{time}\`:当前时间(如 12:20)
|
|
59
|
+
- \`{week}\`:今天星期(如 星期三)
|
|
60
|
+
- \`{task}\`:任务名
|
|
61
|
+
|
|
19
62
|
## 任务配置
|
|
20
63
|
|
|
21
64
|
- \`time\`:发送时间(24小时制 HH:MM),**可填多个,用逗号分隔**,如 \`09:00, 21:30\`(一条内容会在每个时间点各发一次)
|
|
22
|
-
- \`text
|
|
65
|
+
- \`text\`:推送内容(文字,可留空;支持 Markdown 语法和模板变量)
|
|
66
|
+
- \`image\`:图片 URL(可选,随文本一起发送;markdown 模式自动嵌入为图片)
|
|
67
|
+
- \`markdown\`:是否用 QQ官方 Markdown 卡片发送(默认关闭)
|
|
68
|
+
- \`slots\`:排班表(可选,见上方说明;填了就以排班表为准)
|
|
23
69
|
- \`groups\`:手动指定的目标群列表(**推荐留空**,用群内"订阅"命令添加更省事;留空且无订阅 = 发给所有已知群)
|
|
24
70
|
- \`enabled\`:是否启用
|
|
25
71
|
|
|
@@ -28,30 +74,184 @@ exports.usage = `定时发送插件:设置时间自动向指定群发送文字
|
|
|
28
74
|
- \`订阅 [任务名]\`:当前群订阅指定任务的推送(不填任务名时,只有一个启用任务则自动选它)
|
|
29
75
|
- \`取消订阅 [任务名]\`:当前群取消订阅
|
|
30
76
|
- \`订阅列表\`:查看所有任务的订阅群
|
|
31
|
-
- \`schedule.list
|
|
77
|
+
- \`schedule.list\`:查看任务列表(含排班表详情)
|
|
32
78
|
- \`schedule.test <任务名>\`:立即测试发送
|
|
33
79
|
- \`lls.*\`:以上命令的备用入口(lls.sub / lls.unsub / lls.sublist / lls.list / lls.test)
|
|
34
80
|
|
|
35
|
-
订阅/取消订阅所需权限等级在设置页 \`subscribeAuthority\` 调整(默认 1
|
|
81
|
+
订阅/取消订阅所需权限等级在设置页 \`subscribeAuthority\` 调整(默认 1=所有人都能订阅)。\`subscribeAdminBypass\`(默认开启):群主/群管理可无视权限等级直接订阅——插件从群消息自带的成员角色判断(\`owner\`/\`admin\`),**不需要申请接口权限**。想验证自己角色可发 \`我的ID\`。
|
|
36
82
|
|
|
37
83
|
## 官机(官方QQ机器人)说明
|
|
38
84
|
|
|
39
85
|
官方平台没有"群号",只有 openid——用群内"订阅"命令即可,插件会自动记录当前群的 openid,无需手动填写。手动填 \`groups\` 时可用 \`schedule.groups\` 查看已知群的 openid。官机主动发群消息有平台频率限制,且机器人需在群里。
|
|
40
86
|
`
|
|
41
87
|
|
|
88
|
+
// ===== 纯函数(可单测) =====
|
|
89
|
+
|
|
90
|
+
// 解析时间:支持多个时间,逗号/顿号/空格分隔,如 "09:00, 21:30"
|
|
91
|
+
function parseTimes(timeStr) {
|
|
92
|
+
if (!timeStr) return []
|
|
93
|
+
return String(timeStr).split(/[,,、;;\s]+/)
|
|
94
|
+
.map(s => s.trim())
|
|
95
|
+
.filter(s => /^\d{1,2}:\d{2}$/.test(s))
|
|
96
|
+
.map(s => (s.length === 4 ? '0' + s : s))
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// 今天星期几(1=周一 ... 7=周日)
|
|
100
|
+
function todayDay(date) {
|
|
101
|
+
const d = date || new Date()
|
|
102
|
+
const day = d.getDay()
|
|
103
|
+
return day === 0 ? 7 : day
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const WEEK_CN = ['', '一', '二', '三', '四', '五', '六', '日']
|
|
107
|
+
|
|
108
|
+
// 解析星期配置:返回 Set(1..7) 或 null(null = 每天)
|
|
109
|
+
// 支持:留空/每天/每日、1-7 数字逗号分隔、范围 1-5、中文 一,三,五 / 星期X / 周X、工作日、周末
|
|
110
|
+
function parseDays(daysStr) {
|
|
111
|
+
if (daysStr === undefined || daysStr === null) return null
|
|
112
|
+
const s = String(daysStr).trim()
|
|
113
|
+
if (!s) return null
|
|
114
|
+
const alias = { '每天': null, '每日': null, '天天': null, '工作日': new Set([1, 2, 3, 4, 5]), '周末': new Set([6, 7]), '休息日': new Set([6, 7]) }
|
|
115
|
+
if (Object.prototype.hasOwnProperty.call(alias, s)) return alias[s]
|
|
116
|
+
const set = new Set()
|
|
117
|
+
const cnMap = { '一': 1, '二': 2, '三': 3, '四': 4, '五': 5, '六': 6, '日': 7, '天': 7 }
|
|
118
|
+
for (const part of s.split(/[,,、;;\s]+/)) {
|
|
119
|
+
const p = part.trim()
|
|
120
|
+
if (!p) continue
|
|
121
|
+
const range = p.match(/^(\d{1,2})\s*[-~]\s*(\d{1,2})$/)
|
|
122
|
+
if (range) {
|
|
123
|
+
for (let i = parseInt(range[1], 10); i <= parseInt(range[2], 10); i++) {
|
|
124
|
+
if (i >= 1 && i <= 7) set.add(i)
|
|
125
|
+
}
|
|
126
|
+
continue
|
|
127
|
+
}
|
|
128
|
+
const cn = p.match(/^(?:星期|周|礼拜)?([一二三四五六日天])$/)
|
|
129
|
+
if (cn) {
|
|
130
|
+
set.add(cnMap[cn[1]])
|
|
131
|
+
continue
|
|
132
|
+
}
|
|
133
|
+
const num = p.match(/^([1-7])$/)
|
|
134
|
+
if (num) {
|
|
135
|
+
set.add(parseInt(num[1], 10))
|
|
136
|
+
continue
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
return set.size ? set : null
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// 某天是否命中排班(null=每天 恒命中)
|
|
143
|
+
function dayMatches(daysSet, day) {
|
|
144
|
+
return !daysSet || daysSet.has(day)
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// 计算"现在这一刻该发什么":返回 [{ name, key, text, markdown, image }]
|
|
148
|
+
// date 为 Date 对象、hm 为 "HH:MM" 字符串;纯函数便于测试
|
|
149
|
+
function computeFires(tasks, date, hm) {
|
|
150
|
+
const day = todayDay(date)
|
|
151
|
+
const fires = []
|
|
152
|
+
for (const [name, task] of Object.entries(tasks || {})) {
|
|
153
|
+
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)
|
|
171
|
+
if (!timeList.includes(hm)) continue
|
|
172
|
+
fires.push({
|
|
173
|
+
name,
|
|
174
|
+
key: `${name}@${hm}`,
|
|
175
|
+
text: task.text || '',
|
|
176
|
+
markdown: !!task.markdown,
|
|
177
|
+
image: task.image,
|
|
178
|
+
})
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
return fires
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// 模板变量渲染:{date} {time} {week} {task}
|
|
185
|
+
function renderText(text, ctx) {
|
|
186
|
+
return String(text || '')
|
|
187
|
+
.replace(/\{date\}/g, ctx.date)
|
|
188
|
+
.replace(/\{time\}/g, ctx.time)
|
|
189
|
+
.replace(/\{week\}/g, ctx.week)
|
|
190
|
+
.replace(/\{task\}/g, ctx.task)
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// 组装 markdown:文本 + 图片
|
|
194
|
+
function buildMd(text, image) {
|
|
195
|
+
let md = String(text || '').trim()
|
|
196
|
+
if (image) md += (md ? '\n\n' : '') + ``
|
|
197
|
+
return md
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// markdown 转纯文本(发送时兜底)
|
|
201
|
+
function buildTextFromMd(md) {
|
|
202
|
+
return String(md || '')
|
|
203
|
+
.replace(/<qqbot-at-user[^>]*\/>/g, '')
|
|
204
|
+
.replace(/^#{1,6}\s*/gm, '')
|
|
205
|
+
.replace(/\*\*([^*]+)\*\*/g, '$1')
|
|
206
|
+
.replace(/\*([^*]+)\*/g, '$1')
|
|
207
|
+
.replace(/`([^`]+)`/g, '$1')
|
|
208
|
+
.replace(/!\[([^\]]*)\]\(([^)]+)\)/g, '[$1]($2)')
|
|
209
|
+
.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '$1: $2')
|
|
210
|
+
.replace(/^>\s?/gm, '')
|
|
211
|
+
.replace(/\n{3,}/g, '\n\n')
|
|
212
|
+
.trim()
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
exports._test = { parseTimes, todayDay, parseDays, dayMatches, computeFires, renderText, buildMd, buildTextFromMd }
|
|
216
|
+
|
|
217
|
+
// ===== 配置 Schema =====
|
|
218
|
+
|
|
219
|
+
const TimeSlot = Schema.object({
|
|
220
|
+
time: Schema.string()
|
|
221
|
+
.required()
|
|
222
|
+
.description('发送时间(24小时制 HH:MM),可多个用逗号分隔,如 09:00, 21:30'),
|
|
223
|
+
days: Schema.string()
|
|
224
|
+
.default('')
|
|
225
|
+
.description('星期几生效:留空=每天;1-7(1=周一)逗号分隔如 1,3,5;快捷词"工作日"(周一~五)/"周末"(周六日);中文"一,三,五"'),
|
|
226
|
+
text: Schema.string()
|
|
227
|
+
.default('')
|
|
228
|
+
.description('该时段发送的内容(文字,可留空;支持 Markdown 语法和模板变量 {date} {time} {week} {task})'),
|
|
229
|
+
markdown: Schema.boolean()
|
|
230
|
+
.description('用 QQ官方 Markdown 卡片发送(留空则跟随任务级 markdown 设置)'),
|
|
231
|
+
})
|
|
232
|
+
|
|
42
233
|
const TaskItem = Schema.object({
|
|
43
234
|
time: Schema.string()
|
|
44
235
|
.required()
|
|
45
|
-
.description('发送时间(24小时制 HH:MM),可填多个用逗号分隔,如 09:00, 21:30'),
|
|
236
|
+
.description('发送时间(24小时制 HH:MM),可填多个用逗号分隔,如 09:00, 21:30;填了下方 slots 排班表时此项自动忽略'),
|
|
46
237
|
groups: Schema.array(Schema.string())
|
|
47
238
|
.default([])
|
|
48
239
|
.description('手动指定的目标群 ID(可留空;推荐用群内"订阅"命令自动添加,无需手动填 openid)'),
|
|
49
240
|
text: Schema.string()
|
|
50
241
|
.default('')
|
|
51
|
-
.description('
|
|
242
|
+
.description('推送内容(文字,可留空;支持 Markdown 语法和模板变量 {date} {time} {week} {task})'),
|
|
243
|
+
image: Schema.string()
|
|
244
|
+
.default('')
|
|
245
|
+
.description('图片 URL(可选):随文本一起发送(markdown 模式自动嵌入为图片)'),
|
|
246
|
+
markdown: Schema.boolean()
|
|
247
|
+
.default(false)
|
|
248
|
+
.description('用 QQ官方 Markdown 卡片发送(仅官方QQ机器人生效,失败自动回退纯文本;其他平台始终纯文本)'),
|
|
52
249
|
enabled: Schema.boolean()
|
|
53
250
|
.default(true)
|
|
54
251
|
.description('是否启用'),
|
|
252
|
+
slots: Schema.array(TimeSlot)
|
|
253
|
+
.default([])
|
|
254
|
+
.description('📅 排班表(可选):每行=星期+时间+内容,按星期设置不同时段/内容;一旦填写就完全以排班表为准,上方 time/text 自动忽略'),
|
|
55
255
|
})
|
|
56
256
|
|
|
57
257
|
const Config = Schema.object({
|
|
@@ -67,6 +267,9 @@ const Config = Schema.object({
|
|
|
67
267
|
subscribeAuthority: Schema.number()
|
|
68
268
|
.min(1).max(4).default(1)
|
|
69
269
|
.description('执行订阅命令所需的最低权限等级(Koishi 用户权限等级,与QQ群主/管理员身份无关;默认 1=所有人都能订阅。如需限制,先在控制台"用户管理"给指定账号提权,再调高此值)'),
|
|
270
|
+
subscribeAdminBypass: Schema.boolean()
|
|
271
|
+
.default(true)
|
|
272
|
+
.description('群主/群管理可无视上面的权限等级直接订阅(从消息自带的成员角色判断,无需申请接口权限)'),
|
|
70
273
|
sendAllWhenEmpty: Schema.boolean()
|
|
71
274
|
.default(true)
|
|
72
275
|
.description('任务未指定群也没有群订阅时,发送给该机器人所有已知群'),
|
|
@@ -238,64 +441,74 @@ function apply(ctx, config) {
|
|
|
238
441
|
learnGroup(session)
|
|
239
442
|
})
|
|
240
443
|
|
|
241
|
-
function nowHM() {
|
|
242
|
-
const d = new Date()
|
|
444
|
+
function nowHM(date) {
|
|
445
|
+
const d = date || new Date()
|
|
243
446
|
return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`
|
|
244
447
|
}
|
|
245
448
|
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
if (!timeStr) return []
|
|
249
|
-
return String(timeStr).split(/[,,、;;\s]+/)
|
|
250
|
-
.map(s => s.trim())
|
|
251
|
-
.filter(s => /^\d{1,2}:\d{2}$/.test(s))
|
|
252
|
-
.map(s => (s.length === 4 ? '0' + s : s))
|
|
253
|
-
}
|
|
254
|
-
|
|
255
|
-
function todayStr() {
|
|
256
|
-
const d = new Date()
|
|
449
|
+
function todayStr(date) {
|
|
450
|
+
const d = date || new Date()
|
|
257
451
|
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
|
|
258
452
|
}
|
|
259
453
|
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
if (firedToday.date !== today) {
|
|
264
|
-
firedToday = { date: today }
|
|
265
|
-
}
|
|
454
|
+
// ===== 发送 =====
|
|
455
|
+
// 官机 markdown 发送时忽略的已知错误码(主动消息受限等)
|
|
456
|
+
const KNOWN_IGNORE_CODES = new Set([11293, 40034101, 40034105, 304101, 304102])
|
|
266
457
|
|
|
267
|
-
|
|
268
|
-
const
|
|
458
|
+
function errCode(e) {
|
|
459
|
+
const d = e && e.response && e.response.data
|
|
460
|
+
if (!d) return undefined
|
|
461
|
+
return d.code !== undefined ? d.code : d.err_code
|
|
462
|
+
}
|
|
269
463
|
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
// 今天这个时间点已经发过这个任务 → 跳过
|
|
275
|
-
const fireKey = `${name}@${hm}`
|
|
276
|
-
if (firedToday[fireKey]) continue
|
|
277
|
-
firedToday[fireKey] = true
|
|
464
|
+
function isKnownIgnoreError(e) {
|
|
465
|
+
const code = errCode(e)
|
|
466
|
+
return typeof code === 'number' && KNOWN_IGNORE_CODES.has(code)
|
|
467
|
+
}
|
|
278
468
|
|
|
279
|
-
|
|
280
|
-
|
|
469
|
+
// 向一个目标发送一段内容;markdown 模式仅官机生效,失败自动回退纯文本
|
|
470
|
+
async function sendOne(bot, target, text, { markdown, image }) {
|
|
471
|
+
if (markdown && bot.platform === 'qq' && bot.internal && typeof bot.internal.sendMessage === 'function') {
|
|
472
|
+
const md = buildMd(text, image)
|
|
473
|
+
const plain = buildTextFromMd(md) || ' '
|
|
474
|
+
try {
|
|
475
|
+
await bot.internal.sendMessage(target, { msg_type: 2, msg_seq: 0, content: plain, markdown: { content: md } })
|
|
476
|
+
return true
|
|
477
|
+
}
|
|
478
|
+
catch (e) {
|
|
479
|
+
if (isKnownIgnoreError(e)) {
|
|
480
|
+
if (config.verbose) logger.debug(`[schedule] 主动消息受限 (code=${errCode(e)}),跳过 ${bot.sid}:${target}`)
|
|
481
|
+
return false
|
|
482
|
+
}
|
|
483
|
+
logger.warn(`[schedule] markdown 发送失败,回退纯文本 (${bot.sid}:${target}):`, e.message || e)
|
|
484
|
+
}
|
|
281
485
|
}
|
|
486
|
+
const parts = []
|
|
487
|
+
if (text) parts.push(text)
|
|
488
|
+
if (image) parts.push(h.image(image))
|
|
489
|
+
if (!parts.length) return false
|
|
490
|
+
await bot.sendMessage(target, parts)
|
|
491
|
+
return true
|
|
282
492
|
}
|
|
283
493
|
|
|
284
|
-
async function sendTask(name, task,
|
|
285
|
-
|
|
286
|
-
const
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
494
|
+
async function sendTask(name, task, opts = {}) {
|
|
495
|
+
const now = new Date()
|
|
496
|
+
const text = opts.text !== undefined ? opts.text : (task.text || '')
|
|
497
|
+
const markdown = opts.markdown !== undefined ? !!opts.markdown : !!task.markdown
|
|
498
|
+
const image = task.image
|
|
499
|
+
const rendered = renderText(text, {
|
|
500
|
+
date: todayStr(now),
|
|
501
|
+
time: nowHM(now),
|
|
502
|
+
week: '星期' + WEEK_CN[todayDay(now)],
|
|
503
|
+
task: name,
|
|
504
|
+
})
|
|
505
|
+
if (!rendered && !image) {
|
|
293
506
|
logger.warn(`[schedule] 任务 "${name}" 没有文字也没有图片,跳过`)
|
|
294
507
|
return
|
|
295
508
|
}
|
|
296
509
|
|
|
297
510
|
let bots = (ctx.bots || []).filter(b => b.online)
|
|
298
|
-
if (onlyBotSid) bots = bots.filter(b => b.sid === onlyBotSid)
|
|
511
|
+
if (opts.onlyBotSid) bots = bots.filter(b => b.sid === opts.onlyBotSid)
|
|
299
512
|
if (!bots.length) return
|
|
300
513
|
|
|
301
514
|
const groups = (task.groups && task.groups.length > 0) ? task.groups : null
|
|
@@ -317,7 +530,7 @@ function apply(ctx, config) {
|
|
|
317
530
|
continue
|
|
318
531
|
}
|
|
319
532
|
try {
|
|
320
|
-
await bot
|
|
533
|
+
await sendOne(bot, target, rendered, { markdown, image })
|
|
321
534
|
if (config.verbose) logger.info(`[schedule] 已发送任务 "${name}" 到 ${bot.sid}:${target}`)
|
|
322
535
|
}
|
|
323
536
|
catch (e) {
|
|
@@ -326,7 +539,7 @@ function apply(ctx, config) {
|
|
|
326
539
|
}
|
|
327
540
|
for (const target of subTargets) {
|
|
328
541
|
try {
|
|
329
|
-
await bot
|
|
542
|
+
await sendOne(bot, target, rendered, { markdown, image })
|
|
330
543
|
if (config.verbose) logger.info(`[schedule] 已发送任务 "${name}" 到订阅群 ${bot.sid}:${target}`)
|
|
331
544
|
}
|
|
332
545
|
catch (e) {
|
|
@@ -343,7 +556,7 @@ function apply(ctx, config) {
|
|
|
343
556
|
}
|
|
344
557
|
for (const g of targets) {
|
|
345
558
|
try {
|
|
346
|
-
await bot
|
|
559
|
+
await sendOne(bot, g.guildId, rendered, { markdown, image })
|
|
347
560
|
if (config.verbose) logger.info(`[schedule] 已发送任务 "${name}" 到 ${bot.sid}:${g.guildId}`)
|
|
348
561
|
}
|
|
349
562
|
catch (e) {
|
|
@@ -357,6 +570,29 @@ function apply(ctx, config) {
|
|
|
357
570
|
}
|
|
358
571
|
}
|
|
359
572
|
|
|
573
|
+
async function checkAndSend() {
|
|
574
|
+
const now = new Date()
|
|
575
|
+
const today = todayStr(now)
|
|
576
|
+
// 跨天重置
|
|
577
|
+
if (firedToday.date !== today) {
|
|
578
|
+
firedToday = { date: today }
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
const hm = nowHM(now)
|
|
582
|
+
const fires = computeFires(config.tasks, now, hm)
|
|
583
|
+
|
|
584
|
+
for (const f of fires) {
|
|
585
|
+
// 今天这个时间点已经发过 → 跳过
|
|
586
|
+
if (firedToday[f.key]) continue
|
|
587
|
+
firedToday[f.key] = true
|
|
588
|
+
|
|
589
|
+
logger.info(`[schedule] 触发任务 "${f.name}" @ ${hm}`)
|
|
590
|
+
const task = (config.tasks || {})[f.name]
|
|
591
|
+
if (!task) continue
|
|
592
|
+
await sendTask(f.name, task, { text: f.text, markdown: f.markdown })
|
|
593
|
+
}
|
|
594
|
+
}
|
|
595
|
+
|
|
360
596
|
// 每 20 秒检查一次(保证同一分钟内能触发)
|
|
361
597
|
const timer = setInterval(checkAndSend, 20000)
|
|
362
598
|
|
|
@@ -382,13 +618,29 @@ function apply(ctx, config) {
|
|
|
382
618
|
return keys.map(k => {
|
|
383
619
|
const t = tasks[k]
|
|
384
620
|
const s = t.enabled === false ? '⛔' : '✅'
|
|
385
|
-
const content = [t.text && '文字'].filter(Boolean).join('+') || '(空)'
|
|
386
621
|
const staticGroups = (t.groups || []).join(',')
|
|
387
622
|
const subCount = (subs.get(k) || new Map()).size
|
|
388
623
|
const target = staticGroups ? staticGroups : (subCount ? `订阅${subCount}群` : '所有已知群')
|
|
389
|
-
const
|
|
390
|
-
|
|
391
|
-
|
|
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}群` : ''}`
|
|
392
644
|
}).join('\n')
|
|
393
645
|
}
|
|
394
646
|
|
|
@@ -423,11 +675,53 @@ function apply(ctx, config) {
|
|
|
423
675
|
if (!testTask.groups || !testTask.groups.length) {
|
|
424
676
|
testTask.groups = session.guildId ? [String(session.guildId)] : []
|
|
425
677
|
}
|
|
426
|
-
|
|
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 })
|
|
427
692
|
return `✅ 已发送任务 "${name}"`
|
|
428
693
|
})
|
|
429
694
|
|
|
430
695
|
// ===== 群内订阅命令 =====
|
|
696
|
+
// 判断发送者是否群主/群管理:
|
|
697
|
+
// 1) 优先用消息事件自带的成员角色(官机/OneBot 群消息都会带,零 API 调用,不受接口权限限制)
|
|
698
|
+
// 2) 兜底调 getGuildMember(需要接口权限,拿不到就忽略)
|
|
699
|
+
async function isGroupAdmin(session) {
|
|
700
|
+
if (!session.guildId || !session.userId) return false
|
|
701
|
+
const role = session.author?.roles?.[0]?.id || session.event?.member?.roles?.[0]?.id
|
|
702
|
+
if (role) return role === 'owner' || role === 'admin' || role === '1' || role === '2'
|
|
703
|
+
if (session.bot && typeof session.bot.getGuildMember === 'function') {
|
|
704
|
+
try {
|
|
705
|
+
const member = await session.bot.getGuildMember(session.guildId, session.userId)
|
|
706
|
+
const r = member?.roles?.[0]?.id
|
|
707
|
+
return r === 'owner' || r === 'admin' || r === '1' || r === '2'
|
|
708
|
+
}
|
|
709
|
+
catch (e) { /* 无接口权限等,忽略 */ }
|
|
710
|
+
}
|
|
711
|
+
return false
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
async function checkSubscribePerm(session) {
|
|
715
|
+
if (session.user?.authority >= config.subscribeAuthority) return true
|
|
716
|
+
if (config.subscribeAdminBypass) return await isGroupAdmin(session)
|
|
717
|
+
return false
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
function permDeniedHint() {
|
|
721
|
+
return '❌ 权限不足:需要 Koishi 用户权限等级 ≥ ' + config.subscribeAuthority +
|
|
722
|
+
(config.subscribeAdminBypass ? ',或为本群群主/群管理' : '')
|
|
723
|
+
}
|
|
724
|
+
|
|
431
725
|
// 解析订阅命令的任务名:明确指定 → 用它;不填且只有一个启用任务 → 用它;否则返回提示
|
|
432
726
|
function resolveSubscribeTaskName(taskName) {
|
|
433
727
|
const tasks = config.tasks || {}
|
|
@@ -454,8 +748,16 @@ function apply(ctx, config) {
|
|
|
454
748
|
taskSubs.set(key, entry)
|
|
455
749
|
persistSub(name, entry)
|
|
456
750
|
logger.info(`[schedule] 群 ${session.guildId} 已订阅任务 "${name}"`)
|
|
457
|
-
const
|
|
458
|
-
|
|
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 || '(空)'}`
|
|
459
761
|
}
|
|
460
762
|
|
|
461
763
|
async function unsubscribeCurrentGroup(session, taskName) {
|
|
@@ -472,23 +774,27 @@ function apply(ctx, config) {
|
|
|
472
774
|
return `✅ 本群已取消订阅任务 "${name}"`
|
|
473
775
|
}
|
|
474
776
|
|
|
475
|
-
ctx.command(config.subscribeKeyword + ' [taskName:text]', '让当前群订阅指定任务的推送'
|
|
777
|
+
ctx.command(config.subscribeKeyword + ' [taskName:text]', '让当前群订阅指定任务的推送')
|
|
476
778
|
.action(async ({ session }, taskName) => {
|
|
779
|
+
if (!(await checkSubscribePerm(session))) return permDeniedHint()
|
|
477
780
|
return await subscribeCurrentGroup(session, taskName)
|
|
478
781
|
})
|
|
479
782
|
|
|
480
|
-
ctx.command('lls.sub [taskName:text]', '订阅推送(备用入口)'
|
|
783
|
+
ctx.command('lls.sub [taskName:text]', '订阅推送(备用入口)')
|
|
481
784
|
.action(async ({ session }, taskName) => {
|
|
785
|
+
if (!(await checkSubscribePerm(session))) return permDeniedHint()
|
|
482
786
|
return await subscribeCurrentGroup(session, taskName)
|
|
483
787
|
})
|
|
484
788
|
|
|
485
|
-
ctx.command(config.unsubscribeKeyword + ' [taskName:text]', '让当前群取消订阅指定任务的推送'
|
|
789
|
+
ctx.command(config.unsubscribeKeyword + ' [taskName:text]', '让当前群取消订阅指定任务的推送')
|
|
486
790
|
.action(async ({ session }, taskName) => {
|
|
791
|
+
if (!(await checkSubscribePerm(session))) return permDeniedHint()
|
|
487
792
|
return await unsubscribeCurrentGroup(session, taskName)
|
|
488
793
|
})
|
|
489
794
|
|
|
490
|
-
ctx.command('lls.unsub [taskName:text]', '取消订阅(备用入口)'
|
|
795
|
+
ctx.command('lls.unsub [taskName:text]', '取消订阅(备用入口)')
|
|
491
796
|
.action(async ({ session }, taskName) => {
|
|
797
|
+
if (!(await checkSubscribePerm(session))) return permDeniedHint()
|
|
492
798
|
return await unsubscribeCurrentGroup(session, taskName)
|
|
493
799
|
})
|
|
494
800
|
|
|
@@ -526,15 +832,18 @@ function apply(ctx, config) {
|
|
|
526
832
|
return '📋 订阅列表:\n' + lines.join('\n')
|
|
527
833
|
})
|
|
528
834
|
|
|
529
|
-
ctx.command('我的ID', '查看自己的账号信息(openid/群ID
|
|
835
|
+
ctx.command('我的ID', '查看自己的账号信息(openid/群ID/角色)')
|
|
530
836
|
.action(({ session }) => {
|
|
531
837
|
logger.info('[schedule] 命令被调用: 我的ID')
|
|
838
|
+
const role = session.author?.roles?.[0]?.id || session.event?.member?.roles?.[0]?.id
|
|
839
|
+
const roleName = role === 'owner' ? '群主' : role === 'admin' ? '群管理' : role === 'member' ? '普通成员' : (role || '未知')
|
|
532
840
|
const lines = [
|
|
533
841
|
'👤 我的账号信息:',
|
|
534
842
|
`平台:${session.platform}`,
|
|
535
843
|
`用户ID:${session.userId}`,
|
|
536
844
|
]
|
|
537
845
|
if (session.guildId) lines.push(`当前群ID:${session.guildId}`)
|
|
846
|
+
if (session.guildId) lines.push(`本群角色:${roleName}`)
|
|
538
847
|
if (session.channelId && session.channelId !== session.guildId) lines.push(`频道ID:${session.channelId}`)
|
|
539
848
|
return lines.join('\n')
|
|
540
849
|
})
|
|
@@ -542,12 +851,15 @@ function apply(ctx, config) {
|
|
|
542
851
|
ctx.command('lls.me', '查看自己的账号信息(备用入口)')
|
|
543
852
|
.action(({ session }) => {
|
|
544
853
|
logger.info('[schedule] 命令被调用: lls.me')
|
|
854
|
+
const role = session.author?.roles?.[0]?.id || session.event?.member?.roles?.[0]?.id
|
|
855
|
+
const roleName = role === 'owner' ? '群主' : role === 'admin' ? '群管理' : role === 'member' ? '普通成员' : (role || '未知')
|
|
545
856
|
const lines = [
|
|
546
857
|
'👤 我的账号信息:',
|
|
547
858
|
`平台:${session.platform}`,
|
|
548
859
|
`用户ID:${session.userId}`,
|
|
549
860
|
]
|
|
550
861
|
if (session.guildId) lines.push(`当前群ID:${session.guildId}`)
|
|
862
|
+
if (session.guildId) lines.push(`本群角色:${roleName}`)
|
|
551
863
|
if (session.channelId && session.channelId !== session.guildId) lines.push(`频道ID:${session.channelId}`)
|
|
552
864
|
return lines.join('\n')
|
|
553
865
|
})
|
|
@@ -581,7 +893,19 @@ function apply(ctx, config) {
|
|
|
581
893
|
if (!testTask.groups || !testTask.groups.length) {
|
|
582
894
|
testTask.groups = session.guildId ? [String(session.guildId)] : []
|
|
583
895
|
}
|
|
584
|
-
|
|
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 })
|
|
585
909
|
return `✅ 已发送任务 "${name}"`
|
|
586
910
|
})
|
|
587
911
|
|
package/package.json
CHANGED
|
@@ -1,20 +1,20 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "koishi-plugin-ll-schedule",
|
|
3
|
-
"version": "1.0
|
|
4
|
-
"description": "Scheduled message sender with
|
|
3
|
+
"version": "1.1.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
|
}
|