koishi-plugin-rocom 1.0.18 → 1.0.19

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.
Files changed (57) hide show
  1. package/lib/announcement-service.d.ts +21 -0
  2. package/lib/announcement-service.js +63 -0
  3. package/lib/async-control.d.ts +3 -0
  4. package/lib/async-control.js +48 -0
  5. package/lib/client.d.ts +48 -7
  6. package/lib/client.js +244 -125
  7. package/lib/commands/egg.js +66 -4
  8. package/lib/commands/merchant.js +144 -146
  9. package/lib/commands/query.js +148 -117
  10. package/lib/commands/ranking.d.ts +2 -0
  11. package/lib/commands/ranking.js +31 -0
  12. package/lib/commands/share-code.d.ts +2 -0
  13. package/lib/commands/share-code.js +66 -0
  14. package/lib/commands/tools.js +47 -59
  15. package/lib/egg-service.d.ts +82 -8
  16. package/lib/egg-service.js +235 -19
  17. package/lib/img//345/274/202/350/211/262.png +0 -0
  18. package/lib/img//347/202/253/345/275/251.png +0 -0
  19. package/lib/index.d.ts +5 -0
  20. package/lib/index.js +16 -3
  21. package/lib/merchant-clock.d.ts +22 -0
  22. package/lib/merchant-clock.js +95 -0
  23. package/lib/merchant-service.d.ts +24 -0
  24. package/lib/merchant-service.js +148 -0
  25. package/lib/permissions.d.ts +16 -0
  26. package/lib/permissions.js +66 -0
  27. package/lib/pet-data-service.js +7 -9
  28. package/lib/pet-size.d.ts +13 -0
  29. package/lib/pet-size.js +54 -0
  30. package/lib/player-service.d.ts +37 -0
  31. package/lib/player-service.js +201 -0
  32. package/lib/ranking-service.d.ts +46 -0
  33. package/lib/ranking-service.js +137 -0
  34. package/lib/render-templates/announcement/detail.html +8 -0
  35. package/lib/render-templates/announcement/list.html +5 -0
  36. package/lib/render-templates/announcement/style.css +1 -0
  37. package/lib/render-templates/pet-ranking/index.html +99 -0
  38. package/lib/render-templates/pet-ranking/style.css +338 -0
  39. package/lib/render-templates/player-search/index.html +7 -0
  40. package/lib/render-templates/searcheggs/index.html +1 -1
  41. package/lib/render-templates/searcheggs/size.html +70 -68
  42. package/lib/render-templates/share-code-team/index.html +105 -0
  43. package/lib/render-templates/share-code-team/style.css +345 -0
  44. package/lib/render.d.ts +15 -2
  45. package/lib/render.js +120 -37
  46. package/lib/send-image.d.ts +1 -1
  47. package/lib/send-image.js +12 -1
  48. package/lib/share-code-service.d.ts +32 -0
  49. package/lib/share-code-service.js +184 -0
  50. package/lib/subscription-runner.d.ts +6 -0
  51. package/lib/subscription-runner.js +21 -0
  52. package/lib/subscription-send.d.ts +4 -2
  53. package/lib/subscription-send.js +45 -20
  54. package/lib/types.d.ts +5 -0
  55. package/lib/user.d.ts +5 -0
  56. package/package.json +4 -1
  57. package/readme.md +145 -124
