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
package/lib/client.js CHANGED
@@ -1,12 +1,22 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.RocomClient = void 0;
3
+ exports.RocomClient = exports.isObject = void 0;
4
+ exports.inheritGoodsMapping = inheritGoodsMapping;
4
5
  const koishi_1 = require("koishi");
6
+ const async_control_1 = require("./async-control");
5
7
  const logger = new koishi_1.Logger('rocom-client');
6
8
  // ingame 异步任务状态分类(来自服务端 /ingame/tasks/{task_id} 返回的 status 字段)
7
9
  const INGAME_PENDING_STATUSES = ['queued', 'pending', 'running', 'processing', 'accepted'];
8
10
  const INGAME_FAILED_STATUSES = ['failed', 'error', 'timeout', 'cancelled', 'canceled'];
9
11
  const INGAME_COMPLETED_STATUSES = ['done', 'success', 'succeeded', 'completed', 'finished'];
12
+ const isObject = (v) => v !== null && typeof v === 'object' && !Array.isArray(v);
13
+ exports.isObject = isObject;
14
+ // 每剥离一层 envelope 时继承 goods_mapping,避免只改商品渲染后拿不到名称/图标(上游 v4.1.0)。
15
+ function inheritGoodsMapping(parent, child) {
16
+ const mapping = child._goods_mapping ?? child.goods_mapping
17
+ ?? parent._goods_mapping ?? parent.goods_mapping;
18
+ return mapping === undefined ? { ...child } : { ...child, _goods_mapping: mapping };
19
+ }
10
20
  class RocomClient {
11
21
  baseUrl;
12
22
  apiKey;
@@ -23,6 +33,12 @@ class RocomClient {
23
33
  return '';
24
34
  return uid.trim().replace(/[^a-zA-Z0-9_\- \u4e00-\u9fa5]/g, '').trim();
25
35
  }
36
+ // HTTP/任务 envelope 的 goods_mapping 复制到业务对象,供商品名称与图标解析。
37
+ attachGoodsMapping(resp, data) {
38
+ if ((0, exports.isObject)(resp) && (0, exports.isObject)(data))
39
+ return inheritGoodsMapping(resp, data);
40
+ return data;
41
+ }
26
42
  wegameHeaders(fwToken = '', userIdentifier = '', clientType = '', clientId = '', includeApiKey = true) {
27
43
  const headers = {};
28
44
  if (includeApiKey && this.apiKey)
@@ -192,6 +208,7 @@ class RocomClient {
192
208
  headers,
193
209
  params,
194
210
  timeout: this.timeout,
211
+ signal: options?.signal,
195
212
  });
196
213
  if (resp?.code !== 0) {
197
214
  this.setLastError(resp?.message || '接口返回异常');
@@ -201,7 +218,7 @@ class RocomClient {
201
218
  }
202
219
  return null;
203
220
  }
204
- return resp?.data ?? {};
221
+ return this.attachGoodsMapping(resp, resp?.data ?? {});
205
222
  }
206
223
  catch (e) {
207
224
  const message = this.formatHttpError(e);
@@ -219,12 +236,13 @@ class RocomClient {
219
236
  return null;
220
237
  }
221
238
  }
