koishi-plugin-ll-schedule 1.0.0 → 1.0.1

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 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,117 @@ 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
+ exports.inject = { optional: ['database'] }
75
+
28
76
  function apply(ctx, config) {
77
+ const logger = ctx.logger('ll-schedule')
78
+
29
79
  // 记录当天已发送的任务,防止同一分钟重复发
30
80
  let firedToday = {}
31
81
 
82
+ // ===== 已知群(从收到的消息中学习) =====
83
+ // key: `${botSid}:${guildId}` -> { botSid, guildId, platform, learnedAt }
84
+ const knownGroups = new Map()
85
+ const groupNameCache = new Map() // key: botSid:guildId -> { v, t }
86
+ const TTL = 60 * 60 * 1000
87
+
88
+ function cacheGet(map, key) {
89
+ const item = map.get(key)
90
+ return item && Date.now() - item.t < TTL ? item.v : undefined
91
+ }
92
+ function cacheSet(map, key, value) {
93
+ map.set(key, { v: value, t: Date.now() })
94
+ }
95
+
96
+ // 数据库模型(可选)
97
+ if (ctx.database) {
98
+ ctx.model.extend('ll_schedule_groups', {
99
+ botSid: 'string',
100
+ guildId: 'string',
101
+ platform: 'string',
102
+ learnedAt: 'unsigned',
103
+ }, { primary: ['botSid', 'guildId'] })
104
+ }
105
+
106
+ async function loadKnownGroups() {
107
+ if (!ctx.database) return
108
+ try {
109
+ const rows = await ctx.database.get('ll_schedule_groups', {})
110
+ for (const row of rows) {
111
+ knownGroups.set(`${row.botSid}:${row.guildId}`, {
112
+ botSid: row.botSid,
113
+ guildId: row.guildId,
114
+ platform: row.platform,
115
+ learnedAt: row.learnedAt,
116
+ })
117
+ }
118
+ if (rows.length) logger.info(`[schedule] 已从数据库加载 ${rows.length} 个已知群`)
119
+ }
120
+ catch (e) {
121
+ logger.warn('[schedule] 加载已知群失败:', e)
122
+ }
123
+ }
124
+
125
+ async function persistGroup(entry) {
126
+ if (!ctx.database) return
127
+ try {
128
+ await ctx.database.upsert('ll_schedule_groups', [entry], ['botSid', 'guildId'])
129
+ }
130
+ catch (e) {
131
+ logger.warn('[schedule] 保存已知群失败:', e)
132
+ }
133
+ }
134
+
135
+ function learnGroup(session) {
136
+ const bot = session.bot
137
+ if (!bot || !session.guildId) return
138
+ const key = `${bot.sid}:${session.guildId}`
139
+ if (knownGroups.has(key)) return
140
+ const entry = {
141
+ botSid: bot.sid,
142
+ guildId: session.guildId,
143
+ platform: bot.platform,
144
+ learnedAt: Date.now(),
145
+ }
146
+ knownGroups.set(key, entry)
147
+ persistGroup(entry)
148
+ }
149
+
150
+ async function resolveGroupName(bot, guildId) {
151
+ const key = `${bot.sid}:${guildId}`
152
+ const cached = cacheGet(groupNameCache, key)
153
+ if (cached != null) return cached
154
+ try {
155
+ if (typeof bot.getGuild === 'function') {
156
+ const guild = await bot.getGuild(guildId)
157
+ if (guild?.name) {
158
+ cacheSet(groupNameCache, key, guild.name)
159
+ return guild.name
160
+ }
161
+ }
162
+ }
163
+ catch (e) {
164
+ logger.warn(`[schedule] 获取群名失败 ${guildId}:`, e)
165
+ }
166
+ return ''
167
+ }
168
+
169
+ // 从消息事件学习群
170
+ ctx.on('message', (session) => {
171
+ learnGroup(session)
172
+ })
173
+
32
174
  function nowHM() {
33
175
  const d = new Date()
34
176
  return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`
@@ -57,12 +199,12 @@ function apply(ctx, config) {
57
199
  if (firedToday[fireKey]) continue
58
200
  firedToday[fireKey] = true
59
201
 
60
- ctx.logger.info(`[schedule] 触发任务 "${name}" @ ${hm}`)
202
+ logger.info(`[schedule] 触发任务 "${name}" @ ${hm}`)
61
203
  await sendTask(name, task)
62
204
  }
63
205
  }
64
206
 
65
- async function sendTask(name, task) {
207
+ async function sendTask(name, task, onlyBotSid) {
66
208
  // 组装消息
67
209
  const parts = []
68
210
  if (task.text) parts.push(task.text)
@@ -71,32 +213,60 @@ function apply(ctx, config) {
71
213
  parts.push(h.image(task.image))
72
214
  }
73
215
  if (!parts.length) {
74
- ctx.logger.warn(`[schedule] 任务 "${name}" 没有文字也没有图片,跳过`)
216
+ logger.warn(`[schedule] 任务 "${name}" 没有文字也没有图片,跳过`)
75
217
  return
76
218
  }
77
219
 
78
- const bots = ctx.bots || []
220
+ let bots = (ctx.bots || []).filter(b => b.online)
221
+ if (onlyBotSid) bots = bots.filter(b => b.sid === onlyBotSid)
79
222
  if (!bots.length) return
80
223
 
81
224
  const groups = (task.groups && task.groups.length > 0) ? task.groups : null
82
225
 
83
226
  for (const bot of bots) {
84
227
  if (groups) {
85
- for (const gid of groups) {
86
- try { await bot.sendMessage(gid, parts) } catch (e) {
87
- ctx.logger.error(`[schedule] 发送到群 ${gid} 失败: ${e.message}`)
228
+ for (const rawTarget of groups) {
229
+ const target = String(rawTarget).trim()
230
+ if (!target) continue
231
+ // 官机:群 ID 是 openid;数字群号在官机上无法直接使用
232
+ if (bot.platform === 'qq' && /^\d+$/.test(target)) {
233
+ logger.warn(`[schedule] 任务 "${name}":官方QQ机器人不能用数字群号 "${target}" 发送,请用 schedule.groups 查看 openid 填入`)
234
+ continue
235
+ }
236
+ try {
237
+ await bot.sendMessage(target, parts)
238
+ if (config.verbose) logger.info(`[schedule] 已发送任务 "${name}" 到 ${bot.sid}:${target}`)
239
+ }
240
+ catch (e) {
241
+ logger.error(`[schedule] 发送到 ${bot.sid}:${target} 失败: ${e.message}`)
88
242
  }
89
243
  }
90
- } else {
91
- ctx.logger.warn(`[schedule] 任务 "${name}" 未指定群号,跳过(请配置目标群)`)
244
+ }
245
+ else if (config.sendAllWhenEmpty) {
246
+ // 未指定群 → 发送给该 bot 所有已知群
247
+ const targets = [...knownGroups.values()].filter(g => g.botSid === bot.sid)
248
+ if (!targets.length) {
249
+ logger.warn(`[schedule] 任务 "${name}" 未指定群且机器人 ${bot.sid} 暂无已知群(先让机器人收过该群消息,或用 schedule.groups 查看)`)
250
+ continue
251
+ }
252
+ for (const g of targets) {
253
+ try {
254
+ await bot.sendMessage(g.guildId, parts)
255
+ if (config.verbose) logger.info(`[schedule] 已发送任务 "${name}" 到 ${bot.sid}:${g.guildId}`)
256
+ }
257
+ catch (e) {
258
+ logger.error(`[schedule] 发送到 ${bot.sid}:${g.guildId} 失败: ${e.message}`)
259
+ }
260
+ }
261
+ }
262
+ else {
263
+ logger.warn(`[schedule] 任务 "${name}" 未指定群号,跳过`)
92
264
  }
93
265
  }
94
266
  }
95
267
 
96
268
  // 每 20 秒检查一次(保证同一分钟内能触发)
97
269
  const timer = setInterval(checkAndSend, 20000)
98
- // 启动时立即检查一次
99
- checkAndSend()
100
270
 
101
271
  ctx.on('dispose', () => clearInterval(timer))
102
272
 
@@ -104,7 +274,7 @@ function apply(ctx, config) {
104
274
  ctx.command('schedule', '定时发送管理')
105
275
  .action(() =>
106
276
  '⚙️ 管理:插件设置页 → ll-schedule → tasks 表格\n' +
107
- '命令:schedule.list 查看任务 | schedule.test <任务名> 立即测试发送'
277
+ '命令:schedule.list 查看任务 | schedule.groups 查看已知群 | schedule.test <任务名> 立即测试发送'
108
278
  )
109
279
 
110
280
  ctx.command('schedule.list', '查看定时任务')
@@ -116,10 +286,23 @@ function apply(ctx, config) {
116
286
  const t = tasks[k]
117
287
  const s = t.enabled === false ? '⛔' : '✅'
118
288
  const content = [t.text && '文字', t.image && '图片'].filter(Boolean).join('+') || '(空)'
119
- return `${s} ${k} @ ${t.time} → ${content} → 群:${(t.groups || []).join(',') || '未指定'}`
289
+ return `${s} ${k} @ ${t.time} → ${content} → 群:${(t.groups || []).join(',') || '所有已知群'}`
120
290
  }).join('\n')
121
291
  })
122
292
 
293
+ ctx.command('schedule.groups', '查看机器人已知的群(官机显示 openid)')
294
+ .action(async () => {
295
+ if (!knownGroups.size) return '暂无已知群。机器人收到过消息的群才会被记录(官机需要先把机器人拉进群并让它收消息)'
296
+ const lines = []
297
+ for (const g of knownGroups.values()) {
298
+ const bot = ctx.bots.find(b => b.sid === g.botSid)
299
+ const name = bot ? await resolveGroupName(bot, g.guildId) : ''
300
+ const platformTag = g.platform === 'qq' ? '[官机]' : `[${g.platform}]`
301
+ lines.push(`${platformTag} ${name || '未知群名'} ${g.guildId}`)
302
+ }
303
+ return '📋 机器人已知群:\n' + lines.join('\n') + '\n(官机的群 ID 是 openid,填入任务 groups 即可)'
304
+ })
305
+
123
306
  ctx.command('schedule.test <name>', '立即测试发送指定任务')
124
307
  .action(async ({ session }, name) => {
125
308
  const tasks = config.tasks || {}
@@ -130,9 +313,12 @@ function apply(ctx, config) {
130
313
  if (!testTask.groups || !testTask.groups.length) {
131
314
  testTask.groups = session.guildId ? [String(session.guildId)] : []
132
315
  }
133
- await sendTask(name, testTask)
316
+ await sendTask(name, testTask, session.bot?.sid)
134
317
  return `✅ 已发送任务 "${name}"`
135
318
  })
319
+
320
+ // 启动时:加载已知群 + 立即检查一次
321
+ loadKnownGroups().then(() => checkAndSend())
136
322
  }
137
323
 
138
- module.exports = { Config, apply }
324
+ exports.apply = apply
package/package.json CHANGED
@@ -1,8 +1,11 @@
1
1
  {
2
2
  "name": "koishi-plugin-ll-schedule",
3
- "version": "1.0.0",
4
- "description": "Scheduled message sender with text/image support",
3
+ "version": "1.0.1",
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 }}