@anyul/koishi-plugin-rss 5.2.48 → 5.3.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.
@@ -2,221 +2,335 @@
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.createCommandRuntimeDeps = exports.registerWebMonitorCommands = exports.registerSubscriptionCreateCommand = exports.registerSubscriptionEditCommand = exports.registerSubscriptionManagementCommands = void 0;
4
4
  exports.registerManagementCommands = registerManagementCommands;
5
+ exports.resolveCacheMessageById = resolveCacheMessageById;
6
+ exports.handleCacheList = handleCacheList;
7
+ exports.handleCacheMessage = handleCacheMessage;
8
+ exports.handleCacheSearch = handleCacheSearch;
9
+ exports.handleCacheStats = handleCacheStats;
10
+ exports.handleCacheClear = handleCacheClear;
11
+ exports.handleCacheCleanup = handleCacheCleanup;
12
+ exports.handleCachePull = handleCachePull;
5
13
  const error_handler_1 = require("../utils/error-handler");
6
14
  const error_tracker_1 = require("../utils/error-tracker");
7
- const message_cache_1 = require("../utils/message-cache");
8
15
  const logger_1 = require("../utils/logger");
16
+ const message_cache_1 = require("../utils/message-cache");
9
17
  const utils_1 = require("./utils");
10
18
  /**
11
19
  * 注册管理类命令
20
+ *
21
+ * 设计要点(命令系统统一):
22
+ * - cache/queue 父命令【不带 .action()】,由 Koishi 原生「无 action → 显示 help」接管,
23
+ * 自动在 help 菜单/网页控制台列出子命令树。
24
+ * - 子命令注册为 rssowl.cache.list / rssowl.cache.stats 等真正的嵌套命令;
25
+ * 用户在聊天里输入 "rsso.cache list"(带空格)时,Koishi 核心路由
26
+ * (inferCommand, core index.mjs:1377-1405)会自动解析到 rssowl.cache.list 子命令,
27
+ * 输入方式与旧版完全一致,但获得了菜单可见性。
28
+ * - 父命令 rssowl.cache 必须无位置参数(`ctx.command('rssowl.cache', ...)`),
29
+ * 否则路由会在父命令处 break,导致 list 被当作位置参数而非子命令。
12
30
  */
13
31
  function registerManagementCommands(deps) {
14
- registerCacheCommands(deps.ctx, deps.config);
32
+ registerCacheCommands(deps.ctx, deps.config, deps.queueManager);
15
33
  registerQueueCommands(deps.ctx, deps.config, deps.queueManager);
16
34
  }