@@ -111,10 +111,19 @@ function register(deps) {
111
111
  let fallback = '';
112
112
  if (height != null && weight != null) {
113
113
  const heightInMeters = heightMeters ?? height / 100;
114
- const backendResults = await client.queryPetSize(ctx, heightInMeters, weight, 'magic', 1, 30, session?.userId || '');
115
- if (backendResults) {
116
- data = eggService.buildSizeSearchDataFromApi(height, weight, backendResults, heightDisplay);
117
- fallback = eggService.buildSizeSearchTextFromApi(height, weight, backendResults, heightDisplay);
114
+ const userId = session?.userId || '';
115
+ // 后端查蛋优先,失败后回退 Wiki 尺寸接口,再回退本地引擎。成功空集不回退。
116
+ const eggApiResults = await client.searchEggBySize(ctx, heightInMeters, weight, 1, 30, userId);
117
+ if (eggApiResults) {
118
+ data = eggService.buildSizeSearchDataFromApi(height, weight, eggApiResults, heightDisplay);
119
+ fallback = eggService.buildSizeSearchTextFromApi(height, weight, eggApiResults, heightDisplay);
120
+ }
121
+ else {
122
+ const wikiResults = await client.queryPetSize(ctx, heightInMeters, weight, 'magic', 1, 30, userId);
123
+ if (wikiResults) {
124
+ data = eggService.buildSizeSearchDataFromApi(height, weight, wikiResults, heightDisplay);
125
+ fallback = eggService.buildSizeSearchTextFromApi(height, weight, wikiResults, heightDisplay);
126
+ }
118
127
  }
119
128
  }
120
129
  if (!data) {
@@ -128,6 +137,59 @@ function register(deps) {
128
137
  const name = nameParts.join(' ');
129
138
  if (!name)
130
139
  return '请输入精灵名称。用法:洛克.查蛋 <精灵名>';
140
+ // 后端查蛋优先(上游 v3.8.0):pet-groups → group-pets,接口不可用才回退 Wiki。
141
+ const userId = session?.userId || '';
142
+ const eggCandidatesRes = await client.getEggPetGroups(ctx, name, 20, userId);
143
+ if (eggCandidatesRes !== null) {
144
+ const eggCandidates = Array.isArray(eggCandidatesRes)
145
+ ? eggCandidatesRes
146
+ : (Array.isArray(eggCandidatesRes?.items) ? eggCandidatesRes.items : []);
147
+ if (!eggCandidates.length)
148
+ return `未找到名为「${name}」的精灵,请检查名称后重试。`;
149
+ let selected = eggCandidates.find((item) => {
150
+ const itemName = String(item?.name || '').trim();
151
+ const itemForm = String(item?.form || '').trim();
152
+ const display = itemForm && !itemName.includes(itemForm) ? `${itemName}(${itemForm})` : itemName;
153
+ return String(item?.id || '') === name
154
+ || itemName === name
155
+ || display === name
156
+ || (itemForm && `${itemName}${itemForm}` === name);
157
+ });
158
+ if (!selected && eggCandidates.length === 1)
159
+ selected = eggCandidates[0];
160
+ if (!selected) {
161
+ const data = eggService.buildCandidatesFromEggApi(name, eggCandidates);
162
+ const candidateText = [
163
+ `找到多个查蛋候选(${eggCandidates.length}),请使用更精确名称:`,
164
+ ...eggCandidates.slice(0, 10).map((item, index) => `${index + 1}. ${item?.name || '未知精灵'} #${item?.id || '-'}`),
165
+ ].join('\n');
166
+ await sendEggImage(deps, session, 'searcheggs/candidates', data, candidateText);
167
+ return;
168
+ }
169
+ const compatibleByGroup = {};
170
+ const groupIds = [];
171
+ for (const group of selected?.egg_groups || []) {
172
+ if (!group || typeof group !== 'object')
173
+ continue;
174
+ const groupId = group.group_id || group.id;
175
+ if (groupId)
176
+ groupIds.push(groupId);
177
+ }
178
+ if (groupIds.length) {
179
+ compatibleByGroup['__all__'] = await client.getEggGroupPets(ctx, groupIds, 'any', 1, 1, userId) || {};
180
+ }
181
+ for (const group of selected?.egg_groups || []) {
182
+ if (!group || typeof group !== 'object')
183
+ continue;
184
+ const groupId = group.group_id || group.id;
185
+ if (!groupId)
186
+ continue;
187
+ compatibleByGroup[String(groupId)] = await client.getEggGroupPets(ctx, [groupId], 'any', 1, 60, userId) || {};
188
+ }
189
+ const data = eggService.buildSearchDataFromEggApi(selected, compatibleByGroup);
190
+ await sendEggImage(deps, session, 'searcheggs', data, eggService.buildSearchTextFromWiki(data));
191
+ return;
192
+ }
131
193
  let backendDetail = null;
132
194
  let backendProfile = null;
133
195
  const backendList = await client.listWikiPets(ctx, name, 1, 10);
@@ -4,6 +4,12 @@ exports.register = register;
4
4
  const koishi_1 = require("koishi");
5
5
  const send_image_1 = require("../send-image");
6
6
  const subscription_send_1 = require("../subscription-send");
7
+ const merchant_service_1 = require("../merchant-service");
8
+ const merchant_clock_1 = require("../merchant-clock");
9
+ const permissions_1 = require("../permissions");
10
+ const subscription_runner_1 = require("../subscription-runner");
11
+ const node_async_hooks_1 = require("node:async_hooks");
12
+ const async_control_1 = require("../async-control");
7
13
  const logger = new koishi_1.Logger('rocom-merchant');
8
14
  const TEXT = {
9
15
  merchant: '\u8fdc\u884c\u5546\u4eba',
@@ -28,31 +34,21 @@ const ROUND_WINDOWS = [
28
34
  { id: 3, label: '16:00-20:00', startHour: 16, endHour: 20 },
29
35
  { id: 4, label: '20:00-24:00', startHour: 20, endHour: 24 },
30
36
  ];
31
- const CHINA_TIMEZONE = 'Asia/Shanghai';
32
- const chinaPartsFormatter = new Intl.DateTimeFormat('zh-CN', {
33
- timeZone: CHINA_TIMEZONE,
34
- year: 'numeric',
35
- month: '2-digit',
36
- day: '2-digit',
37
- hour: '2-digit',
38
- minute: '2-digit',
39
- second: '2-digit',
40
- hour12: false,
41
- });
37
+ const defaultClock = new merchant_clock_1.MerchantClock('Asia/Shanghai');
38
+ // Scoped to each synchronous view build; plugin instances never overwrite another clock.
39
+ const clockScope = new node_async_hooks_1.AsyncLocalStorage();
40
+ const currentClock = () => clockScope.getStore() || defaultClock;
42
41
  function getChinaParts(input = Date.now()) {
43
- const parts = {};
44
- const date = input instanceof Date ? input : new Date(input);
45
- for (const item of chinaPartsFormatter.formatToParts(date)) {
46
- if (item.type !== 'literal')
47
- parts[item.type] = item.value;
48
- }
42
+ const ms = input instanceof Date ? input.getTime() : input;
43
+ const parts = currentClock().parts(ms);
44
+ const [year, month, day] = parts.date.split('-').map(Number);
49
45
  return {
50
- year: Number(parts.year || '0'),
51
- month: Number(parts.month || '0'),
52
- day: Number(parts.day || '0'),
53
- hour: Number(parts.hour || '0'),
54
- minute: Number(parts.minute || '0'),
55
- second: Number(parts.second || '0'),
46
+ year: year || 0,
47
+ month: month || 0,
48
+ day: day || 0,
49
+ hour: parts.hour,
50
+ minute: parts.minute,
51
+ second: parts.second,
56
52
  };
57
53
  }
58
54
  function padNumber(value) {
@@ -62,10 +58,6 @@ function getChinaDateText(input = Date.now()) {
62
58
  const parts = getChinaParts(input);
63
59
  return `${parts.year}-${padNumber(parts.month)}-${padNumber(parts.day)}`;
64
60
  }
65
- function getChinaDayStartMs(input = Date.now()) {
66
- const parts = getChinaParts(input);
67
- return new Date(`${parts.year}-${padNumber(parts.month)}-${padNumber(parts.day)}T00:00:00+08:00`).getTime();
68
- }
69
61
  function classifyMerchantItem(item) {
70
62
  const start = normalizeTimestamp(item?.start_time);
71
63
  const end = normalizeTimestamp(item?.end_time);
@@ -115,34 +107,6 @@ function getMerchantActivity(res) {
115
107
  const activities = res?.merchantActivities || res?.merchant_activities || [];
116
108
  return activities[0] || {};
117
109
  }
118
- function getMerchantProducts(res) {
119
- const activity = getMerchantActivity(res);
120
- const groups = [];
121
- if (Array.isArray(activity?.products))
122
- groups.push(activity.products);
123
- if (Array.isArray(activity?.product_list))
124
- groups.push(activity.product_list);
125
- if (Array.isArray(activity?.get_props))
126
- groups.push(activity.get_props);
127
- if (Array.isArray(activity?.get_extra_props))
128
- groups.push(activity.get_extra_props);
129
- if (Array.isArray(activity?.get_pets))
130
- groups.push(activity.get_pets);
131
- const merged = [];
132
- const seen = new Set();
133
- for (const list of groups) {
134
- for (const item of list) {
135
- const start = normalizeTimestamp(item?.start_time) ?? 0;
136
- const end = normalizeTimestamp(item?.end_time) ?? Infinity;
137
- const key = `${item?.id ?? ''}|${item?.name ?? ''}|${start}|${end === Infinity ? 'inf' : end}`;
138
- if (seen.has(key))
139
- continue;
140
- seen.add(key);
141
- merged.push(item);
142
- }
143
- }
144
- return merged;
145
- }
146
110
  function isMerchantItemActive(item, now = Date.now()) {
147
111
  const nowMs = now instanceof Date ? now.getTime() : now;
148
112
  const start = normalizeTimestamp(item?.start_time);
@@ -154,8 +118,7 @@ function isMerchantItemToday(item, now = Date.now()) {
154
118
  const end = normalizeTimestamp(item?.end_time);
155
119
  if (start === null || end === null)
156
120
  return true;
157
- const startOfDay = getChinaDayStartMs(now);
158
- const endOfDay = startOfDay + 24 * 60 * 60 * 1000;
121
+ const { start: startOfDay, end: endOfDay } = currentClock().dayBounds(now instanceof Date ? now.getTime() : now);
159
122
  return start < endOfDay && end > startOfDay;
160
123
  }
161
124
  function getRoundForItem(item, now = Date.now()) {
@@ -220,61 +183,49 @@ function parseMerchantSubscriptionArgs(args, defaultItems) {
220
183
  }
221
184
  function getSubscriptionTarget(session) {
222
185
  const platform = session.platform || session.bot?.platform || '';
223
- const privateChat = !session.guildId;
186
+ const privateChat = (0, permissions_1.isDirectSession)(session);
224
187
  const channelId = session.channelId || session.guildId || session.userId || '';
225
- const key = privateChat ? `private_${session.userId}` : session.guildId;
188
+ const key = privateChat ? `private_${session.userId}` : (session.guildId || channelId);
226
189
  return { key, platform, channelId, privateChat };
227
190
  }
228
191
  function isBotAdmin(session, adminUserIds) {
229
192
  return adminUserIds.includes(session?.userId || '');
230
193
  }
194
+ // 订阅管理权限:群主/管理员与 Bot 管理员按独立开关生效(上游 v4.0)。
195
+ function canManageGroupSubscription(deps, session) {
196
+ return (0, permissions_1.canManageSubscription)(deps.config, session);
197
+ }
231
198
  function sameStringArray(left, right) {
232
199
  return JSON.stringify(left) === JSON.stringify(right);
233
200
  }
234
- function getRandomGoodsMaps(res) {
235
- const priceMap = new Map();
236
- const limitMap = new Map();
237
- const randomGoods = Array.isArray(res?.random_goods)
238
- ? res.random_goods
239
- : Array.isArray(res?.randomGoods)
240
- ? res.randomGoods
241
- : [];
242
- for (const item of randomGoods) {
243
- const name = String(item?.goods_name || item?.name || '').trim();
244
- if (!name)
245
- continue;
246
- if (item?.price !== undefined && item?.price !== null && item.price !== '')
247
- priceMap.set(name, item.price);
248
- if (item?.buy_limit_num !== undefined && item?.buy_limit_num !== null && item.buy_limit_num !== '') {
249
- limitMap.set(name, item.buy_limit_num);
250
- }
251
- }
252
- return { priceMap, limitMap };
253
- }
254
201
  function normalizeMerchantProducts(res, now = new Date()) {
255
- return getMerchantProducts(res).map((item) => {
256
- const name = String(item?.name || item?.goods_name || TEXT.unknown).trim() || TEXT.unknown;
202
+ const nowMs = now.getTime();
203
+ const { items } = (0, merchant_service_1.normalizeMerchant)(res, nowMs);
204
+ return items.map((item) => {
205
+ const source = { start_time: item.startsAt, end_time: item.endsAt };
257
206
  return {
258
- name,
259
- image: String(item?.icon_url || item?.iconUrl || ''),
260
- time_label: formatProductWindow(item),
261
- category: classifyMerchantItem(item),
262
- round_id: getRoundForItem(item, now),
263
- is_active: isMerchantItemActive(item, now),
264
- start_time: normalizeTimestamp(item?.start_time),
265
- end_time: normalizeTimestamp(item?.end_time),
207
+ name: item.name,
208
+ image: item.icon,
209
+ time_label: formatProductWindow(source),
210
+ category: classifyMerchantItem(source),
211
+ round_id: getRoundForItem(source, now),
212
+ is_active: item.active,
213
+ start_time: item.startsAt,
214
+ end_time: item.endsAt,
215
+ price: item.price,
216
+ limit: item.limit,
217
+ item_category: item.category,
266
218
  };
267
219
  });
268
220
  }
269
- function buildMerchantCardItems(products, res, options) {
270
- const { priceMap, limitMap } = getRandomGoodsMaps(res);
221
+ function buildMerchantCardItems(products, options) {
271
222
  const catOrder = { round: 0, normal: 1, weekend: 2 };
272
223
  const startY = 592;
273
224
  const cardHeight = 308;
274
225
  const gap = 43;
275
226
  const goodsAll = products.map((product) => {
276
- const limit = limitMap.get(product.name);
277
- const limitText = limit === undefined || limit === null || limit === '' ? '--' : String(limit);
227
+ const limit = product.limit;
228
+ const limitText = limit === undefined || limit === null ? '--' : String(limit);
278
229
  const isHot = product.category !== 'round';
279
230
  const isEnded = options.includeEnded ? !product.is_active : false;
280
231
  const roundPrefix = product.round_id ? `第${product.round_id}轮·` : '';
@@ -286,7 +237,7 @@ function buildMerchantCardItems(products, res, options) {
286
237
  return {
287
238
  goods_name: product.name,
288
239
  iconUrl: product.image,
289
- price: priceMap.get(product.name) ?? 0,
240
+ price: product.price ?? 0,
290
241
  num: '',
291
242
  category: product.category,
292
243
  roundId: product.round_id || 0,
@@ -397,7 +348,7 @@ function buildMerchantRenderPayload(res, now = new Date()) {
397
348
  const data = {
398
349
  dateStr: getMerchantDateStr(now),
399
350
  timeRange,
400
- ...buildMerchantCardItems(products, res, { includeEnded: false }),
351
+ ...buildMerchantCardItems(products, { includeEnded: false }),
401
352
  };
402
353
  const fallback = buildMerchantFallbackText(TEXT.merchant, products, roundInfo);
403
354
  return { products, roundInfo, data, fallback };
@@ -432,7 +383,7 @@ function buildTodayMerchantRenderPayload(res, now = new Date()) {
432
383
  });
433
384
  const data = {
434
385
  dateStr: getMerchantDateStr(now),
435
- ...buildMerchantCardItems(products, res, { includeEnded: true }),
386
+ ...buildMerchantCardItems(products, { includeEnded: true }),
436
387
  };
437
388
  const fallback = buildMerchantFallbackText(`今日远行商人 (${getChinaDateText(now)})`, products);
438
389
  return { products, data, fallback };
@@ -472,36 +423,52 @@ function useLegacyMerchantUi(deps) {
472
423
  return deps.config.merchantUiStyle === 'old';
473
424
  }
474
425
  function buildConfiguredMerchantRenderPayload(deps, res, now = new Date()) {
475
- const payload = useLegacyMerchantUi(deps)
476
- ? buildLegacyMerchantRenderPayload(res, now)
477
- : buildMerchantRenderPayload(res, now);
478
- return {
479
- ...payload,
480
- templateName: useLegacyMerchantUi(deps) ? 'yuanxing-shangren' : 'yuanxing-shangren/merchant',
481
- };
426
+ return clockScope.run(new merchant_clock_1.MerchantClock(deps.config.merchantTimezone || 'Asia/Shanghai'), () => {
427
+ const payload = useLegacyMerchantUi(deps)
428
+ ? buildLegacyMerchantRenderPayload(res, now)
429
+ : buildMerchantRenderPayload(res, now);
430
+ return {
431
+ ...payload,
432
+ templateName: useLegacyMerchantUi(deps) ? 'yuanxing-shangren' : 'yuanxing-shangren/merchant',
433
+ };
434
+ });
482
435
  }
483
436
  function buildConfiguredTodayMerchantRenderPayload(deps, res, now = new Date()) {
484
- const payload = useLegacyMerchantUi(deps)
485
- ? buildLegacyTodayMerchantRenderPayload(res, now)
486
- : buildTodayMerchantRenderPayload(res, now);
487
- return {
488
- ...payload,
489
- templateName: useLegacyMerchantUi(deps) ? 'yuanxing-shangren' : 'yuanxing-shangren/today',
490
- };
437
+ return clockScope.run(new merchant_clock_1.MerchantClock(deps.config.merchantTimezone || 'Asia/Shanghai'), () => {
438
+ const payload = useLegacyMerchantUi(deps)
439
+ ? buildLegacyTodayMerchantRenderPayload(res, now)
440
+ : buildTodayMerchantRenderPayload(res, now);
441
+ return {
442
+ ...payload,
443
+ templateName: useLegacyMerchantUi(deps) ? 'yuanxing-shangren' : 'yuanxing-shangren/today',
444
+ };
445
+ });
491
446
  }
492
- async function checkMerchantSubscriptions(deps) {
447
+ async function checkMerchantSubscriptions(deps, signal) {
493
448
  const { ctx, client, merchantSubMgr, renderer, config } = deps;
494
- const res = await client.getMerchantInfo(ctx, true);
449
+ const subs = merchantSubMgr.getAll();
450
+ if (!Object.keys(subs).length)
451
+ return { subscriptions: 0, matched: 0, pushed: 0, kind: 'no-subscriptions' };
452
+ if (signal?.aborted)
453
+ return { subscriptions: 0, matched: 0, pushed: 0, kind: 'cancelled' };
454
+ const res = await client.getMerchantInfo(ctx, true, { signal });
455
+ if (signal?.aborted)
456
+ return { subscriptions: 0, matched: 0, pushed: 0, kind: 'cancelled' };
495
457
  if (!res)
496
- return { subscriptions: 0, matched: 0, pushed: 0 };
458
+ return { subscriptions: Object.keys(subs).length, matched: 0, pushed: 0, kind: 'error' };
497
459
  const { products, roundInfo, data, fallback, templateName } = buildConfiguredMerchantRenderPayload(deps, res);
460
+ if (!products.length)
461
+ return { subscriptions: Object.keys(subs).length, matched: 0, pushed: 0, kind: 'empty' };
498
462
  const productNames = products.map((p) => p.name || '').filter(Boolean);
499
- const rendered = await renderer.renderHtml(ctx, templateName, data);
463
+ const rendered = await renderer.renderHtml(ctx, templateName, data, { signal });
500
464
  const renderedImage = rendered ? (0, send_image_1.compressPngImage)(rendered, config) : null;
501
- const subs = merchantSubMgr.getAll();
502
465
  let matchedCount = 0;
503
466
  let pushedCount = 0;
504
467
  for (const [key, sub] of Object.entries(subs)) {
468
+ if (signal?.aborted)
469
+ break;
470
+ if (JSON.stringify(merchantSubMgr.getAll()[key]) !== JSON.stringify(sub))
471
+ continue;
505
472
  const matchAll = !!sub.match_all;
506
473
  const matched = matchAll
507
474
  ? productNames
@@ -534,8 +501,9 @@ async function checkMerchantSubscriptions(deps) {
534
501
  channelId,
535
502
  guildId: sub.group_id || '',
536
503
  userId: sub.user_id || '',
537
- }, renderedImage, fallbackText, !!sub.mention_all);
538
- if (!sent)
504
+ selfId: sub.self_id || '',
505
+ }, renderedImage, fallbackText, !!sub.mention_all, signal);
506
+ if (!sent || signal?.aborted || JSON.stringify(merchantSubMgr.getAll()[key]) !== JSON.stringify(sub))
539
507
  continue;
540
508
  pushedCount++;
541
509
  merchantSubMgr.upsert(key, {
@@ -544,12 +512,15 @@ async function checkMerchantSubscriptions(deps) {
544
512
  last_matched_items: matched,
545
513
  });
546
514
  }
547
- return { subscriptions: Object.keys(subs).length, matched: matchedCount, pushed: pushedCount };
515
+ return { subscriptions: Object.keys(subs).length, matched: matchedCount, pushed: pushedCount, kind: 'success' };
548
516
  }
549
517
  function register(deps) {
518
+ const merchantRunner = (0, subscription_runner_1.createRunner)(deps.ctx);
519
+ const merchantClock = new merchant_clock_1.MerchantClock(deps.config.merchantTimezone || 'Asia/Shanghai');
550
520
  const { ctx, config, client, merchantSubMgr } = deps;
551
521
  ctx.command(TEXT.merchant, '\u67e5\u770b\u8fdc\u884c\u5546\u4eba\u5546\u54c1')
552
522
  .alias('yxsr')
523
+ .userFields(['authority'])
553
524
  .action(async ({ session }) => {
554
525
  const res = await client.getMerchantInfo(ctx, true);
555
526
  if (!res)
@@ -559,6 +530,7 @@ function register(deps) {
559
530
  await (0, send_image_1.sendImageWithFallback)(session, png, fallback, 'merchant:yuanxing-shangren', deps.config);
560
531
  });
561
532
  ctx.command(TEXT.todayMerchant, '查看今日远行商人全部商品')
533
+ .userFields(['authority'])
562
534
  .action(async ({ session }) => {
563
535
  const res = await client.getMerchantInfo(ctx, true);
564
536
  if (!res)
@@ -568,10 +540,11 @@ function register(deps) {
568
540
  await (0, send_image_1.sendImageWithFallback)(session, png, fallback, 'merchant:yuanxing-shangren:today', deps.config);
569
541
  });
570
542
  ctx.command(`${TEXT.subscribe} [args:text]`, '\u8ba2\u9605\u8fdc\u884c\u5546\u4eba\u5546\u54c1\u63d0\u9192')
543
+ .userFields(['authority'])
571
544
  .action(async ({ session }, args) => {
572
545
  const target = getSubscriptionTarget(session);
573
- if (!target.privateChat && !isBotAdmin(session, config.adminUserIds))
574
- return '此指令仅限管理员使用。';
546
+ if (!target.privateChat && !canManageGroupSubscription(deps, session))
547
+ return '此指令仅限群管理员或机器人管理员使用。';
575
548
  if (target.privateChat && !config.merchantPrivateSubscriptionEnabled)
576
549
  return '个人私聊订阅功能已被禁用,请联系机器人管理员。';
577
550
  const parsed = parseMerchantSubscriptionArgs(args, config.merchantSubscriptionItems);
@@ -582,6 +555,7 @@ function register(deps) {
582
555
  type: target.privateChat ? '个人订阅' : '群订阅',
583
556
  channel_id: target.channelId,
584
557
  platform: target.platform,
558
+ self_id: session.selfId || session.bot?.selfId || '',
585
559
  items: parsed.items,
586
560
  match_all: parsed.match_all,
587
561
  mention_all: target.privateChat ? false : parsed.mention_all,
@@ -592,10 +566,11 @@ function register(deps) {
592
566
  return `\u2705 \u5df2\u8ba2\u9605\u8fdc\u884c\u5546\u4eba\u5546\u54c1\uff1a${parsed.match_all ? '\u5168\u90e8\u5546\u54c1\uff08\u6bcf\u8f6e\u63a8\u9001\uff09' : parsed.items.join('\u3001')}\uff08${parsed.source}\uff09\uff1b${target.privateChat ? '个人订阅' : (parsed.mention_all ? '\u547d\u4e2d\u540e\u4f1a @\u5168\u4f53' : '\u547d\u4e2d\u540e\u4e0d @\u5168\u4f53')}`;
593
567
  });
594
568
  ctx.command(TEXT.viewSubscribe, '\u67e5\u770b\u5f53\u524d\u4f1a\u8bdd\u7684\u8fdc\u884c\u5546\u4eba\u8ba2\u9605')
569
+ .userFields(['authority'])
595
570
  .action(async ({ session }) => {
596
571
  const target = getSubscriptionTarget(session);
597
- if (!target.privateChat && !isBotAdmin(session, config.adminUserIds))
598
- return '此指令仅限管理员使用。';
572
+ if (!target.privateChat && !canManageGroupSubscription(deps, session))
573
+ return '此指令仅限群管理员或机器人管理员使用。';
599
574
  const sub = merchantSubMgr.get(target.key);
600
575
  const scopeName = target.privateChat ? '你' : '当前群组';
601
576
  if (!sub)
@@ -604,48 +579,71 @@ function register(deps) {
604
579
  return `${scopeName}订阅商品:${itemsText}\n提醒方式:${target.privateChat ? '私聊提醒' : (sub.mention_all ? '@全体' : '普通提醒')}`;
605
580
  });
606
581
  ctx.command(TEXT.unsubscribe, '\u53d6\u6d88\u8fdc\u884c\u5546\u4eba\u8ba2\u9605')
582
+ .userFields(['authority'])
607
583
  .action(async ({ session }) => {
608
584
  const target = getSubscriptionTarget(session);
609
- if (!target.privateChat && !isBotAdmin(session, config.adminUserIds))
610
- return '此指令仅限管理员使用。';
585
+ if (!target.privateChat && !canManageGroupSubscription(deps, session))
586
+ return '此指令仅限群管理员或机器人管理员使用。';
611
587
  merchantSubMgr.delete(target.key);
612
588
  return '\u2705 \u5df2\u53d6\u6d88\u8fdc\u884c\u5546\u4eba\u8ba2\u9605\u3002';
613
589
  });
614
590
  ctx.command('洛克').subcommand('.调试远行商人订阅', '立即执行一次远行商人订阅检查')
615
591
  .alias('洛克调试远行商人订阅')
592
+ .userFields(['authority'])
616
593
  .action(async ({ session }) => {
617
594
  if (!isBotAdmin(session, config.adminUserIds))
618
595
  return '此指令仅限管理员使用。';
619
- const result = await checkMerchantSubscriptions(deps);
596
+ const result = await merchantRunner(signal => checkMerchantSubscriptions(deps, signal));
597
+ if (!result)
598
+ return '订阅检查正在运行或插件已停止。';
620
599
  return `远行商人订阅检查完成:订阅 ${result.subscriptions} 条,命中 ${result.matched} 条,推送 ${result.pushed} 条。`;
621
600
  });
601
+ const roundKey = () => {
602
+ const p = merchantClock.parts();
603
+ return p.date + ':' + Math.floor(p.hour / 4);
604
+ };
605
+ const runMerchantCheck = () => merchantRunner(async (signal) => {
606
+ const round = roundKey();
607
+ for (let retry = 0; retry <= 3; retry++) {
608
+ if (signal.aborted || (retry > 0 && roundKey() !== round))
609
+ return;
610
+ if (!Object.keys(merchantSubMgr.getAll()).length)
611
+ return;
612
+ const result = await checkMerchantSubscriptions(deps, signal);
613
+ if (result.kind !== 'empty' || retry === 3)
614
+ return;
615
+ await (0, async_control_1.delay)(240000 + Math.floor(Math.random() * 60001) - 30000, signal);
616
+ }
617
+ }).catch(err => { if (!merchantRunner.aborted())
618
+ logger.warn('远行商人订阅检查失败: ' + err); });
622
619
  if (config.merchantSubscriptionEnabled) {
623
- // 上游 v3.1.0:检查前加入 ±30s 随机延迟,降低刷新窗口和并发请求冲突。
624
- const MERCHANT_JITTER_MS = 30000;
625
- const runCheckWithJitter = () => {
626
- const jitter = Math.floor(Math.random() * MERCHANT_JITTER_MS);
627
- setTimeout(() => {
628
- checkMerchantSubscriptions(deps).catch(err => logger.warn(`远行商人订阅检查失败: ${err}`));
629
- }, jitter);
630
- };
631
620
  if (config.merchantCheckMode === 'times' && config.merchantCheckTimes.length > 0) {
632
- let lastMerchantCheckKey = '';
633
- ctx.setInterval(async () => {
634
- const now = new Date();
635
- const timeStr = `${String(now.getHours()).padStart(2, '0')}:${String(now.getMinutes()).padStart(2, '0')}`;
636
- if (!config.merchantCheckTimes.includes(timeStr))
621
+ const times = config.merchantCheckTimes.filter(value => /^([01]\d|2[0-3]):[0-5]\d$/.test(value));
622
+ let lastWallKey = '';
623
+ const schedule = (after = Date.now()) => {
624
+ if (merchantRunner.aborted())
625
+ return;
626
+ const target = merchantClock.nextTimeFor(times, after);
627
+ if (target === null)
637
628
  return;
638
- const checkKey = `${now.toDateString()}-${timeStr}`;
639
- if (checkKey === lastMerchantCheckKey)
629
+ const wallKey = merchantClock.date(target) + ':' + merchantClock.timeText(target);
630
+ if (wallKey === lastWallKey) {
631
+ schedule(target);
640
632
  return;
641
- lastMerchantCheckKey = checkKey;
642
- runCheckWithJitter();
643
- }, 60000);
633
+ }
634
+ const jitter = Math.floor(Math.random() * 60001) - 30000;
635
+ ctx.setTimeout(() => {
636
+ lastWallKey = wallKey;
637
+ void runMerchantCheck();
638
+ schedule(target);
639
+ }, Math.max(0, target + jitter - Date.now()));
640
+ };
641
+ schedule();
644
642
  }
645
643
  else {
646
- ctx.setInterval(async () => {
647
- runCheckWithJitter();
648
- }, config.merchantCheckInterval);
644
+ ctx.setInterval(() => {
645
+ ctx.setTimeout(() => { void runMerchantCheck(); }, Math.floor(Math.random() * 30001));
646
+ }, Math.max(30000, config.merchantCheckInterval || 300000));
649
647
  }
650
648
  }
651
649
  }