koishi-plugin-ll-schedule 1.0.0 → 1.0.2
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 +204 -17
- package/package.json +7 -4
- package/.github/workflows/publish.yml +0 -16
package/index.js
CHANGED
|
@@ -1,13 +1,50 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
// koishi-plugin-ll-schedule
|
|
4
|
+
// 定时发送插件:设置时间自动向指定群发送文字/图片
|
|
5
|
+
// v1.0.1:兼容官方QQ机器人(官机)
|
|
6
|
+
// 官机的群 ID 是 openid(不是数字群号),本插件通过"学习"机器人收到的群消息来记录群,
|
|
7
|
+
// 用 schedule.groups 命令查看已知群并填入 tasks.groups;留空则发送给所有已知群。
|
|
8
|
+
|
|
1
9
|
const { Schema, h } = require('koishi')
|
|
2
10
|
|
|
3
|
-
|
|
11
|
+
exports.name = 'll-schedule'
|
|
12
|
+
|
|
13
|
+
exports.usage = `定时发送插件:设置时间自动向指定群发送文字/图片。
|
|
14
|
+
|
|
15
|
+
## 任务配置
|
|
16
|
+
|
|
17
|
+
在插件设置页的 \`tasks\` 表格中添加任务(键=任务名),每项:
|
|
18
|
+
|
|
19
|
+
- \`time\`:发送时间,HH:MM 格式(24小时制)
|
|
20
|
+
- \`groups\`:目标群列表(**留空=发送给所有已知群**)
|
|
21
|
+
- \`text\`:文字内容(可留空)
|
|
22
|
+
- \`image\`:图片 URL(可留空)
|
|
23
|
+
- \`enabled\`:是否启用
|
|
24
|
+
|
|
25
|
+
## 官机(官方QQ机器人)说明
|
|
26
|
+
|
|
27
|
+
官方平台没有"群号",只有 openid。机器人收到过消息的群会被自动记录:
|
|
28
|
+
|
|
29
|
+
- 命令 \`schedule.groups\` 查看所有已知群(显示群名和 openid)
|
|
30
|
+
- 把 openid 填进任务的 \`groups\` 里即可
|
|
31
|
+
- 注意:官机主动发群消息有平台频率限制,且机器人需在群里
|
|
32
|
+
|
|
33
|
+
## 命令
|
|
34
|
+
|
|
35
|
+
- \`schedule\` 帮助
|
|
36
|
+
- \`schedule.list\` 查看任务
|
|
37
|
+
- \`schedule.groups\` 查看机器人已知的群(官机显示 openid)
|
|
38
|
+
- \`schedule.test <任务名>\` 立即测试发送
|
|
39
|
+
`
|
|
40
|
+
|
|
4
41
|
const TaskItem = Schema.object({
|
|
5
42
|
time: Schema.string()
|
|
6
43
|
.required()
|
|
7
44
|
.description('发送时间,HH:MM 格式(24小时制),如 08:30、21:00'),
|
|
8
45
|
groups: Schema.array(Schema.string())
|
|
9
46
|
.default([])
|
|
10
|
-
.description('
|
|
47
|
+
.description('目标群 ID(留空=所有已知群;官机填 openid,OneBot 填数字群号)'),
|
|
11
48
|
text: Schema.string()
|
|
12
49
|
.default('')
|
|
13
50
|
.description('文字内容(可留空)'),
|
|
@@ -23,12 +60,118 @@ const Config = Schema.object({
|
|
|
23
60
|
tasks: Schema.dict(TaskItem)
|
|
24
61
|
.default({})
|
|
25
62
|
.description('📋 定时任务表格(键=任务名)'),
|
|
63
|
+
sendAllWhenEmpty: Schema.boolean()
|
|
64
|
+
.default(true)
|
|
65
|
+
.description('任务未指定群时,发送给该机器人所有已知群'),
|
|
66
|
+
verbose: Schema.boolean()
|
|
67
|
+
.default(false)
|
|
68
|
+
.description('调试:控制台打印每次发送详情'),
|
|
26
69
|
})
|
|
27
70
|
|
|
71
|
+
exports.Config = Config
|
|
72
|
+
|
|
73
|
+
// 需要数据库:持久化学习到的群(重启不丢)
|
|
74
|
+
// 必须声明 required:确保插件在数据库就绪后才启动,模型表能正常注册建表
|
|
75
|
+
exports.inject = { required: ['database'] }
|
|
76
|
+
|
|
28
77
|
function apply(ctx, config) {
|
|
78
|
+
const logger = ctx.logger('ll-schedule')
|
|
79
|
+
|
|
29
80
|
// 记录当天已发送的任务,防止同一分钟重复发
|
|
30
81
|
let firedToday = {}
|
|
31
82
|
|
|
83
|
+
// ===== 已知群(从收到的消息中学习) =====
|
|
84
|
+
// key: `${botSid}:${guildId}` -> { botSid, guildId, platform, learnedAt }
|
|
85
|
+
const knownGroups = new Map()
|
|
86
|
+
const groupNameCache = new Map() // key: botSid:guildId -> { v, t }
|
|
87
|
+
const TTL = 60 * 60 * 1000
|
|
88
|
+
|
|
89
|
+
function cacheGet(map, key) {
|
|
90
|
+
const item = map.get(key)
|
|
91
|
+
return item && Date.now() - item.t < TTL ? item.v : undefined
|
|
92
|
+
}
|
|
93
|
+
function cacheSet(map, key, value) {
|
|
94
|
+
map.set(key, { v: value, t: Date.now() })
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// 数据库模型(可选)
|
|
98
|
+
if (ctx.database) {
|
|
99
|
+
ctx.model.extend('ll_schedule_groups', {
|
|
100
|
+
botSid: 'string',
|
|
101
|
+
guildId: 'string',
|
|
102
|
+
platform: 'string',
|
|
103
|
+
learnedAt: 'unsigned',
|
|
104
|
+
}, { primary: ['botSid', 'guildId'] })
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
async function loadKnownGroups() {
|
|
108
|
+
if (!ctx.database) return
|
|
109
|
+
try {
|
|
110
|
+
const rows = await ctx.database.get('ll_schedule_groups', {})
|
|
111
|
+
for (const row of rows) {
|
|
112
|
+
knownGroups.set(`${row.botSid}:${row.guildId}`, {
|
|
113
|
+
botSid: row.botSid,
|
|
114
|
+
guildId: row.guildId,
|
|
115
|
+
platform: row.platform,
|
|
116
|
+
learnedAt: row.learnedAt,
|
|
117
|
+
})
|
|
118
|
+
}
|
|
119
|
+
if (rows.length) logger.info(`[schedule] 已从数据库加载 ${rows.length} 个已知群`)
|
|
120
|
+
}
|
|
121
|
+
catch (e) {
|
|
122
|
+
logger.warn('[schedule] 加载已知群失败:', e)
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
async function persistGroup(entry) {
|
|
127
|
+
if (!ctx.database) return
|
|
128
|
+
try {
|
|
129
|
+
await ctx.database.upsert('ll_schedule_groups', [entry], ['botSid', 'guildId'])
|
|
130
|
+
}
|
|
131
|
+
catch (e) {
|
|
132
|
+
logger.warn('[schedule] 保存已知群失败:', e)
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function learnGroup(session) {
|
|
137
|
+
const bot = session.bot
|
|
138
|
+
if (!bot || !session.guildId) return
|
|
139
|
+
const key = `${bot.sid}:${session.guildId}`
|
|
140
|
+
if (knownGroups.has(key)) return
|
|
141
|
+
const entry = {
|
|
142
|
+
botSid: bot.sid,
|
|
143
|
+
guildId: session.guildId,
|
|
144
|
+
platform: bot.platform,
|
|
145
|
+
learnedAt: Date.now(),
|
|
146
|
+
}
|
|
147
|
+
knownGroups.set(key, entry)
|
|
148
|
+
persistGroup(entry)
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
async function resolveGroupName(bot, guildId) {
|
|
152
|
+
const key = `${bot.sid}:${guildId}`
|
|
153
|
+
const cached = cacheGet(groupNameCache, key)
|
|
154
|
+
if (cached != null) return cached
|
|
155
|
+
try {
|
|
156
|
+
if (typeof bot.getGuild === 'function') {
|
|
157
|
+
const guild = await bot.getGuild(guildId)
|
|
158
|
+
if (guild?.name) {
|
|
159
|
+
cacheSet(groupNameCache, key, guild.name)
|
|
160
|
+
return guild.name
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
catch (e) {
|
|
165
|
+
logger.warn(`[schedule] 获取群名失败 ${guildId}:`, e)
|
|
166
|
+
}
|
|
167
|
+
return ''
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// 从消息事件学习群
|
|
171
|
+
ctx.on('message', (session) => {
|
|
172
|
+
learnGroup(session)
|
|
173
|
+
})
|
|
174
|
+
|
|
32
175
|
function nowHM() {
|
|
33
176
|
const d = new Date()
|
|
34
177
|
return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`
|
|
@@ -57,12 +200,12 @@ function apply(ctx, config) {
|
|
|
57
200
|
if (firedToday[fireKey]) continue
|
|
58
201
|
firedToday[fireKey] = true
|
|
59
202
|
|
|
60
|
-
|
|
203
|
+
logger.info(`[schedule] 触发任务 "${name}" @ ${hm}`)
|
|
61
204
|
await sendTask(name, task)
|
|
62
205
|
}
|
|
63
206
|
}
|
|
64
207
|
|
|
65
|
-
async function sendTask(name, task) {
|
|
208
|
+
async function sendTask(name, task, onlyBotSid) {
|
|
66
209
|
// 组装消息
|
|
67
210
|
const parts = []
|
|
68
211
|
if (task.text) parts.push(task.text)
|
|
@@ -71,32 +214,60 @@ function apply(ctx, config) {
|
|
|
71
214
|
parts.push(h.image(task.image))
|
|
72
215
|
}
|
|
73
216
|
if (!parts.length) {
|
|
74
|
-
|
|
217
|
+
logger.warn(`[schedule] 任务 "${name}" 没有文字也没有图片,跳过`)
|
|
75
218
|
return
|
|
76
219
|
}
|
|
77
220
|
|
|
78
|
-
|
|
221
|
+
let bots = (ctx.bots || []).filter(b => b.online)
|
|
222
|
+
if (onlyBotSid) bots = bots.filter(b => b.sid === onlyBotSid)
|
|
79
223
|
if (!bots.length) return
|
|
80
224
|
|
|
81
225
|
const groups = (task.groups && task.groups.length > 0) ? task.groups : null
|
|
82
226
|
|
|
83
227
|
for (const bot of bots) {
|
|
84
228
|
if (groups) {
|
|
85
|
-
for (const
|
|
86
|
-
|
|
87
|
-
|
|
229
|
+
for (const rawTarget of groups) {
|
|
230
|
+
const target = String(rawTarget).trim()
|
|
231
|
+
if (!target) continue
|
|
232
|
+
// 官机:群 ID 是 openid;数字群号在官机上无法直接使用
|
|
233
|
+
if (bot.platform === 'qq' && /^\d+$/.test(target)) {
|
|
234
|
+
logger.warn(`[schedule] 任务 "${name}":官方QQ机器人不能用数字群号 "${target}" 发送,请用 schedule.groups 查看 openid 填入`)
|
|
235
|
+
continue
|
|
236
|
+
}
|
|
237
|
+
try {
|
|
238
|
+
await bot.sendMessage(target, parts)
|
|
239
|
+
if (config.verbose) logger.info(`[schedule] 已发送任务 "${name}" 到 ${bot.sid}:${target}`)
|
|
240
|
+
}
|
|
241
|
+
catch (e) {
|
|
242
|
+
logger.error(`[schedule] 发送到 ${bot.sid}:${target} 失败: ${e.message}`)
|
|
88
243
|
}
|
|
89
244
|
}
|
|
90
|
-
}
|
|
91
|
-
|
|
245
|
+
}
|
|
246
|
+
else if (config.sendAllWhenEmpty) {
|
|
247
|
+
// 未指定群 → 发送给该 bot 所有已知群
|
|
248
|
+
const targets = [...knownGroups.values()].filter(g => g.botSid === bot.sid)
|
|
249
|
+
if (!targets.length) {
|
|
250
|
+
logger.warn(`[schedule] 任务 "${name}" 未指定群且机器人 ${bot.sid} 暂无已知群(先让机器人收过该群消息,或用 schedule.groups 查看)`)
|
|
251
|
+
continue
|
|
252
|
+
}
|
|
253
|
+
for (const g of targets) {
|
|
254
|
+
try {
|
|
255
|
+
await bot.sendMessage(g.guildId, parts)
|
|
256
|
+
if (config.verbose) logger.info(`[schedule] 已发送任务 "${name}" 到 ${bot.sid}:${g.guildId}`)
|
|
257
|
+
}
|
|
258
|
+
catch (e) {
|
|
259
|
+
logger.error(`[schedule] 发送到 ${bot.sid}:${g.guildId} 失败: ${e.message}`)
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
else {
|
|
264
|
+
logger.warn(`[schedule] 任务 "${name}" 未指定群号,跳过`)
|
|
92
265
|
}
|
|
93
266
|
}
|
|
94
267
|
}
|
|
95
268
|
|
|
96
269
|
// 每 20 秒检查一次(保证同一分钟内能触发)
|
|
97
270
|
const timer = setInterval(checkAndSend, 20000)
|
|
98
|
-
// 启动时立即检查一次
|
|
99
|
-
checkAndSend()
|
|
100
271
|
|
|
101
272
|
ctx.on('dispose', () => clearInterval(timer))
|
|
102
273
|
|
|
@@ -104,7 +275,7 @@ function apply(ctx, config) {
|
|
|
104
275
|
ctx.command('schedule', '定时发送管理')
|
|
105
276
|
.action(() =>
|
|
106
277
|
'⚙️ 管理:插件设置页 → ll-schedule → tasks 表格\n' +
|
|
107
|
-
'命令:schedule.list 查看任务 | schedule.test <任务名> 立即测试发送'
|
|
278
|
+
'命令:schedule.list 查看任务 | schedule.groups 查看已知群 | schedule.test <任务名> 立即测试发送'
|
|
108
279
|
)
|
|
109
280
|
|
|
110
281
|
ctx.command('schedule.list', '查看定时任务')
|
|
@@ -116,10 +287,23 @@ function apply(ctx, config) {
|
|
|
116
287
|
const t = tasks[k]
|
|
117
288
|
const s = t.enabled === false ? '⛔' : '✅'
|
|
118
289
|
const content = [t.text && '文字', t.image && '图片'].filter(Boolean).join('+') || '(空)'
|
|
119
|
-
return `${s} ${k} @ ${t.time} → ${content} → 群:${(t.groups || []).join(',') || '
|
|
290
|
+
return `${s} ${k} @ ${t.time} → ${content} → 群:${(t.groups || []).join(',') || '所有已知群'}`
|
|
120
291
|
}).join('\n')
|
|
121
292
|
})
|
|
122
293
|
|
|
294
|
+
ctx.command('schedule.groups', '查看机器人已知的群(官机显示 openid)')
|
|
295
|
+
.action(async () => {
|
|
296
|
+
if (!knownGroups.size) return '暂无已知群。机器人收到过消息的群才会被记录(官机需要先把机器人拉进群并让它收消息)'
|
|
297
|
+
const lines = []
|
|
298
|
+
for (const g of knownGroups.values()) {
|
|
299
|
+
const bot = ctx.bots.find(b => b.sid === g.botSid)
|
|
300
|
+
const name = bot ? await resolveGroupName(bot, g.guildId) : ''
|
|
301
|
+
const platformTag = g.platform === 'qq' ? '[官机]' : `[${g.platform}]`
|
|
302
|
+
lines.push(`${platformTag} ${name || '未知群名'} ${g.guildId}`)
|
|
303
|
+
}
|
|
304
|
+
return '📋 机器人已知群:\n' + lines.join('\n') + '\n(官机的群 ID 是 openid,填入任务 groups 即可)'
|
|
305
|
+
})
|
|
306
|
+
|
|
123
307
|
ctx.command('schedule.test <name>', '立即测试发送指定任务')
|
|
124
308
|
.action(async ({ session }, name) => {
|
|
125
309
|
const tasks = config.tasks || {}
|
|
@@ -130,9 +314,12 @@ function apply(ctx, config) {
|
|
|
130
314
|
if (!testTask.groups || !testTask.groups.length) {
|
|
131
315
|
testTask.groups = session.guildId ? [String(session.guildId)] : []
|
|
132
316
|
}
|
|
133
|
-
await sendTask(name, testTask)
|
|
317
|
+
await sendTask(name, testTask, session.bot?.sid)
|
|
134
318
|
return `✅ 已发送任务 "${name}"`
|
|
135
319
|
})
|
|
320
|
+
|
|
321
|
+
// 启动时:加载已知群 + 立即检查一次
|
|
322
|
+
loadKnownGroups().then(() => checkAndSend())
|
|
136
323
|
}
|
|
137
324
|
|
|
138
|
-
|
|
325
|
+
exports.apply = apply
|
package/package.json
CHANGED
|
@@ -1,8 +1,11 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "koishi-plugin-ll-schedule",
|
|
3
|
-
"version": "1.0.
|
|
4
|
-
"description": "Scheduled message sender with text/image support",
|
|
3
|
+
"version": "1.0.2",
|
|
4
|
+
"description": "Scheduled message sender with text/image support, works with official QQ bot",
|
|
5
5
|
"main": "index.js",
|
|
6
|
+
"files": [
|
|
7
|
+
"index.js"
|
|
8
|
+
],
|
|
6
9
|
"license": "MIT",
|
|
7
10
|
"keywords": ["koishi", "koishi-plugin", "schedule", "timer", "定时"],
|
|
8
11
|
"peerDependencies": {
|
|
@@ -10,8 +13,8 @@
|
|
|
10
13
|
},
|
|
11
14
|
"koishi": {
|
|
12
15
|
"description": {
|
|
13
|
-
"zh": "
|
|
14
|
-
"en": "Scheduled message sender. Auto-send text/image to groups at set times."
|
|
16
|
+
"zh": "定时发送插件。设置时间后自动发送文字/图片到指定群,兼容官方QQ机器人(官机)。",
|
|
17
|
+
"en": "Scheduled message sender. Auto-send text/image to groups at set times, works with official QQ bot."
|
|
15
18
|
}
|
|
16
19
|
}
|
|
17
20
|
}
|
|
@@ -1,16 +0,0 @@
|
|
|
1
|
-
name: Publish to npm
|
|
2
|
-
on:
|
|
3
|
-
push:
|
|
4
|
-
branches: [master]
|
|
5
|
-
jobs:
|
|
6
|
-
publish:
|
|
7
|
-
runs-on: ubuntu-latest
|
|
8
|
-
steps:
|
|
9
|
-
- uses: actions/checkout@v4
|
|
10
|
-
- uses: actions/setup-node@v4
|
|
11
|
-
with:
|
|
12
|
-
node-version: '22'
|
|
13
|
-
registry-url: 'https://registry.npmjs.org'
|
|
14
|
-
- run: npm publish
|
|
15
|
-
env:
|
|
16
|
-
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
|