17
- function registerCacheCommands(ctx, config) {
35
+ // 设计决策(审查 #9):cache/queue 命令通过 ctx.guild() 限定为「仅群组可用」。
36
+ // 这是有意为之——缓存数据天然按 platform+guildId 分组,私聊没有有意义的 guildId,
37
+ // extractSessionInfo 依赖 session.event.guild.id;若放开到私聊会导致 guild 为 undefined 而崩溃,
38
+ // 且私聊也无"本群缓存"这一语义。因此保持 ctx.guild(),不在私聊降级。
39
+ // ---------------------------------------------------------------------------
40
+ // cache 命令树
41
+ // ---------------------------------------------------------------------------
42
+ function registerCacheCommands(ctx, config, queueManager) {
43
+ // 父命令:无 action,Koishi 自动显示 help + 子命令列表
18
44
  ctx.guild()
19
45
  .command('rssowl.cache', '消息缓存管理')
20
- .alias('rsso.cache')
21
- .usage(`
22
- 消息缓存管理功能,查看和管理已推送的 RSS 消息缓存。
46
+ .alias('rsso.cache', 'rsc')
47
+ .usage(`查看和管理已推送的 RSS 消息缓存(仅限当前群组)。
48
+
49
+ 输入 rsso.cache 不带子命令可查看所有子命令,或直接使用:
50
+ rsso.cache list [页数] 查看缓存消息列表
51
+ rsso.cache search <关键词> 搜索缓存消息
52
+ rsso.cache stats 查看缓存统计
53
+ rsso.cache message <ID> 查看消息详情(真实 ID)
54
+ rsso.cache pull <ID> 重新推送缓存消息(真实 ID)
55
+ rsso.cache clear 清空本群缓存(需权限)
56
+ rsso.cache cleanup [N] 清理并保留最新 N 条(需权限)
23
57
 
24
- 用法:
25
- rsso.cache list [页数] - 查看缓存消息列表
26
- rsso.cache search <关键词> - 搜索缓存消息
27
- rsso.cache stats - 查看缓存统计
28
- rsso.cache message <序号> - 查看消息详情
29
- rsso.cache pull <序号> - 重新推送缓存消息
30
- rsso.cache clear - 清空所有缓存
31
- rsso.cache cleanup [保留数量] - 清理缓存(保留最新N条)
58
+ 注意:
59
+ - 仅显示/操作【当前群组】的缓存
60
+ - message/pull 使用 list 中方括号 [ID:xxx] 显示的真实数据库 ID`);
61
+ // 子命令:list
62
+ ctx.guild()
63
+ .command('rssowl.cache.list [page:number]', '查看缓存消息列表')
64
+ .alias('rsso.cache.list')
65
+ .usage(`查看当前群组的缓存消息列表(分页,每页 10 条)。
32
66
 
33
67
  示例:
34
- rsso.cache list - 查看第1页(每页10条)
35
- rsso.cache list 2 - 查看第2
36
- rsso.cache search 新闻 - 搜索包含"新闻"的消息
37
- rsso.cache stats - 查看统计信息
38
- rsso.cache message 1 - 查看序号1的消息详情
39
- rsso.cache pull 1 - 推送序号1的消息
40
- rsso.cache cleanup 50 - 清理并保留最新50条
68
+ rsso.cache list 查看第 1
69
+ rsso.cache list 2 查看第 2 页`)
70
+ .example('rsso.cache list')
71
+ .action(async ({ session }, page) => {
72
+ const cmd = buildCacheCmd(session, 'rsso.cache', 'list');
73
+ if (typeof cmd === 'string')
74
+ return cmd;
75
+ return handleCacheList(cmd, page ? [String(page)] : [], config);
76
+ });
77
+ // 子命令:search
78
+ ctx.guild()
79
+ .command('rssowl.cache.search <keyword:text>', '搜索缓存消息')
80
+ .alias('rsso.cache.search')
81
+ .usage(`在当前群组的缓存中搜索包含关键词的消息。
41
82
 
42
- 注意:序号从1开始,会在列表中显示对应的真实数据库ID
43
- `)
44
- .action(async ({ session }, subcommand, ...args) => {
45
- const { authority } = session.user;
46
- const cache = (0, message_cache_1.getMessageCache)();
47
- if (!cache) {
48
- return '消息缓存功能未启用,请在配置中启用 cache.enabled';
49
- }
50
- if (!subcommand) {
51
- return `消息缓存管理
83
+ 示例:
84
+ rsso.cache search 新闻`)
85
+ .example('rsso.cache search 新闻')
86
+ .action(async ({ session }, keyword) => {
87
+ const cmd = buildCacheCmd(session, 'rsso.cache', 'search');
88
+ if (typeof cmd === 'string')
89
+ return cmd;
90
+ if (!keyword)
91
+ return '请提供搜索关键词\n用法: rsso.cache search <关键词>';
92
+ return handleCacheSearch(cmd, [keyword], config);
93
+ });
94
+ // 子命令:stats
95
+ ctx.guild()
96
+ .command('rssowl.cache.stats', '查看缓存统计')
97
+ .alias('rsso.cache.stats')
98
+ .usage('查看当前群组的缓存统计信息(总数、按订阅分布、时间范围)。')
99
+ .example('rsso.cache stats')
100
+ .action(async ({ session }) => {
101
+ const cmd = buildCacheCmd(session, 'rsso.cache', 'stats');
102
+ if (typeof cmd === 'string')
103
+ return cmd;
104
+ return handleCacheStats(cmd, config);
105
+ });
106
+ // 子命令:message
107
+ ctx.guild()
108
+ .command('rssowl.cache.message <id:number>', '查看缓存消息详情')
109
+ .alias('rsso.cache.message')
110
+ .usage(`按真实 ID 查看缓存消息详情(ID 来自 list 列表中的 [ID:xxx])。
52
111
 
53
- 可用指令:
54
- rsso.cache list [页数] - 查看缓存消息列表
55
- rsso.cache search <关键词> - 搜索缓存消息
56
- rsso.cache stats - 查看缓存统计
57
- rsso.cache message <序号> - 查看消息详情
58
- rsso.cache pull <序号> - 重新推送缓存消息
59
- rsso.cache clear - 清空所有缓存
60
- rsso.cache cleanup [保留数量] - 清理缓存(保留最新N条)
112
+ 示例:
113
+ rsso.cache message 42`)
114
+ .example('rsso.cache message 42')
115
+ .action(async ({ session }, id) => {
116
+ const cmd = buildCacheCmd(session, 'rsso.cache', 'message');
117
+ if (typeof cmd === 'string')
118
+ return cmd;
119
+ return handleCacheMessage(cmd, id != null ? [String(id)] : [], config);
120
+ });
121
+ // 子命令:pull
122
+ ctx.guild()
123
+ .command('rssowl.cache.pull <id:number>', '重新推送缓存消息')
124
+ .alias('rsso.cache.pull')
125
+ .usage(`按真实 ID 重新推送一条缓存消息(ID 来自 list 列表中的 [ID:xxx])。
126
+ 推送经发送队列,与正常 feed 推送路径一致(含重试/降级)。
61
127
 
62
- 详细信息请使用: rsso.cache --help`;
63
- }
64
- const logContext = (0, utils_1.buildCommandLogContext)(session, 'rsso.cache', subcommand);
65
- switch (subcommand) {
66
- case 'list':
67
- return handleCacheList(cache, args, config, logContext);
68
- case 'message':
69
- return handleCacheMessage(cache, args, config, logContext);
70
- case 'search':
71
- return handleCacheSearch(cache, args, config, logContext);
72
- case 'stats':
73
- return handleCacheStats(cache, config, logContext);
74
- case 'clear':
75
- if (authority < config.basic.authority) {
76
- return `权限不足!当前权限: ${authority},需要权限: ${config.basic.authority} 或以上`;
77
- }
78
- return handleCacheClear(cache, config, logContext);
79
- case 'cleanup':
80
- if (authority < config.basic.authority) {
81
- return `权限不足!当前权限: ${authority},需要权限: ${config.basic.authority} 或以上`;
82
- }
83
- return handleCacheCleanup(cache, args, config, logContext);
84
- case 'pull':
85
- return handleCachePull(ctx, session, cache, args, config, logContext);
86
- default:
87
- return `未知的子命令: ${subcommand}\n使用 "rsso.cache" 查看可用指令`;
88
- }
128
+ 示例:
129
+ rsso.cache pull 42`)
130
+ .example('rsso.cache pull 42')
131
+ .action(async ({ session }, id) => {
132
+ const cmd = buildCacheCmd(session, 'rsso.cache', 'pull');
133
+ if (typeof cmd === 'string')
134
+ return cmd;
135
+ return handleCachePull(ctx, queueManager, session, cmd, id != null ? [String(id)] : [], config);
136
+ });
137
+ // 子命令:clear(需权限)
138
+ ctx.guild()
139
+ .command('rssowl.cache.clear', '清空本群缓存')
140
+ .alias('rsso.cache.clear')
141
+ .usage('清空当前群组的所有缓存消息(需要基础权限)。')
142
+ .example('rsso.cache clear')
143
+ .action(async ({ session }) => {
144
+ const cmd = buildCacheCmd(session, 'rsso.cache', 'clear');
145
+ if (typeof cmd === 'string')
146
+ return cmd;
147
+ const ac = (0, utils_1.checkAuthority)(cmd.authority, config.basic.authority, permissionDeniedMsg(cmd.authority, config));
148
+ if (!ac.success)
149
+ return ac.message;
150
+ return handleCacheClear(cmd, config);
151
+ });
152
+ // 子命令:cleanup(需权限)
153
+ ctx.guild()
154
+ .command('rssowl.cache.cleanup [keepLatest:number]', '清理缓存')
155
+ .alias('rsso.cache.cleanup')
156
+ .usage(`清理当前群组缓存,保留最新 N 条(需要基础权限)。
157
+ 不传 N 时使用配置的最大缓存限制。
158
+
159
+ 示例:
160
+ rsso.cache cleanup 按配置上限清理
161
+ rsso.cache cleanup 50 保留最新 50 条`)
162
+ .example('rsso.cache cleanup 50')
163
+ .action(async ({ session }, keepLatest) => {
164
+ const cmd = buildCacheCmd(session, 'rsso.cache', 'cleanup');
165
+ if (typeof cmd === 'string')
166
+ return cmd;
167
+ const ac = (0, utils_1.checkAuthority)(cmd.authority, config.basic.authority, permissionDeniedMsg(cmd.authority, config));
168
+ if (!ac.success)
169
+ return ac.message;
170
+ return handleCacheCleanup(cmd, keepLatest != null ? [String(keepLatest)] : [], config);
89
171
  });
90
172
  }
173
+ // ---------------------------------------------------------------------------
174
+ // queue 命令树
175
+ // ---------------------------------------------------------------------------
91
176
  function registerQueueCommands(ctx, config, queueManager) {
177
+ // 父命令:无 action,Koishi 自动显示 help + 子命令列表
92
178
  ctx.guild()
93
179
  .command('rssowl.queue', '发送队列管理')
94
- .alias('rsso.queue')
95
- .usage(`
96
- 发送队列管理功能,查看和管理待发送的消息队列。
180
+ .alias('rsso.queue', 'rsq')
181
+ .usage(`查看和管理发送队列。
97
182
 
98
- 用法:
99
- rsso.queue stats - 查看队列统计
100
- rsso.queue retry [id] - 重试失败的任务
101
- rsso.queue retry --all - 重试所有失败任务
102
- rsso.queue cleanup [hours] - 清理旧的成功任务(默认24小时)
183
+ 输入 rsso.queue 不带子命令可查看所有子命令,或直接使用:
184
+ rsso.queue stats 查看队列统计
185
+ rsso.queue retry [id] 重试失败任务(需权限)
186
+ rsso.queue retry --all 重试所有失败任务(需权限)
187
+ rsso.queue cleanup [hours] 清理旧的成功任务(默认 24 小时,需权限)`);
188
+ // 子命令:stats
189
+ ctx.guild()
190
+ .command('rssowl.queue.stats', '查看发送队列统计')
191
+ .alias('rsso.queue.stats')
192
+ .usage('查看发送队列各状态的任务数量。')
193
+ .example('rsso.queue stats')
194
+ .action(async ({ session }) => {
195
+ const logContext = (0, utils_1.buildCommandLogContext)(session, 'rsso.queue', 'stats');
196
+ return handleQueueStats(queueManager, config, logContext);
197
+ });
198
+ // 子命令:retry(需权限)
199
+ ctx.guild()
200
+ .command('rssowl.queue.retry [id:number]', '重试发送队列失败任务')
201
+ .alias('rsso.queue.retry')
202
+ .option('all', '--all 重试所有失败任务')
203
+ .usage(`重试发送队列中失败的任务(需要基础权限)。
103
204
 
104
205
  示例:
105
- rsso.queue stats - 查看队列状态
106
- rsso.queue retry 5 - 重试ID为5的任务
107
- rsso.queue retry --all - 重试所有失败任务
108
- rsso.queue cleanup 48 - 清理48小时前的成功任务
109
-
110
- 说明:
111
- - PENDING: 待发送
112
- - RETRY: 等待重试
113
- - FAILED: 发送失败
114
- - SUCCESS: 发送成功
115
- `)
116
- .action(async ({ session }, subcommand, ...args) => {
117
- const { authority } = session.user;
118
- if (!subcommand) {
119
- return `发送队列管理
120
-
121
- 可用指令:
122
- rsso.queue stats - 查看队列统计
123
- rsso.queue retry [id] - 重试失败的任务
124
- rsso.queue retry --all - 重试所有失败任务
125
- rsso.queue cleanup [hours] - 清理旧的成功任务(默认24小时)
206
+ rsso.queue retry 5 重试 ID 为 5 的任务
207
+ rsso.queue retry --all 重试所有失败任务`)
208
+ .example('rsso.queue retry --all')
209
+ .action(async ({ session, options }, id) => {
210
+ const { authority } = (0, utils_1.extractSessionInfo)(session);
211
+ const ac = (0, utils_1.checkAuthority)(authority, config.basic.authority, permissionDeniedMsg(authority, config));
212
+ if (!ac.success)
213
+ return ac.message;
214
+ const logContext = (0, utils_1.buildCommandLogContext)(session, 'rsso.queue', 'retry');
215
+ const args = options?.all ? ['--all'] : (id != null ? [String(id)] : []);
216
+ return handleQueueRetry(queueManager, args, config, logContext);
217
+ });
218
+ // 子命令:cleanup(需权限)
219
+ ctx.guild()
220
+ .command('rssowl.queue.cleanup [hours:number]', '清理发送队列成功任务')
221
+ .alias('rsso.queue.cleanup')
222
+ .usage(`清理超过指定小时数的成功任务(需要基础权限)。
223
+ 不传 hours 时默认 24 小时。
126
224
 
127
- 详细信息请使用: rsso.queue --help`;
128
- }
129
- const logContext = (0, utils_1.buildCommandLogContext)(session, 'rsso.queue', subcommand);
130
- switch (subcommand) {
131
- case 'stats':
132
- return handleQueueStats(queueManager, config, logContext);
133
- case 'retry':
134
- if (authority < config.basic.authority) {
135
- return `权限不足!当前权限: ${authority},需要权限: ${config.basic.authority} 或以上`;
136
- }
137
- return handleQueueRetry(queueManager, args, config, logContext);
138
- case 'cleanup':
139
- if (authority < config.basic.authority) {
140
- return `权限不足!当前权限: ${authority},需要权限: ${config.basic.authority} 或以上`;
141
- }
142
- return handleQueueCleanup(queueManager, args, config, logContext);
143
- default:
144
- return `未知的子命令: ${subcommand}\n使用 "rsso.queue" 查看可用指令`;
145
- }
225
+ 示例:
226
+ rsso.queue cleanup 清理 24 小时前的成功任务
227
+ rsso.queue cleanup 48 清理 48 小时前的成功任务`)
228
+ .example('rsso.queue cleanup 48')
229
+ .action(async ({ session }, hours) => {
230
+ const { authority } = (0, utils_1.extractSessionInfo)(session);
231
+ const ac = (0, utils_1.checkAuthority)(authority, config.basic.authority, permissionDeniedMsg(authority, config));
232
+ if (!ac.success)
233
+ return ac.message;
234
+ const logContext = (0, utils_1.buildCommandLogContext)(session, 'rsso.queue', 'cleanup');
235
+ return handleQueueCleanup(queueManager, hours != null ? [String(hours)] : [], config, logContext);
146
236
  });
147
237
  }
148
- async function handleCacheList(cache, args, config, logContext) {
238
+ // ---------------------------------------------------------------------------
239
+ // cache 命令上下文构造
240
+ // ---------------------------------------------------------------------------
241
+ /**
242
+ * 从 session 构造 cache 命令上下文,并预先校验缓存是否启用。
243
+ * 返回 string 时表示出错消息(应直接 return),返回 CacheCommandContext 时继续。
244
+ */
245
+ function buildCacheCmd(session, parent, sub) {
246
+ const { platform, guildId, authority } = (0, utils_1.extractSessionInfo)(session);
247
+ const cache = (0, message_cache_1.getMessageCache)();
248
+ if (!cache) {
249
+ return '消息缓存功能未启用,请在配置中启用 cache.enabled';
250
+ }
251
+ const logContext = (0, utils_1.buildCommandLogContext)(session, parent, sub);
252
+ return { cache, platform, guildId, authority, logContext };
253
+ }
254
+ function permissionDeniedMsg(authority, config) {
255
+ return `❌ 权限不足!当前权限: ${authority},需要权限: ${config.basic.authority} 或以上`;
256
+ }
257
+ // ---------------------------------------------------------------------------
258
+ // cache handlers(业务逻辑不变,仅统一轻量 emoji 风格)
259
+ // ---------------------------------------------------------------------------
260
+ async function handleCacheList(cmdCtx, args, config) {
261
+ const { cache, platform, guildId, logContext } = cmdCtx;
149
262
  const page = parseInt(args[0]) || 1;
150
263
  const limit = 10;
151
264
  const offset = (page - 1) * limit;
152
265
  try {
153
- const messages = await cache.getMessages({ limit, offset });
266
+ // 限定到当前群组,杜绝跨群泄露
267
+ const messages = await cache.getMessages({ limit, offset, platform, guildId });
154
268
  if (messages.length === 0) {
155
269
  return '暂无缓存消息';
156
270
  }
157
- const stats = await cache.getStats();
158
- let output = `📋 缓存消息列表 (第${page}页,共${Math.ceil(stats.totalMessages / limit)}页,总计${stats.totalMessages}条)\n\n`;
271
+ const stats = await cache.getStats({ platform, guildId });
272
+ let output = `缓存消息列表 (第${page}页,共${Math.max(1, Math.ceil(stats.totalMessages / limit))}页,总计${stats.totalMessages}条)\n\n`;
159
273
  output += messages.map((msg, index) => {
160
274
  const date = new Date(msg.createdAt).toLocaleString('zh-CN', {
161
275
  month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit'
162
276
  });
163
277
  const title = msg.title.length > 30 ? msg.title.substring(0, 30) + '...' : msg.title;
164
- return `${index + 1}. [ID:${msg.id}] [${msg.rssId}] ${title}\n 时间: ${date}\n 链接: ${msg.link}`;
278
+ return `${offset + index + 1}. [ID:${msg.id}] [${msg.rssId}] ${title}\n 时间: ${date}\n 链接: ${msg.link}`;
165
279
  }).join('\n\n');
166
280
  output += `\n\n💡 使用 "rsso.cache list ${page + 1}" 查看下一页`;
167
- output += '\n💡 使用 "rsso.cache pull <序号>" 推送消息(注意:序号基于当前页)';
168
- output += '\n💡 使用 "rsso.cache message <序号>" 查看详情';
281
+ output += '\n💡 使用 "rsso.cache message <ID>" 查看详情(ID 为方括号中的真实 ID)';
282
+ output += '\n💡 使用 "rsso.cache pull <ID>" 推送消息(使用真实 ID)';
169
283
  return output;
170
284
  }
171
285
  catch (error) {
172
- logCommandError(config, error, 'cache list error', { ...logContext, page, limit });
173
- return `获取消息列表失败: ${error.message}`;
286
+ logCacheError(config, error, 'cache list error', { ...logContext, page, limit });
287
+ return `❌ 获取消息列表失败: ${error.message}`;
174
288
  }
175
289
  }
176
- async function handleCacheMessage(cache, args, config, logContext) {
177
- const serialNumber = parseInt(args[0]);
178
- if (!serialNumber || serialNumber < 1) {
179
- return '请提供序号\n使用方法: rsso.cache message <序号>\n示例: rsso.cache message 1\n💡 提示:使用 "rsso.cache list" 查看序号';
290
+ async function handleCacheMessage(cmdCtx, args, config) {
291
+ const { cache, logContext } = cmdCtx;
292
+ const realId = parseInt(args[0]);
293
+ if (!realId || realId < 1) {
294
+ return '请提供真实 ID\n用法: rsso.cache message <ID>\n示例: rsso.cache message 42\n💡 提示:使用 "rsso.cache list" 查看方括号中的真实 ID';
180
295
  }
181
296
  try {
182
- const found = await findCachedMessageBySerial(cache, serialNumber);
183
- if (!found.message) {
184
- return `❌ 未找到序号为 ${args[0]} 的消息\n💡 使用 "rsso.cache list" 查看可用的序号`;
297
+ const message = await resolveCacheMessageById(cmdCtx, realId);
298
+ if (!message) {
299
+ return `❌ 未找到 ID 为 ${args[0]} 的消息(或该消息不属于当前群组)\n💡 使用 "rsso.cache list" 查看可用的真实 ID`;
185
300
  }
186
- const pubDate = new Date(found.message.pubDate).toLocaleString('zh-CN', {
301
+ const pubDate = new Date(message.pubDate).toLocaleString('zh-CN', {
187
302
  year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit'
188
303
  });
189
- const createdAt = new Date(found.message.createdAt).toLocaleString('zh-CN', {
304
+ const createdAt = new Date(message.createdAt).toLocaleString('zh-CN', {
190
305
  year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit'
191
306
  });
192
- let output = `📰 消息详情 (第${found.page}页序号${args[0]},真实ID:${found.message.id})\n\n`;
193
- output += `📰 标题: ${found.message.title}\n📡 订阅: ${found.message.rssId}\n👥 群组: ${found.message.platform}:${found.message.guildId}\n🔗 链接: ${found.message.link}\n📅 发布时间: ${pubDate}\n💾 缓存时间: ${createdAt}\n`;
194
- if (found.message.content) {
195
- const content = found.message.content.length > 200 ? found.message.content.substring(0, 200) + '...' : found.message.content;
196
- output += `\n📝 内容:\n${content}`;
307
+ let output = `消息详情 (ID:${message.id})\n\n`;
308
+ output += `标题: ${message.title}\n订阅: ${message.rssId}\n群组: ${message.platform}:${message.guildId}\n链接: ${message.link}\n发布时间: ${pubDate}\n缓存时间: ${createdAt}\n`;
309
+ if (message.content) {
310
+ const content = message.content.length > 200 ? message.content.substring(0, 200) + '...' : message.content;
311
+ output += `\n内容:\n${content}`;
197
312
  }
198
- if (found.message.imageUrl)
199
- output += `\n\n🖼️ 图片: ${found.message.imageUrl}`;
200
- if (found.message.videoUrl)
201
- output += `\n\n🎬 视频: ${found.message.videoUrl}`;
313
+ if (message.imageUrl)
314
+ output += `\n\n图片: ${message.imageUrl}`;
315
+ if (message.videoUrl)
316
+ output += `\n\n视频: ${message.videoUrl}`;
202
317
  return output;
203
318
  }
204
319
  catch (error) {
205
- logCommandError(config, error, 'cache message error', { ...logContext, serialNumber });
206
- return `获取消息详情失败: ${error.message}`;
320
+ logCacheError(config, error, 'cache message error', { ...logContext, realId });
321
+ return `❌ 获取消息详情失败: ${error.message}`;
207
322
  }
208
323
  }
209
- async function handleCacheSearch(cache, args, config, logContext) {
324
+ async function handleCacheSearch(cmdCtx, args, config) {
325
+ const { cache, platform, guildId, logContext } = cmdCtx;
210
326
  const keyword = args[0];
211
- if (!keyword) {
212
- return '请提供搜索关键词\n使用方法: rsso.cache search <关键词>';
213
- }
214
327
  try {
215
- const messages = await cache.searchMessages({ keyword, limit: 10 });
328
+ // 限定到当前群组
329
+ const messages = await cache.searchMessages({ keyword, limit: 10, platform, guildId });
216
330
  if (messages.length === 0) {
217
331
  return `未找到包含 "${keyword}" 的消息`;
218
332
  }
219
- let output = `🔍 搜索结果 "${keyword}" (找到${messages.length}条)\n\n`;
333
+ let output = `搜索结果 "${keyword}" (找到${messages.length}条)\n\n`;
220
334
  output += messages.map((msg, index) => {
221
335
  const date = new Date(msg.createdAt).toLocaleString('zh-CN', {
222
336
  month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit'
@@ -224,109 +338,134 @@ async function handleCacheSearch(cache, args, config, logContext) {
224
338
  const title = msg.title.length > 30 ? msg.title.substring(0, 30) + '...' : msg.title;
225
339
  return `${index + 1}. [ID:${msg.id}] [${msg.rssId}] ${title}\n 时间: ${date}`;
226
340
  }).join('\n\n');
227
- output += '\n\n💡 使用 "rsso.cache message <真实ID>" 查看详情';
341
+ output += '\n\n💡 使用 "rsso.cache message <ID>" 查看详情(使用方括号中的真实 ID)';
228
342
  return output;
229
343
  }
230
344
  catch (error) {
231
- logCommandError(config, error, 'cache search error', { ...logContext, keyword });
232
- return `搜索失败: ${error.message}`;
345
+ logCacheError(config, error, 'cache search error', { ...logContext, keyword });
346
+ return `❌ 搜索失败: ${error.message}`;
233
347
  }
234
348
  }
235
- async function handleCacheStats(cache, config, logContext) {
349
+ async function handleCacheStats(cmdCtx, config) {
350
+ const { cache, platform, guildId, logContext } = cmdCtx;
236
351
  try {
237
- const stats = await cache.getStats();
238
- let output = `📊 缓存统计信息\n\n📦 总消息数: ${stats.totalMessages}\n`;
352
+ // 限定到当前群组
353
+ const stats = await cache.getStats({ platform, guildId });
354
+ let output = `缓存统计信息(本群)\n\n总消息数: ${stats.totalMessages}\n`;
239
355
  if (stats.oldestMessage)
240
- output += `📅 最早消息: ${new Date(stats.oldestMessage).toLocaleString('zh-CN')}\n`;
356
+ output += `最早消息: ${new Date(stats.oldestMessage).toLocaleString('zh-CN')}\n`;
241
357
  if (stats.newestMessage)
242
- output += `📅 最新消息: ${new Date(stats.newestMessage).toLocaleString('zh-CN')}\n`;
243
- output += '\n📡 按订阅统计:\n';
358
+ output += `最新消息: ${new Date(stats.newestMessage).toLocaleString('zh-CN')}\n`;
359
+ output += '\n按订阅统计:\n';
244
360
  Object.entries(stats.bySubscription).sort(([, a], [, b]) => b - a).slice(0, 10).forEach(([rssId, count]) => {
245
361
  output += ` ${rssId}: ${count}条\n`;
246
362
  });
247
- output += '\n👥 按群组统计:\n';
248
- Object.entries(stats.byGuild).sort(([, a], [, b]) => b - a).slice(0, 10).forEach(([guild, count]) => {
249
- output += ` ${guild}: ${count}条\n`;
250
- });
251
- output += `\n⚙️ 最大缓存限制: ${cache.getMaxCacheSize()}条`;
363
+ output += `\n最大缓存限制: ${cache.getMaxCacheSize()}条`;
252
364
  return output;
253
365
  }
254
366
  catch (error) {
255
- logCommandError(config, error, 'cache stats error', logContext);
256
- return `获取统计信息失败: ${error.message}`;
367
+ logCacheError(config, error, 'cache stats error', logContext);
368
+ return `❌ 获取统计信息失败: ${error.message}`;
257
369
  }
258
370
  }
259
- async function handleCacheClear(cache, config, logContext) {
371
+ async function handleCacheClear(cmdCtx, config) {
372
+ const { cache, platform, guildId, logContext } = cmdCtx;
260
373
  try {
261
- const deletedCount = await cache.clearAll();
262
- return `✅ 已清空所有缓存,共删除 ${deletedCount} 条消息`;
374
+ // 限定到当前群组
375
+ const deletedCount = await cache.clearAll({ platform, guildId });
376
+ return `✅ 已清空本群缓存,共删除 ${deletedCount} 条消息`;
263
377
  }
264
378
  catch (error) {
265
- logCommandError(config, error, 'cache clear error', logContext);
266
- return `清空缓存失败: ${error.message}`;
379
+ logCacheError(config, error, 'cache clear error', logContext);
380
+ return `❌ 清空缓存失败: ${error.message}`;
267
381
  }
268
382
  }
269
- async function handleCacheCleanup(cache, args, config, logContext) {
383
+ async function handleCacheCleanup(cmdCtx, args, config) {
384
+ const { cache, platform, guildId, logContext } = cmdCtx;
270
385
  const keepLatest = parseInt(args[0]) || cache.getMaxCacheSize();
271
386
  try {
272
- const deletedCount = await cache.cleanup({ keepLatest });
387
+ // 限定到当前群组
388
+ const deletedCount = await cache.cleanup({ keepLatest, platform, guildId });
273
389
  if (deletedCount === 0) {
274
- return '✅ 当前缓存数量未超过限制,无需清理';
390
+ return '✅ 当前群组缓存数量未超过限制,无需清理';
275
391
  }
276
- return `✅ 已清理缓存,保留最新 ${keepLatest} 条,删除 ${deletedCount} 条消息`;
392
+ return `✅ 已清理本群缓存,保留最新 ${keepLatest} 条,删除 ${deletedCount} 条消息`;
277
393
  }
278
394
  catch (error) {
279
- logCommandError(config, error, 'cache cleanup error', { ...logContext, keepLatest });
280
- return `清理缓存失败: ${error.message}`;
395
+ logCacheError(config, error, 'cache cleanup error', { ...logContext, keepLatest });
396
+ return `❌ 清理缓存失败: ${error.message}`;
281
397
  }
282
398
  }
283
- async function handleCachePull(ctx, session, cache, args, config, logContext) {
284
- const serialNumber = parseInt(args[0]);
285
- if (!serialNumber || serialNumber < 1) {
286
- return '请提供有效的序号\n使用方法: rsso.cache pull <序号>\n示例: rsso.cache pull 1\n💡 提示:使用 "rsso.cache list" 查看序号';
399
+ async function handleCachePull(ctx, queueManager, session, cmdCtx, args, config) {
400
+ const { logContext } = cmdCtx;
401
+ const realId = parseInt(args[0]);
402
+ if (!realId || realId < 1) {
403
+ return '请提供有效的真实 ID\n用法: rsso.cache pull <ID>\n示例: rsso.cache pull 42\n💡 提示:使用 "rsso.cache list" 查看方括号中的真实 ID';
287
404
  }
288
- let found = null;
289
- const targetContext = {
290
- ...logContext,
291
- serialNumber,
292
- };
405
+ let message = null;
293
406
  try {
294
- found = await findCachedMessageBySerial(cache, serialNumber);
295
- if (!found.message) {
296
- return `❌ 未找到序号为 ${args[0]} 的消息\n💡 使用 "rsso.cache list" 查看可用的序号`;
407
+ message = await resolveCacheMessageById(cmdCtx, realId);
408
+ if (!message) {
409
+ return `❌ 未找到 ID 为 ${args[0]} 的消息(或该消息不属于当前群组)\n💡 使用 "rsso.cache list" 查看可用的真实 ID`;
297
410
  }
298
- if (!found.message.finalMessage) {
411
+ if (!message.finalMessage) {
299
412
  return '❌ 该消息没有缓存的最终消息\n💡 这条消息可能是旧版本缓存,请重新订阅后重试';
300
413
  }
301
414
  const { id: guildId } = session.event.guild;
302
415
  const { platform } = session.event;
303
- const target = `${platform}:${guildId}`;
304
- await ctx.broadcast([target], found.message.finalMessage);
305
- return '';
416
+ // 走发送队列而不是直接 ctx.broadcast,与 feeder 推送路径保持一致:
417
+ // - 经队列可获得重试 / 降级(OneBot 1200 等错误)保护
418
+ // - 统一的日志/追踪上下文
419
+ // - finalMessage 在缓存前已按 msg.censor 完成审查包裹,此处保持原样不重复包裹
420
+ // 为避免与原 feed 任务的 uid 去重冲突(队列按 subscribeId+uid+target 去重),
421
+ // 重新推送使用带 cache-pull 前缀与时间戳的唯一 uid。
422
+ const taskContent = {
423
+ message: message.finalMessage,
424
+ originalItem: undefined,
425
+ isDowngraded: false,
426
+ title: message.title,
427
+ description: message.content,
428
+ link: message.link,
429
+ pubDate: message.pubDate,
430
+ imageUrl: message.imageUrl,
431
+ };
432
+ await queueManager.addTask({
433
+ subscribeId: `cache-pull:${message.id}`,
434
+ rssId: message.rssId,
435
+ uid: `cache-pull:${message.id}:${Date.now()}`,
436
+ guildId,
437
+ platform,
438
+ content: taskContent,
439
+ });
440
+ return `✅ 已将消息(ID:${message.id})加入发送队列,即将重新推送`;
306
441
  }
307
442
  catch (error) {
308
443
  const { id: guildId } = session.event.guild;
309
444
  const { platform } = session.event;
310
- logCommandError(config, error, 'cache pull error', {
311
- ...targetContext,
445
+ logCacheError(config, error, 'cache pull error', {
446
+ ...logContext,
447
+ realId,
312
448
  guildId,
313
449
  platform,
314
450
  target: `${platform}:${guildId}`,
315
- cachedMessageId: found?.message?.id,
316
- rssId: found?.message?.rssId,
451
+ cachedMessageId: message?.id,
452
+ rssId: message?.rssId,
317
453
  });
318
- return `推送消息失败: ${error.message}`;
454
+ return `❌ 推送消息失败: ${error.message}`;
319
455
  }
320
456
  }
457
+ // ---------------------------------------------------------------------------
458
+ // queue handlers(业务逻辑不变)
459
+ // ---------------------------------------------------------------------------
321
460
  async function handleQueueStats(queueManager, config, logContext) {
322
461
  try {
323
462
  const stats = await queueManager.getStats();
324
463
  const total = stats.pending + stats.retry + stats.failed + stats.success;
325
- return `📊 发送队列统计\n\n待发送: ${stats.pending}\n🔄 等待重试: ${stats.retry}\n❌ 发送失败: ${stats.failed}\n✅ 发送成功: ${stats.success}\n\n📦 总计: ${total} 个任务`;
464
+ return `发送队列统计\n\n待发送: ${stats.pending}\n等待重试: ${stats.retry}\n❌ 发送失败: ${stats.failed}\n✅ 发送成功: ${stats.success}\n\n总计: ${total} 个任务`;
326
465
  }
327
466
  catch (error) {
328
- logCommandError(config, error, 'queue stats error', logContext);
329
- return `获取统计信息失败: ${error.message}`;
467
+ logCacheError(config, error, 'queue stats error', logContext);
468
+ return `❌ 获取统计信息失败: ${error.message}`;
330
469
  }
331
470
  }
332
471
  async function handleQueueRetry(queueManager, args, config, logContext) {
@@ -344,11 +483,11 @@ async function handleQueueRetry(queueManager, args, config, logContext) {
344
483
  const count = await queueManager.retryFailedTasks(id);
345
484
  return count > 0 ? `✅ 已重置任务 ${id}` : `❌ 未找到任务 ${id}`;
346
485
  }
347
- return '请指定任务ID或使用 --all 重试所有失败任务\n使用方法: rsso.queue retry <id|--all>';
486
+ return '请指定任务ID或使用 --all 重试所有失败任务\n用法: rsso.queue retry <id|--all>';
348
487
  }
349
488
  catch (error) {
350
- logCommandError(config, error, 'queue retry error', { ...logContext, taskId: args[0] });
351
- return `重试失败: ${error.message}`;
489
+ logCacheError(config, error, 'queue retry error', { ...logContext, taskId: args[0] });
490
+ return `❌ 重试失败: ${error.message}`;
352
491
  }
353
492
  }
354
493
  async function handleQueueCleanup(queueManager, args, config, logContext) {
@@ -361,25 +500,29 @@ async function handleQueueCleanup(queueManager, args, config, logContext) {
361
500
  return `✅ 已清理 ${count} 个超过 ${hours} 小时的成功任务`;
362
501
  }
363
502
  catch (error) {
364
- logCommandError(config, error, 'queue cleanup error', { ...logContext, hours: parseInt(args[0]) || 24 });
365
- return `清理失败: ${error.message}`;
503
+ logCacheError(config, error, 'queue cleanup error', { ...logContext, hours: parseInt(args[0]) || 24 });
504
+ return `❌ 清理失败: ${error.message}`;
366
505
  }
367
506
  }
368
- async function findCachedMessageBySerial(cache, serialNumber, limit = 10, maxPagesToSearch = 10) {
369
- let targetSerialNumber = serialNumber;
370
- for (let page = 1; page <= maxPagesToSearch; page++) {
371
- const offset = (page - 1) * limit;
372
- const messages = await cache.getMessages({ limit, offset });
373
- if (messages.length === 0)
374
- break;
375
- if (targetSerialNumber <= messages.length) {
376
- return { message: messages[targetSerialNumber - 1], page };
377
- }
378
- targetSerialNumber -= messages.length;
507
+ // ---------------------------------------------------------------------------
508
+ // 辅助函数
509
+ // ---------------------------------------------------------------------------
510
+ /**
511
+ * 按真实数据库 ID 取缓存消息,并校验其归属当前群组(防跨群越权)。
512
+ * 即使攻击者猜到其他群的 ID,也会因 platform/guildId 不匹配而被拦截。
513
+ */
514
+ async function resolveCacheMessageById(cmdCtx, realId) {
515
+ const { cache, platform, guildId } = cmdCtx;
516
+ const message = await cache.getMessage(realId);
517
+ if (!message)
518
+ return null;
519
+ // 归属校验:必须属于当前群组
520
+ if (message.platform !== platform || message.guildId !== guildId) {
521
+ return null;
379
522
  }
380
- return { message: null, page: 1 };
523
+ return message;
381
524
  }
382
- function logCommandError(config, error, scope, context) {
525
+ function logCacheError(config, error, scope, context) {
383
526
  const normalizedError = (0, error_handler_1.normalizeError)(error);
384
527
  (0, logger_1.debug)(config, normalizedError, scope, 'error', context);
385
528
  (0, error_tracker_1.trackError)(normalizedError, context);