222
- async post(ctx, path, headers, json, params) {
239
+ async post(ctx, path, headers, json, params, signal) {
223
240
  try {
224
241
  const resp = await ctx.http.post(`${this.baseUrl}${path}`, json, {
225
242
  headers,
226
243
  params,
227
244
  timeout: this.timeout,
245
+ signal,
228
246
  });
229
247
  if (resp?.code !== 0) {
230
248
  this.setLastError(resp?.message || '接口返回异常');
@@ -232,7 +250,7 @@ class RocomClient {
232
250
  this.logRequestFailureDetails('POST', path, headers, params, json, resp);
233
251
  return null;
234
252
  }
235
- return resp?.data ?? {};
253
+ return this.attachGoodsMapping(resp, resp?.data ?? {});
236
254
  }
237
255
  catch (e) {
238
256
  const message = this.formatHttpError(e);
@@ -263,7 +281,7 @@ class RocomClient {
263
281
  this.logRequestFailureDetails('DELETE', path, headers, params, undefined, resp);
264
282
  return null;
265
283
  }
266
- return resp?.data ?? {};
284
+ return this.attachGoodsMapping(resp, resp?.data ?? {});
267
285
  }
268
286
  catch (e) {
269
287
  const message = this.formatHttpError(e);
@@ -287,14 +305,16 @@ class RocomClient {
287
305
  headers,
288
306
  params: options.params,
289
307
  data: options.json,
290
- timeout: this.timeout,
308
+ timeout: Math.max(this.timeout, Number(options.json?.wait_ms ?? options.params?.wait_ms ?? 0) + 2000),
309
+ signal: options.signal,
291
310
  validateStatus: () => true,
292
311
  });
293
- const status = Number(response?.status ?? response?.statusCode ?? 200);
312
+ const rawStatus = Number(response?.status);
313
+ const status = Number.isFinite(rawStatus) ? rawStatus : null;
294
314
  const body = response?.data !== undefined ? response.data : response;
295
315
  if (body?.code !== undefined && body.code !== 0) {
296
316
  const rawMessage = body.message || body.msg || '接口返回异常';
297
- const message = !acceptedStatuses.includes(status)
317
+ const message = status !== null && !acceptedStatuses.includes(status)
298
318
  ? `HTTP ${status}: ${rawMessage}`
299
319
  : rawMessage;
300
320
  this.setLastError(message);
@@ -303,9 +323,9 @@ class RocomClient {
303
323
  }
304
324
  return { status: null, data: null };
305
325
  }
306
- const data = body?.code !== undefined ? (body.data ?? {}) : (body ?? {});
307
- if (!acceptedStatuses.includes(status)) {
308
- this.setLastError(`HTTP ${status}`);
326
+ const data = body?.code !== undefined ? this.attachGoodsMapping(body, body.data ?? {}) : (body ?? {});
327
+ if (status === null || !acceptedStatuses.includes(status)) {
328
+ this.setLastError(status === null ? 'HTTP 响应缺少状态码' : `HTTP ${status}`);
309
329
  if (!options.silentFailureDetails) {
310
330
  this.logRequestFailureDetails(method, path, headers, options.params, options.json, response);
311
331
  }
@@ -324,18 +344,21 @@ class RocomClient {
324
344
  }
325
345
  async requestIngameWithFallback(ctx, path, payload, options = {}) {
326
346
  const acceptedStatuses = options.acceptedStatuses || [200, 202];
347
+ const auth = options.auth || {};
327
348
  const requestOnce = async (includeApiKey, silentFailureDetails) => {
328
- const headers = this.wegameHeaders('', '', '', '', includeApiKey);
349
+ const headers = this.wegameHeaders(auth.fwToken || '', auth.userIdentifier || '', '', '', includeApiKey);
329
350
  let result = await this.requestWithStatus(ctx, 'POST', path, headers, {
330
351
  json: payload,
331
352
  acceptedStatuses,
332
353
  silentFailureDetails,
354
+ signal: options.signal,
333
355
  });
334
- if (result.status === null) {
356
+ if (result.status === null && !options.signal?.aborted) {
335
357
  result = await this.requestWithStatus(ctx, 'GET', path, headers, {
336
358
  params: payload,
337
359
  acceptedStatuses,
338
360
  silentFailureDetails,
361
+ signal: options.signal,
339
362
  });
340
363
  }
341
364
  return result;
@@ -343,15 +366,15 @@ class RocomClient {
343
366
  let result = await requestOnce(false, Boolean(this.apiKey));
344
367
  if (result.status !== null)
345
368
  return { ...result, usedApiKey: false };
346
- if (this.shouldRetryIngameWithApiKey(this.getLastError())) {
369
+ if (!options.signal?.aborted && this.shouldRetryIngameWithApiKey(this.getLastError())) {
347
370
  result = await requestOnce(true, false);
348
371
  if (result.status !== null)
349
372
  return { ...result, usedApiKey: true };
350
373
  }
351
374
  return { status: null, data: null, usedApiKey: false };
352
375
  }
353
- async getIngameTask(ctx, taskId, includeApiKey = true) {
354
- return this.requestWithStatus(ctx, 'GET', `/api/v1/games/rocom/ingame/tasks/${taskId}`, this.wegameHeaders('', '', '', '', includeApiKey), { acceptedStatuses: [200, 202] });
376
+ async getIngameTask(ctx, taskId, includeApiKey = true, auth = {}, signal) {
377
+ return this.requestWithStatus(ctx, 'GET', `/api/v1/games/rocom/ingame/tasks/${taskId}`, this.wegameHeaders(auth.fwToken || '', auth.userIdentifier || '', '', '', includeApiKey), { acceptedStatuses: [200, 202], signal });
355
378
  }
356
379
  static normalizeTaskStatus(value) {
357
380
  return String(value ?? '').trim().toLowerCase();
@@ -361,7 +384,8 @@ class RocomClient {
361
384
  }
362
385
  // 判断一份 payload 是否为“已完成的业务结果”(而非排队占位)。
363
386
  // 家园接口完成时返回 rows/home_info;玩家、商店完成时返回 source/title/rows;
364
- // 家园详情(pet/data)完成时返回 npc_pets/npc_pet。
387
+ // 家园详情(pet/data)完成时返回 npc_pets/npc_pet;
388
+ // v4.1 玩家嵌套结构返回 player_info/player_card_brief_info,实时商人返回 goods/shop/meta。
365
389
  static isCompletedGatewayPayload(payload) {
366
390
  if (!payload || typeof payload !== 'object')
367
391
  return false;
@@ -369,26 +393,46 @@ class RocomClient {
369
393
  return true;
370
394
  if (payload.home_info !== undefined)
371
395
  return true;
372
- if (payload.source !== undefined)
373
- return true;
374
396
  if (Array.isArray(payload.npc_pets))
375
397
  return true;
376
398
  if (payload.npc_pet && typeof payload.npc_pet === 'object')
377
399
  return true;
378
- if (String(payload.title || '').trim())
400
+ if (payload.player_info && typeof payload.player_info === 'object')
401
+ return true;
402
+ if (payload.player_card_brief_info && typeof payload.player_card_brief_info === 'object')
403
+ return true;
404
+ if (Array.isArray(payload.goods))
405
+ return true;
406
+ if (payload.shop && typeof payload.shop === 'object')
407
+ return true;
408
+ if (payload.meta && typeof payload.meta === 'object' && Array.isArray(payload.goods))
379
409
  return true;
380
410
  return false;
381
411
  }
382
- static extractCompletedIngamePayload(payload) {
383
- if (!payload || typeof payload !== 'object')
384
- return null;
412
+ static inspectIngamePayload(payload, depth = 0, completed = false) {
413
+ if (!(0, exports.isObject)(payload) || depth > 6)
414
+ return { error: '任务返回结构无法解析' };
415
+ const state = RocomClient.normalizeTaskStatus(payload.status);
416
+ if (INGAME_FAILED_STATUSES.includes(state))
417
+ return { error: RocomClient.taskErrorMessage(payload, state) };
418
+ if (INGAME_PENDING_STATUSES.includes(state))
419
+ return { pending: true };
420
+ if (state && !INGAME_COMPLETED_STATUSES.includes(state))
421
+ return { error: 'Ingame 任务状态异常:' + state };
422
+ completed ||= INGAME_COMPLETED_STATUSES.includes(state);
385
423
  if (RocomClient.isCompletedGatewayPayload(payload))
386
- return payload;
387
- if (RocomClient.isCompletedGatewayPayload(payload.result))
388
- return payload.result;
389
- if (RocomClient.isCompletedGatewayPayload(payload.data))
390
- return payload.data;
391
- return null;
424
+ return { value: payload };
425
+ for (const key of ['result', 'data']) {
426
+ if ((0, exports.isObject)(payload[key]))
427
+ return RocomClient.inspectIngamePayload(inheritGoodsMapping(payload, payload[key]), depth + 1, completed);
428
+ }
429
+ if (completed)
430
+ return { error: '任务已完成但未返回可解析结果' };
431
+ if (state)
432
+ return { error: 'Ingame 任务状态异常:' + state };
433
+ if (RocomClient.extractTaskId(payload))
434
+ return { pending: true };
435
+ return { value: payload };
392
436
  }
393
437
  static taskErrorMessage(payload, fallbackStatus = '') {
394
438
  const candidates = [
@@ -405,64 +449,65 @@ class RocomClient {
405
449
  }
406
450
  // 排队等候轮询:把 ingame 接口提交后返回的 202(task_id/status=queued)轮询到出结果或超时。
407
451
  // 入参 first 是首请求拿到的 { status, data, usedApiKey }。
408
- async pollIngameTask(ctx, first, options, labels) {
409
- const { status, data, usedApiKey } = first;
410
- // 服务端在 wait_ms 内已直接返回业务结果,或任务壳里已经带了完成结果。
411
- const initialCompletedPayload = RocomClient.extractCompletedIngamePayload(data);
412
- if (status !== null && initialCompletedPayload)
413
- return initialCompletedPayload;
414
- const taskId = RocomClient.extractTaskId(data);
452
+ async pollIngameTask(ctx, first, options, labels, auth = {}) {
453
+ if (first.status === null || options.signal?.aborted)
454
+ return null;
455
+ const initial = RocomClient.inspectIngamePayload(first.data);
456
+ if (initial.error) {
457
+ this.setLastError(initial.error);
458
+ return null;
459
+ }
460
+ const taskId = RocomClient.extractTaskId(first.data);
461
+ if (!initial.pending && initial.value !== undefined && first.status !== 202)
462
+ return initial.value;
415
463
  if (!taskId) {
416
- if (status === 202)
417
- this.setLastError(labels.queuedNoTaskId);
418
- // 既不是任务也不是已知完成结构:把原始 data 透传给上层兼容旧逻辑。
419
- return status === null ? null : data;
420
- }
421
- // 已进入队列,回调通知“排队中”。
422
- if (typeof options.onQueued === 'function') {
423
- try {
424
- await options.onQueued(taskId);
425
- }
426
- catch (e) {
427
- logger.warn(`ingame 排队提示回调失败: ${e}`);
428
- }
464
+ this.setLastError(labels.queuedNoTaskId);
465
+ return null;
429
466
  }
430
- const intervalMs = Math.max(300, Number(options.intervalMs) || 3000);
431
- const timeoutMs = Math.max(intervalMs, Number(options.timeoutMs) || 180000);
432
- const startedAt = Date.now();
433
- let lastStatus = RocomClient.normalizeTaskStatus(data?.status);
434
- while (Date.now() - startedAt < timeoutMs) {
435
- if (lastStatus && INGAME_FAILED_STATUSES.includes(lastStatus)) {
436
- this.setLastError(RocomClient.taskErrorMessage(data, lastStatus));
437
- return null;
438
- }
439
- await new Promise(resolve => setTimeout(resolve, intervalMs));
440
- const task = await this.getIngameTask(ctx, taskId, usedApiKey);
441
- if (task.status === null)
442
- return null;
443
- const completedPayload = RocomClient.extractCompletedIngamePayload(task.data);
444
- if (completedPayload)
445
- return completedPayload;
446
- lastStatus = RocomClient.normalizeTaskStatus(task.data?.status);
447
- if (INGAME_FAILED_STATUSES.includes(lastStatus)) {
448
- this.setLastError(RocomClient.taskErrorMessage(task.data, lastStatus));
449
- return null;
450
- }
451
- // 状态标记完成,但结果可能嵌套在 result/data 中。
452
- if (INGAME_COMPLETED_STATUSES.includes(lastStatus)) {
453
- this.setLastError(`Ingame 任务已完成但未返回可解析结果:${taskId}`);
454
- return null;
455
- }
456
- if (lastStatus && !INGAME_PENDING_STATUSES.includes(lastStatus)) {
457
- this.setLastError(`Ingame 任务状态异常:${lastStatus}`);
467
+ try {
468
+ return await (0, async_control_1.withDeadline)(Math.max(1, Number(options.timeoutMs) || 180000), options.signal, async (signal) => {
469
+ if (options.onQueued) {
470
+ try {
471
+ await options.onQueued(taskId);
472
+ }
473
+ catch (error) {
474
+ logger.warn('排队提示失败: ' + error);
475
+ }
476
+ }
477
+ while (!signal.aborted) {
478
+ await (0, async_control_1.delay)(Math.max(300, Number(options.intervalMs) || 3000), signal);
479
+ const task = await this.getIngameTask(ctx, taskId, first.usedApiKey, auth, signal);
480
+ signal.throwIfAborted();
481
+ if (task.status === null)
482
+ return null;
483
+ const result = RocomClient.inspectIngamePayload(task.data);
484
+ if (result.error) {
485
+ this.setLastError(result.error);
486
+ return null;
487
+ }
488
+ if (!result.pending && result.value !== undefined && task.status !== 202)
489
+ return result.value;
490
+ }
458
491
  return null;
459
- }
460
- // 既无 task_id 也无状态,说明拿到的就是裸结果。
461
- if (!RocomClient.extractTaskId(task.data) && !lastStatus)
462
- return task.data;
492
+ });
493
+ }
494
+ catch {
495
+ this.setLastError(options.signal?.aborted ? '查询已取消' : labels.stillQueued(taskId));
496
+ return null;
497
+ }
498
+ }
499
+ async queuedQuery(ctx, path, payload, options, labels) {
500
+ try {
501
+ return await (0, async_control_1.withDeadline)(Number(options.timeoutMs) || 180000, options.signal, async (signal) => {
502
+ const first = await this.requestIngameWithFallback(ctx, path, payload, { auth: options.auth, signal });
503
+ signal.throwIfAborted();
504
+ return this.pollIngameTask(ctx, first, { ...options, signal }, labels, options.auth);
505
+ });
506
+ }
507
+ catch {
508
+ this.setLastError(options.signal?.aborted ? '查询已取消' : '查询等待超时,请稍后重试');
509
+ return null;
463
510
  }
464
- this.setLastError(labels.stillQueued(taskId));
465
- return null;
466
511
  }
467
512
  async qqQrLogin(ctx, userIdentifier) {
468
513
  const params = { client_type: 'bot', client_id: 'koishi', provider: 'rocom' };
@@ -562,39 +607,40 @@ class RocomClient {
562
607
  || Array.isArray(data.notes)
563
608
  || data.title
564
609
  || data.nickname
565
- || data.uid);
610
+ || data.uid
611
+ || (data.player_info && typeof data.player_info === 'object')
612
+ || (data.player_card_brief_info && typeof data.player_card_brief_info === 'object'));
566
613
  }
567
- async ingamePlayerSearch(ctx, uid) {
614
+ async ingamePlayerSearch(ctx, uid, options = {}) {
568
615
  const sanitizedUid = this.sanitizeUid(uid);
569
- if (!sanitizedUid) {
616
+ if (!sanitizedUid && !options.auth?.fwToken) {
570
617
  this.setLastError('UID 不能为空');
571
618
  return null;
572
619
  }
573
620
  const path = '/api/v1/games/rocom/ingame/player/search';
574
- const payload = { uid: sanitizedUid, wait_ms: 5000 };
575
- const { status, data, usedApiKey } = await this.requestIngameWithFallback(ctx, path, payload);
576
- if (status === 200 && data && this.isIngamePlayerPayload(data))
577
- return data;
578
- if (!data)
621
+ const payload = { ...(sanitizedUid ? { uid: sanitizedUid } : {}), wait_ms: Number(options.waitMs) || 5000 };
622
+ return this.queuedQuery(ctx, path, payload, options, {
623
+ queuedNoTaskId: '玩家搜索任务已入队,但未返回 task_id',
624
+ stillQueued: (taskId) => `玩家搜索任务仍在排队,请稍后重试(task_id: ${taskId})`,
625
+ });
626
+ }
627
+ // 玩家名片(上游 v4.1.0):需要补充收集数/名片图时按需查询。
628
+ async ingamePlayerCard(ctx, uid, options = {}) {
629
+ const sanitizedUid = this.sanitizeUid(uid);
630
+ if (!sanitizedUid && !options.auth?.fwToken) {
631
+ this.setLastError('UID 不能为空');
579
632
  return null;
580
- if (this.isIngamePlayerPayload(data))
581
- return data;
582
- const taskId = data.task_id || data.taskId || data.taskID;
583
- if (!taskId) {
584
- if (status === 202)
585
- this.setLastError('玩家搜索任务已入队,但未返回 task_id');
586
- return data;
587
- }
588
- for (let i = 0; i < 8; i++) {
589
- await new Promise(resolve => setTimeout(resolve, 1000));
590
- const task = await this.getIngameTask(ctx, taskId, usedApiKey);
591
- if (task.status === 200)
592
- return task.data;
593
- if (task.status === null)
594
- return null;
595
633
  }
596
- this.setLastError('Player search task is still queued, please retry later (task_id: ' + taskId + ')');
597
- return null;
634
+ const path = '/api/v1/games/rocom/ingame/player/card';
635
+ const payload = {
636
+ ...(sanitizedUid ? { uid: sanitizedUid } : {}),
637
+ wait_ms: Number(options.waitMs) || 5000,
638
+ source: options.source || 'friend',
639
+ };
640
+ return this.queuedQuery(ctx, path, payload, options, {
641
+ queuedNoTaskId: '玩家名片任务已入队,但未返回 task_id',
642
+ stillQueued: (taskId) => `玩家名片任务仍在排队,请稍后重试(task_id: ${taskId})`,
643
+ });
598
644
  }
599
645
  async getPets(ctx, fwToken, petSubset = 0, pageNo = 1, pageSize = 10, userIdentifier = '') {
600
646
  const params = { pet_subset: petSubset, page_no: pageNo, page_size: pageSize };
@@ -610,11 +656,26 @@ class RocomClient {
610
656
  const params = { page_no: Math.max(pageNo, 1), refresh: 'false' };
611
657
  return this.get(ctx, '/api/v1/games/rocom/exchange/posters', this.wegameHeaders(fwToken, userIdentifier), params);
612
658
  }
613
- async getMerchantInfo(ctx, refresh = false) {
614
- return this.get(ctx, '/api/v1/games/rocom/merchant/info', this.wegameHeaders(), {
659
+ // 远行商人:优先实时 ingame 接口,失败/协议无法识别时回退旧 merchant/info。
660
+ // 成功的空商品列表(goods: [])视为有效结果,不触发回退。
661
+ async getMerchantInfo(ctx, refresh = false, options = {}) {
662
+ const live = await this.ingameMerchantInfo(ctx, null, options);
663
+ if (live && typeof live === 'object' && (Array.isArray(live.goods)
664
+ || Array.isArray(live.merchantActivities)
665
+ || Array.isArray(live.merchant_activities))) {
666
+ return { ...live, _source: 'live' };
667
+ }
668
+ if (options.signal?.aborted)
669
+ return null;
670
+ const legacy = await this.get(ctx, '/api/v1/games/rocom/merchant/info', this.wegameHeaders(options.auth?.fwToken, options.auth?.userIdentifier), {
615
671
  refresh: refresh ? 'true' : 'false',
616
672
  random_goods: 'all',
617
- });
673
+ }, { signal: options.signal });
674
+ if (legacy && typeof legacy === 'object') {
675
+ logger.warn('[Rocom] ingame/merchant/info 不可用,已回退 merchant/info');
676
+ return { ...legacy, _source: 'legacy' };
677
+ }
678
+ return null;
618
679
  }
619
680
  async queryPetSize(ctx, diameter, weight, pool = 'magic', pageNo = 1, pageSize = 30, userIdentifier = '') {
620
681
  const params = this.scopedParams({
@@ -627,6 +688,55 @@ class RocomClient {
627
688
  }, userIdentifier);
628
689
  return this.get(ctx, '/api/v1/games/rocom/wiki/pet-size/query', this.wegameHeaders('', userIdentifier, 'bot', 'koishi'), params);
629
690
  }
691
+ // 后端查蛋(上游 v3.8.0):按展示身高/体重查询,参数名是 height/weight。
692
+ async searchEggBySize(ctx, heightMeters, weightKg, pageNo = 1, pageSize = 30, userIdentifier = '') {
693
+ const params = this.scopedParams({
694
+ height: heightMeters,
695
+ weight: weightKg,
696
+ page_no: Math.max(Number(pageNo) || 1, 1),
697
+ page_size: Math.min(Math.max(Number(pageSize) || 30, 1), 100),
698
+ }, userIdentifier);
699
+ return this.get(ctx, '/api/v1/games/rocom/egg/search', this.wegameHeaders('', userIdentifier, 'bot', 'koishi'), params);
700
+ }
701
+ // 异色/炫彩收集排行榜(上游 v3.9.0)。
702
+ async getPetCollectionRanking(ctx, rankType, limit = 10, uid = '', options = {}) {
703
+ if (!['shining', 'glass'].includes(rankType)) {
704
+ this.setLastError('无效榜单类型');
705
+ return null;
706
+ }
707
+ if (!Number.isInteger(limit) || limit < 1 || limit > 50) {
708
+ this.setLastError('无效数量');
709
+ return null;
710
+ }
711
+ const params = { limit, resolve_names: '1' };
712
+ if (uid)
713
+ params.uid = this.sanitizeUid(uid);
714
+ return this.get(ctx, `/api/v1/games/rocom/ingame/player/card/pet-stats/rankings/${rankType}`, this.wegameHeaders('', options.userIdentifier || '', 'bot', 'koishi'), this.scopedParams(params, options.userIdentifier || ''));
715
+ }
716
+ // 阵容分享码解析与历史查询(上游 v3.9.0)。
717
+ async parseShareCode(ctx, shareCode, userIdentifier = '') {
718
+ const code = String(shareCode || '').trim();
719
+ if (!code) {
720
+ this.setLastError('分享码不能为空');
721
+ return null;
722
+ }
723
+ return this.post(ctx, '/api/v1/games/rocom/tools/share-code/parse', this.wegameHeaders('', userIdentifier, 'bot', 'koishi'), { share_code: code }, this.scopedParams({}, userIdentifier));
724
+ }
725
+ async getShareCodeRecords(ctx, options = {}, userIdentifier = '') {
726
+ const params = {
727
+ page_no: Math.max(Number(options.pageNo) || 1, 1),
728
+ page_size: Math.min(Math.max(Number(options.pageSize) || 20, 1), 100),
729
+ };
730
+ if (options.shareCode)
731
+ params.share_code = String(options.shareCode).trim();
732
+ if (options.hash)
733
+ params.hash = String(options.hash).trim();
734
+ if (options.modeId !== undefined)
735
+ params.mode_id = options.modeId;
736
+ if (options.magicId !== undefined)
737
+ params.magic_id = options.magicId;
738
+ return this.get(ctx, '/api/v1/games/rocom/tools/share-code/records', this.wegameHeaders('', userIdentifier, 'bot', 'koishi'), this.scopedParams(params, userIdentifier));
739
+ }
630
740
  async getActivitiesInfo(ctx, refresh = false, userIdentifier = '') {
631
741
  return this.get(ctx, '/api/v1/games/rocom/activities/info', this.wegameHeaders('', userIdentifier, 'bot', 'koishi'), this.scopedParams({ refresh: refresh ? 'true' : 'false' }, userIdentifier));
632
742
  }
@@ -636,8 +746,8 @@ class RocomClient {
636
746
  async getAnnouncementList(ctx, params = {}, userIdentifier = '') {
637
747
  return this.get(ctx, '/api/v1/games/rocom/announcement/list', this.wegameHeaders('', userIdentifier, 'bot', 'koishi'), this.scopedParams(params, userIdentifier));
638
748
  }
639
- async getLatestAnnouncement(ctx, params = {}, userIdentifier = '') {
640
- return this.get(ctx, '/api/v1/games/rocom/announcement/latest', this.wegameHeaders('', userIdentifier, 'bot', 'koishi'), this.scopedParams(params, userIdentifier));
749
+ async getLatestAnnouncement(ctx, params = {}, userIdentifier = '', signal) {
750
+ return this.get(ctx, '/api/v1/games/rocom/announcement/latest', this.wegameHeaders('', userIdentifier, 'bot', 'koishi'), this.scopedParams(params, userIdentifier), { signal });
641
751
  }
642
752
  async getAnnouncementDetail(ctx, threadId, userIdentifier = '') {
643
753
  return this.get(ctx, '/api/v1/games/rocom/announcement/detail', this.wegameHeaders('', userIdentifier, 'bot', 'koishi'), this.scopedParams({ thread_id: threadId }, userIdentifier));
@@ -684,41 +794,50 @@ class RocomClient {
684
794
  }
685
795
  async ingameHomeInfo(ctx, uid, options = {}) {
686
796
  const sanitizedUid = this.sanitizeUid(uid);
687
- if (!sanitizedUid) {
797
+ if (!sanitizedUid && !options.auth?.fwToken) {
688
798
  this.setLastError('UID 不能为空');
689
799
  return null;
690
800
  }
691
801
  const waitMs = Number(options.waitMs) || 5000;
692
802
  const path = '/api/v1/games/rocom/ingame/home/info';
693
- const payload = { uid: sanitizedUid, wait_ms: waitMs };
694
- const first = await this.requestIngameWithFallback(ctx, path, payload);
695
- return this.pollIngameTask(ctx, first, options, {
803
+ const payload = { wait_ms: waitMs };
804
+ if (sanitizedUid)
805
+ payload.uid = sanitizedUid;
806
+ return this.queuedQuery(ctx, path, payload, options, {
696
807
  queuedNoTaskId: '家园查询任务已入队,但未返回 task_id',
697
808
  stillQueued: (taskId) => `家园查询任务仍在排队,请稍后重试(task_id: ${taskId})`,
698
809
  });
699
810
  }
700
- async ingameMerchantInfo(ctx, shopId) {
701
- const params = { shop_id: shopId };
702
- const { status, data } = await this.requestIngameWithFallback(ctx, '/api/v1/games/rocom/ingame/merchant/info', params);
703
- return status === null ? null : data;
811
+ // 远行商人实时接口(上游 v4.1.0):优先 ingame live,返回 goods/shop/meta 或任务轮询结果。
812
+ // 未指定商店时不传 shop_id;调用方负责在新接口不可用时回退旧 merchant/info。
813
+ async ingameMerchantInfo(ctx, shopId, options = {}) {
814
+ const waitMs = Number(options.waitMs) || 5000;
815
+ const payload = { wait_ms: waitMs };
816
+ if (shopId !== undefined && shopId !== null && String(shopId) !== '')
817
+ payload.shop_id = shopId;
818
+ return this.queuedQuery(ctx, '/api/v1/games/rocom/ingame/merchant/info', payload, options, {
819
+ queuedNoTaskId: '远行商人任务已入队,但未返回 task_id',
820
+ stillQueued: (taskId) => `远行商人任务仍在排队,请稍后重试(task_id: ${taskId})`,
821
+ });
704
822
  }
705
823
  // 家园详情:批量或单只查询家园摆放精灵的完整 ingame 数据(上游 v3.6.1)。
706
824
  // UID 参数名为 target_uin;单只查询需同时提供 pet_gid 和 npc_id。
707
825
  async ingamePetData(ctx, uid, extras = {}, options = {}) {
708
826
  const sanitizedUid = this.sanitizeUid(uid);
709
- if (!sanitizedUid) {
827
+ if (!sanitizedUid && !options.auth?.fwToken) {
710
828
  this.setLastError('UID 不能为空');
711
829
  return null;
712
830
  }
713
831
  const waitMs = Number(options.waitMs) || 20000;
714
- const payload = { target_uin: sanitizedUid, wait_ms: waitMs };
832
+ const payload = { wait_ms: waitMs };
833
+ if (sanitizedUid)
834
+ payload.target_uin = sanitizedUid;
715
835
  if (extras.petGid !== undefined && extras.petGid !== null && String(extras.petGid) !== '')
716
836
  payload.pet_gid = extras.petGid;
717
837
  if (extras.npcId !== undefined && extras.npcId !== null && String(extras.npcId) !== '')
718
838
  payload.npc_id = extras.npcId;
719
839
  const path = '/api/v1/games/rocom/ingame/pet/data';
720
- const first = await this.requestIngameWithFallback(ctx, path, payload);
721
- return this.pollIngameTask(ctx, first, options, {
840
+ return this.queuedQuery(ctx, path, payload, options, {
722
841
  queuedNoTaskId: '家园详情任务已入队,但未返回 task_id',
723
842
  stillQueued: (taskId) => `家园详情任务仍在排队,请稍后重试(task_id: ${taskId})`,
724
843
  });