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.
- package/lib/announcement-service.d.ts +21 -0
- package/lib/announcement-service.js +63 -0
- package/lib/async-control.d.ts +3 -0
- package/lib/async-control.js +48 -0
- package/lib/client.d.ts +48 -7
- package/lib/client.js +244 -125
- package/lib/commands/egg.js +66 -4
- package/lib/commands/merchant.js +144 -146
- package/lib/commands/query.js +148 -117
- package/lib/commands/ranking.d.ts +2 -0
- package/lib/commands/ranking.js +31 -0
- package/lib/commands/share-code.d.ts +2 -0
- package/lib/commands/share-code.js +66 -0
- package/lib/commands/tools.js +47 -59
- package/lib/egg-service.d.ts +82 -8
- package/lib/egg-service.js +235 -19
- package/lib/img//345/274/202/350/211/262.png +0 -0
- package/lib/img//347/202/253/345/275/251.png +0 -0
- package/lib/index.d.ts +5 -0
- package/lib/index.js +16 -3
- package/lib/merchant-clock.d.ts +22 -0
- package/lib/merchant-clock.js +95 -0
- package/lib/merchant-service.d.ts +24 -0
- package/lib/merchant-service.js +148 -0
- package/lib/permissions.d.ts +16 -0
- package/lib/permissions.js +66 -0
- package/lib/pet-data-service.js +7 -9
- package/lib/pet-size.d.ts +13 -0
- package/lib/pet-size.js +54 -0
- package/lib/player-service.d.ts +37 -0
- package/lib/player-service.js +201 -0
- package/lib/ranking-service.d.ts +46 -0
- package/lib/ranking-service.js +137 -0
- package/lib/render-templates/announcement/detail.html +8 -0
- package/lib/render-templates/announcement/list.html +5 -0
- package/lib/render-templates/announcement/style.css +1 -0
- package/lib/render-templates/pet-ranking/index.html +99 -0
- package/lib/render-templates/pet-ranking/style.css +338 -0
- package/lib/render-templates/player-search/index.html +7 -0
- package/lib/render-templates/searcheggs/index.html +1 -1
- package/lib/render-templates/searcheggs/size.html +70 -68
- package/lib/render-templates/share-code-team/index.html +105 -0
- package/lib/render-templates/share-code-team/style.css +345 -0
- package/lib/render.d.ts +15 -2
- package/lib/render.js +120 -37
- package/lib/send-image.d.ts +1 -1
- package/lib/send-image.js +12 -1
- package/lib/share-code-service.d.ts +32 -0
- package/lib/share-code-service.js +184 -0
- package/lib/subscription-runner.d.ts +6 -0
- package/lib/subscription-runner.js +21 -0
- package/lib/subscription-send.d.ts +4 -2
- package/lib/subscription-send.js +45 -20
- package/lib/types.d.ts +5 -0
- package/lib/user.d.ts +5 -0
- package/package.json +4 -1
- package/readme.md +145 -124
package/lib/render.js
CHANGED
|
@@ -36,13 +36,14 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
|
36
36
|
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
37
37
|
};
|
|
38
38
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
39
|
-
exports.Renderer = void 0;
|
|
39
|
+
exports.Renderer = exports.lowBandwidthRenderOptions = void 0;
|
|
40
40
|
const koishi_1 = require("koishi");
|
|
41
41
|
const template = __importStar(require("art-template"));
|
|
42
42
|
const node_fs_1 = __importDefault(require("node:fs"));
|
|
43
43
|
const node_os_1 = __importDefault(require("node:os"));
|
|
44
44
|
const node_path_1 = __importDefault(require("node:path"));
|
|
45
45
|
const node_url_1 = require("node:url");
|
|
46
|
+
const async_control_1 = require("./async-control");
|
|
46
47
|
const logger = new koishi_1.Logger('rocom-render');
|
|
47
48
|
const TEMPLATE_CAPTURE_PADDING = {
|
|
48
49
|
package: { left: 0, right: 0, top: 0, bottom: 0 },
|
|
@@ -59,6 +60,15 @@ const DEFAULT_SCREENSHOT_OPTIONS = {
|
|
|
59
60
|
type: 'jpeg',
|
|
60
61
|
quality: 82,
|
|
61
62
|
};
|
|
63
|
+
// 家园详情低带宽建议初值,需用真实截图校准。
|
|
64
|
+
exports.lowBandwidthRenderOptions = {
|
|
65
|
+
timeoutMs: 30000,
|
|
66
|
+
imageWaitMs: 3000,
|
|
67
|
+
fontWaitMs: 1500,
|
|
68
|
+
deviceScaleFactor: 1,
|
|
69
|
+
jpegQuality: 65,
|
|
70
|
+
maxPageHeight: 12000,
|
|
71
|
+
};
|
|
62
72
|
function toDirectoryFileUrl(dirPath) {
|
|
63
73
|
const href = (0, node_url_1.pathToFileURL)(dirPath).href;
|
|
64
74
|
return href.endsWith('/') ? href : `${href}/`;
|
|
@@ -68,8 +78,10 @@ function normalizeTemplateResourcePaths(content) {
|
|
|
68
78
|
}
|
|
69
79
|
class Renderer {
|
|
70
80
|
resPath;
|
|
71
|
-
|
|
81
|
+
defaults;
|
|
82
|
+
constructor(resPath, defaults = {}) {
|
|
72
83
|
this.resPath = resPath;
|
|
84
|
+
this.defaults = defaults;
|
|
73
85
|
}
|
|
74
86
|
resourceUrl(relativePath) {
|
|
75
87
|
return (0, node_url_1.pathToFileURL)(node_path_1.default.join(this.getPreferredResourceRoot(), relativePath)).href;
|
|
@@ -107,7 +119,58 @@ class Renderer {
|
|
|
107
119
|
return '';
|
|
108
120
|
return node_path_1.default.join(resolved.resourceRoot, 'render-templates', templateName, 'style.css');
|
|
109
121
|
}
|
|
110
|
-
async renderHtml(ctx, templateName, data) {
|
|
122
|
+
async renderHtml(ctx, templateName, data, options = {}) {
|
|
123
|
+
const pages = await this.renderPages(ctx, templateName, data, { ...options, maxPageHeight: 0 });
|
|
124
|
+
return pages?.[0] ?? null;
|
|
125
|
+
}
|
|
126
|
+
async renderPages(ctx, templateName, data, options = {}) {
|
|
127
|
+
options = { maxPageHeight: 6000, ...this.defaults, ...options };
|
|
128
|
+
let page;
|
|
129
|
+
let tempDir = '';
|
|
130
|
+
let stopped = false;
|
|
131
|
+
let closing;
|
|
132
|
+
const close = () => {
|
|
133
|
+
if (page && !closing)
|
|
134
|
+
closing = Promise.resolve().then(() => page.close()).catch(() => { });
|
|
135
|
+
return closing;
|
|
136
|
+
};
|
|
137
|
+
try {
|
|
138
|
+
return await (0, async_control_1.withDeadline)(options.timeoutMs || 30000, options.signal, async (signal) => {
|
|
139
|
+
const acquire = async () => {
|
|
140
|
+
page = await ctx.puppeteer.page();
|
|
141
|
+
if (stopped || signal.aborted) {
|
|
142
|
+
await close();
|
|
143
|
+
signal.throwIfAborted();
|
|
144
|
+
}
|
|
145
|
+
return page;
|
|
146
|
+
};
|
|
147
|
+
return this.capture(ctx, templateName, data, { ...options, signal }, acquire, dir => { tempDir = dir; });
|
|
148
|
+
}, () => { stopped = true; void close(); });
|
|
149
|
+
}
|
|
150
|
+
catch (error) {
|
|
151
|
+
logger.warn('render interrupted for ' + templateName + ': ' + error);
|
|
152
|
+
return null;
|
|
153
|
+
}
|
|
154
|
+
finally {
|
|
155
|
+
stopped = true;
|
|
156
|
+
// A stalled browser must not block the request's deadline indefinitely.
|
|
157
|
+
void close();
|
|
158
|
+
if (tempDir) {
|
|
159
|
+
try {
|
|
160
|
+
node_fs_1.default.rmSync(tempDir, { recursive: true, force: true });
|
|
161
|
+
}
|
|
162
|
+
catch { }
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
async capture(ctx, templateName, data, options, acquire, rememberDirectory) {
|
|
167
|
+
const timeoutMs = options.timeoutMs && options.timeoutMs > 0 ? options.timeoutMs : 30000;
|
|
168
|
+
const deadline = Date.now() + timeoutMs;
|
|
169
|
+
const imageWaitMs = options.imageWaitMs && options.imageWaitMs > 0 ? options.imageWaitMs : 10000;
|
|
170
|
+
const fontWaitMs = options.fontWaitMs && options.fontWaitMs > 0 ? options.fontWaitMs : 3000;
|
|
171
|
+
const jpegQuality = options.jpegQuality && options.jpegQuality > 0 ? options.jpegQuality : DEFAULT_SCREENSHOT_OPTIONS.quality;
|
|
172
|
+
const screenshotOptions = { type: 'jpeg', quality: jpegQuality };
|
|
173
|
+
const remainingMs = () => { options.signal?.throwIfAborted(); return Math.max(1, deadline - Date.now()); };
|
|
111
174
|
try {
|
|
112
175
|
const resolvedTemplate = this.resolveTemplatePath(templateName);
|
|
113
176
|
if (!resolvedTemplate) {
|
|
@@ -125,25 +188,33 @@ class Renderer {
|
|
|
125
188
|
logger.error('puppeteer service is unavailable');
|
|
126
189
|
return null;
|
|
127
190
|
}
|
|
128
|
-
|
|
129
|
-
const
|
|
130
|
-
|
|
191
|
+
// Puppeteer 服务由 Koishi 共享,只能关闭本次 page,不能关闭共享 browser。
|
|
192
|
+
const page = await acquire();
|
|
193
|
+
let tempDir = '';
|
|
131
194
|
try {
|
|
195
|
+
options.signal?.throwIfAborted();
|
|
196
|
+
tempDir = node_fs_1.default.mkdtempSync(node_path_1.default.join(node_os_1.default.tmpdir(), 'rocom-render-'));
|
|
197
|
+
rememberDirectory(tempDir);
|
|
198
|
+
const tempHtmlPath = node_path_1.default.join(tempDir, `${templateName.replace(/[\\/]/g, '_')}.html`);
|
|
132
199
|
await page.setCacheEnabled(false);
|
|
133
200
|
node_fs_1.default.writeFileSync(tempHtmlPath, html, 'utf-8');
|
|
134
|
-
const
|
|
201
|
+
const templateViewport = TEMPLATE_VIEWPORTS[templateName] || { width: 1280, height: 768, deviceScaleFactor: 2 };
|
|
202
|
+
const initialViewport = {
|
|
203
|
+
...templateViewport,
|
|
204
|
+
deviceScaleFactor: options.deviceScaleFactor ?? templateViewport.deviceScaleFactor,
|
|
205
|
+
};
|
|
135
206
|
await page.setViewport(initialViewport);
|
|
136
207
|
try {
|
|
137
208
|
await page.goto((0, node_url_1.pathToFileURL)(tempHtmlPath).href, {
|
|
138
209
|
waitUntil: 'networkidle0',
|
|
139
|
-
timeout: 15000,
|
|
210
|
+
timeout: Math.min(15000, remainingMs()),
|
|
140
211
|
});
|
|
141
212
|
}
|
|
142
213
|
catch (err) {
|
|
143
214
|
logger.warn(`page.goto failed for ${templateName}: ${err}`);
|
|
144
215
|
}
|
|
145
216
|
try {
|
|
146
|
-
await page.evaluate(async () => {
|
|
217
|
+
await page.evaluate(async (waitImageMs, waitFontMs) => {
|
|
147
218
|
const images = Array.from(document.images);
|
|
148
219
|
// 限制远程图片等待时间,避免慢速资源拖垮整次渲染(对应上游 v3.7.1)。
|
|
149
220
|
await Promise.race([
|
|
@@ -155,18 +226,22 @@ class Renderer {
|
|
|
155
226
|
img.onerror = () => resolve();
|
|
156
227
|
});
|
|
157
228
|
})),
|
|
158
|
-
new Promise(resolve => setTimeout(resolve,
|
|
229
|
+
new Promise(resolve => setTimeout(resolve, waitImageMs)),
|
|
159
230
|
]);
|
|
160
231
|
const fonts = document.fonts;
|
|
161
232
|
if (fonts?.ready) {
|
|
162
|
-
|
|
233
|
+
// 字体等待单独设上限,避免字体服务异常时无限等待。
|
|
234
|
+
await Promise.race([
|
|
235
|
+
fonts.ready,
|
|
236
|
+
new Promise(resolve => setTimeout(resolve, waitFontMs)),
|
|
237
|
+
]);
|
|
163
238
|
}
|
|
164
|
-
});
|
|
239
|
+
}, Math.min(imageWaitMs, remainingMs()), Math.min(fontWaitMs, remainingMs()));
|
|
165
240
|
}
|
|
166
241
|
catch (err) {
|
|
167
242
|
logger.warn(`asset wait failed for ${templateName}: ${err}`);
|
|
168
243
|
}
|
|
169
|
-
await
|
|
244
|
+
await (0, async_control_1.delay)(300, options.signal);
|
|
170
245
|
const selectors = [
|
|
171
246
|
'.exchange-page',
|
|
172
247
|
'.record-page',
|
|
@@ -193,6 +268,10 @@ class Renderer {
|
|
|
193
268
|
'.pet-panel-page',
|
|
194
269
|
'.pet-detail-page',
|
|
195
270
|
'.activity-calendar-page',
|
|
271
|
+
'.pet-ranking-page',
|
|
272
|
+
'.share-code-page',
|
|
273
|
+
'.announcement-list-page',
|
|
274
|
+
'.announcement-detail-page',
|
|
196
275
|
];
|
|
197
276
|
let target = null;
|
|
198
277
|
for (const selector of selectors) {
|
|
@@ -220,10 +299,10 @@ class Renderer {
|
|
|
220
299
|
const capturePadding = TEMPLATE_CAPTURE_PADDING[templateName] || { left: 0, right: 0, top: 0, bottom: 0 };
|
|
221
300
|
await page.setViewport({
|
|
222
301
|
width: Math.max(Math.ceil(elementMetrics.x + elementMetrics.width + capturePadding.right) + 8, 200),
|
|
223
|
-
height: Math.max(Math.ceil(elementMetrics.y + elementMetrics.height + capturePadding.bottom) + 8, 200),
|
|
302
|
+
height: Math.min(6000, Math.max(Math.ceil(elementMetrics.y + elementMetrics.height + capturePadding.bottom) + 8, 200)),
|
|
224
303
|
deviceScaleFactor: initialViewport.deviceScaleFactor,
|
|
225
304
|
});
|
|
226
|
-
await
|
|
305
|
+
await (0, async_control_1.delay)(100, options.signal);
|
|
227
306
|
// 居中(margin auto)模板在视口调整后 x/y 会变化,截图前需要重新测量,
|
|
228
307
|
// 否则 clip 会带着旧偏移导致画面左侧被截断(对应上游 v3.7.5 修复)。
|
|
229
308
|
try {
|
|
@@ -247,30 +326,27 @@ class Renderer {
|
|
|
247
326
|
catch (err) {
|
|
248
327
|
logger.warn(`element remeasure failed for ${templateName}: ${err}`);
|
|
249
328
|
}
|
|
250
|
-
const
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
height: clipHeight,
|
|
264
|
-
},
|
|
265
|
-
});
|
|
266
|
-
return Buffer.isBuffer(screenshot) ? screenshot : Buffer.from(screenshot);
|
|
329
|
+
const clipX = Math.max(0, elementMetrics.x - capturePadding.left);
|
|
330
|
+
const clipY = Math.max(0, elementMetrics.y - capturePadding.top);
|
|
331
|
+
const width = elementMetrics.width + capturePadding.left + capturePadding.right;
|
|
332
|
+
const height = elementMetrics.height + capturePadding.top + capturePadding.bottom;
|
|
333
|
+
const maxHeight = Math.max(1, options.maxPageHeight || height);
|
|
334
|
+
// Evenly distribute the last few pixels instead of emitting a tiny blank final page.
|
|
335
|
+
const step = Math.ceil(height / Math.ceil(height / maxHeight));
|
|
336
|
+
const pages = [];
|
|
337
|
+
for (let offset = 0; offset < height; offset += step) {
|
|
338
|
+
options.signal?.throwIfAborted();
|
|
339
|
+
const screenshot = await page.screenshot({ ...screenshotOptions, captureBeyondViewport: true,
|
|
340
|
+
clip: { x: clipX, y: clipY + offset, width, height: Math.min(step, height - offset) } });
|
|
341
|
+
pages.push(Buffer.from(screenshot));
|
|
267
342
|
}
|
|
343
|
+
return pages;
|
|
268
344
|
}
|
|
269
|
-
const screenshot = await target.screenshot(
|
|
270
|
-
return Buffer.
|
|
345
|
+
const screenshot = await target.screenshot(screenshotOptions);
|
|
346
|
+
return [Buffer.from(screenshot)];
|
|
271
347
|
}
|
|
272
|
-
const screenshot = await page.screenshot({ ...
|
|
273
|
-
return Buffer.
|
|
348
|
+
const screenshot = await page.screenshot({ ...screenshotOptions, fullPage: true });
|
|
349
|
+
return [Buffer.from(screenshot)];
|
|
274
350
|
}
|
|
275
351
|
finally {
|
|
276
352
|
try {
|
|
@@ -279,7 +355,14 @@ class Renderer {
|
|
|
279
355
|
catch {
|
|
280
356
|
// ignore
|
|
281
357
|
}
|
|
282
|
-
|
|
358
|
+
if (tempDir) {
|
|
359
|
+
try {
|
|
360
|
+
node_fs_1.default.rmSync(tempDir, { recursive: true, force: true });
|
|
361
|
+
}
|
|
362
|
+
catch {
|
|
363
|
+
// ignore
|
|
364
|
+
}
|
|
365
|
+
}
|
|
283
366
|
}
|
|
284
367
|
}
|
|
285
368
|
catch (e) {
|
package/lib/send-image.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
1
|
import { PluginConfig } from './types';
|
|
2
2
|
export declare function detectImageMime(image: Buffer): string;
|
|
3
3
|
export declare function compressPngImage(image: Buffer, config: Pick<PluginConfig, 'imageCompressionEnabled' | 'imageCompressionMinBytes' | 'imageCompressionLevel'>): Buffer;
|
|
4
|
-
export declare function sendImageWithFallback(session: any, image: Buffer | null, fallbackText: string, scene: string, compressionConfig?: Pick<PluginConfig, 'imageCompressionEnabled' | 'imageCompressionMinBytes' | 'imageCompressionLevel'>): Promise<
|
|
4
|
+
export declare function sendImageWithFallback(session: any, image: Buffer | Buffer[] | null, fallbackText: string, scene: string, compressionConfig?: Pick<PluginConfig, 'imageCompressionEnabled' | 'imageCompressionMinBytes' | 'imageCompressionLevel'>): Promise<boolean>;
|
package/lib/send-image.js
CHANGED
|
@@ -114,6 +114,16 @@ function compressPngImage(image, config) {
|
|
|
114
114
|
return image;
|
|
115
115
|
}
|
|
116
116
|
async function sendImageWithFallback(session, image, fallbackText, scene, compressionConfig) {
|
|
117
|
+
if (Array.isArray(image)) {
|
|
118
|
+
if (!image.length)
|
|
119
|
+
return sendImageWithFallback(session, null, fallbackText, scene, compressionConfig);
|
|
120
|
+
for (const page of image) {
|
|
121
|
+
const ok = await sendImageWithFallback(session, page, fallbackText, scene, compressionConfig);
|
|
122
|
+
if (!ok)
|
|
123
|
+
return false;
|
|
124
|
+
}
|
|
125
|
+
return true;
|
|
126
|
+
}
|
|
117
127
|
const ctxInfo = formatSessionContext(session);
|
|
118
128
|
if (!image) {
|
|
119
129
|
logger.warn(`[${scene}] render returned empty image, fallback to text | ${ctxInfo}`);
|
|
@@ -132,8 +142,9 @@ async function sendImageWithFallback(session, image, fallbackText, scene, compre
|
|
|
132
142
|
try {
|
|
133
143
|
const result = await session.send(koishi_1.h.image(outputImage, detectImageMime(outputImage)));
|
|
134
144
|
if (!hasSendResult(result)) {
|
|
135
|
-
|
|
145
|
+
throw new Error('图片发送未返回消息 ID');
|
|
136
146
|
}
|
|
147
|
+
return true;
|
|
137
148
|
}
|
|
138
149
|
catch (e) {
|
|
139
150
|
logger.error(`[${scene}] image send failed | size=${outputImage.length}B | ${ctxInfo} | ${e}`);
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
export declare const SHARE_CODE_MAX_LENGTH: number;
|
|
2
|
+
export type ShareCodeSource = 'parse' | 'record';
|
|
3
|
+
export declare function extractShareCode(input: string): string;
|
|
4
|
+
export declare function parsedRecord(record: any): any | null;
|
|
5
|
+
export declare function buildShareCodeView(payload: any, source: ShareCodeSource, record?: any, baseUrl?: string): {
|
|
6
|
+
source: ShareCodeSource;
|
|
7
|
+
sourceLabel: string;
|
|
8
|
+
shareCode: string;
|
|
9
|
+
shareCodePreview: string;
|
|
10
|
+
version: number;
|
|
11
|
+
spriteCount: number;
|
|
12
|
+
mode: {
|
|
13
|
+
id: number;
|
|
14
|
+
name: string;
|
|
15
|
+
icon: string;
|
|
16
|
+
configured: boolean;
|
|
17
|
+
};
|
|
18
|
+
magic: {
|
|
19
|
+
id: number;
|
|
20
|
+
name: string;
|
|
21
|
+
icon: string;
|
|
22
|
+
configured: boolean;
|
|
23
|
+
};
|
|
24
|
+
teams: any;
|
|
25
|
+
parseCount: number;
|
|
26
|
+
firstSeen: string;
|
|
27
|
+
lastSeen: string;
|
|
28
|
+
shareCodeHash: string;
|
|
29
|
+
commandHint: string;
|
|
30
|
+
copyright: string;
|
|
31
|
+
};
|
|
32
|
+
export declare function buildShareCodeText(data: any): string;
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.SHARE_CODE_MAX_LENGTH = void 0;
|
|
4
|
+
exports.extractShareCode = extractShareCode;
|
|
5
|
+
exports.parsedRecord = parsedRecord;
|
|
6
|
+
exports.buildShareCodeView = buildShareCodeView;
|
|
7
|
+
exports.buildShareCodeText = buildShareCodeText;
|
|
8
|
+
const client_1 = require("./client");
|
|
9
|
+
const player_service_1 = require("./player-service");
|
|
10
|
+
exports.SHARE_CODE_MAX_LENGTH = 16 * 1024;
|
|
11
|
+
function extractShareCode(input) {
|
|
12
|
+
const raw = String(input ?? '').trim().replace(/^[`"']+|[`"']+$/g, '');
|
|
13
|
+
if (!raw)
|
|
14
|
+
return '';
|
|
15
|
+
if (raw.length > exports.SHARE_CODE_MAX_LENGTH)
|
|
16
|
+
throw new Error('分享码内容过长');
|
|
17
|
+
const match = /(?:[?&#]|^)share(?:Data|_data|_code)=([^&#\s]+)/i.exec(raw);
|
|
18
|
+
if (!match) {
|
|
19
|
+
if (/^https?:\/\//i.test(raw))
|
|
20
|
+
throw new Error('链接中没有分享码参数');
|
|
21
|
+
return raw;
|
|
22
|
+
}
|
|
23
|
+
let value = match[1];
|
|
24
|
+
for (let i = 0; i < 2; i++) {
|
|
25
|
+
let decoded;
|
|
26
|
+
try {
|
|
27
|
+
decoded = decodeURIComponent(value);
|
|
28
|
+
}
|
|
29
|
+
catch {
|
|
30
|
+
throw new Error('分享码链接的编码不完整');
|
|
31
|
+
}
|
|
32
|
+
if (decoded === value)
|
|
33
|
+
break;
|
|
34
|
+
value = decoded;
|
|
35
|
+
}
|
|
36
|
+
return value.trim().replace(/^[`"']+|[`"']+$/g, '');
|
|
37
|
+
}
|
|
38
|
+
// 避免对已存解析对象重复发 POST。
|
|
39
|
+
function parsedRecord(record) {
|
|
40
|
+
for (const candidate of [record?.share_code, record?.parsed, record]) {
|
|
41
|
+
if (candidate && typeof candidate === 'object' && Array.isArray(candidate.teams)) {
|
|
42
|
+
return candidate;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
return null;
|
|
46
|
+
}
|
|
47
|
+
function toInt(value, fallback = 0) {
|
|
48
|
+
const n = Number(value);
|
|
49
|
+
return Number.isFinite(n) ? Math.trunc(n) : fallback;
|
|
50
|
+
}
|
|
51
|
+
function text(value, fallback = '') {
|
|
52
|
+
if (value === null || value === undefined)
|
|
53
|
+
return fallback;
|
|
54
|
+
const s = String(value).trim();
|
|
55
|
+
return s || fallback;
|
|
56
|
+
}
|
|
57
|
+
function formatTime(value) {
|
|
58
|
+
const raw = text(value);
|
|
59
|
+
if (!raw)
|
|
60
|
+
return '';
|
|
61
|
+
const normalized = /^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}/.test(raw) && !/(Z|[+-]\d{2}:?\d{2})$/.test(raw)
|
|
62
|
+
? `${raw.replace(' ', 'T')}+08:00`
|
|
63
|
+
: raw.replace('Z', '+00:00');
|
|
64
|
+
const date = new Date(normalized);
|
|
65
|
+
if (Number.isNaN(date.getTime()))
|
|
66
|
+
return raw;
|
|
67
|
+
const shifted = new Date(date.getTime() + 8 * 3600_000);
|
|
68
|
+
const pad = (v) => String(v).padStart(2, '0');
|
|
69
|
+
return `${shifted.getUTCFullYear()}-${pad(shifted.getUTCMonth() + 1)}-${pad(shifted.getUTCDate())} ${pad(shifted.getUTCHours())}:${pad(shifted.getUTCMinutes())}`;
|
|
70
|
+
}
|
|
71
|
+
function normalizeUrl(value, baseUrl) {
|
|
72
|
+
return (0, player_service_1.resourceUrl)(value, baseUrl);
|
|
73
|
+
}
|
|
74
|
+
function wikiAssetUrl(baseUrl, path) {
|
|
75
|
+
if (!baseUrl)
|
|
76
|
+
return path;
|
|
77
|
+
return `${String(baseUrl).replace(/\/$/, '')}${path}`;
|
|
78
|
+
}
|
|
79
|
+
function normalizeNamedIcon(value, emptyName, baseUrl) {
|
|
80
|
+
const item = (0, client_1.isObject)(value) ? value : {};
|
|
81
|
+
const id = toInt(item.id);
|
|
82
|
+
return {
|
|
83
|
+
id,
|
|
84
|
+
name: text(item.name, emptyName),
|
|
85
|
+
icon: normalizeUrl(item.icon, baseUrl),
|
|
86
|
+
configured: Boolean(id || text(item.name) || text(item.icon)),
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
function normalizeTeam(value, index, baseUrl) {
|
|
90
|
+
const team = (0, client_1.isObject)(value) ? value : {};
|
|
91
|
+
const pet = normalizeNamedIcon(team.pet, '未配置精灵', baseUrl);
|
|
92
|
+
if (pet.id)
|
|
93
|
+
pet.icon = wikiAssetUrl(baseUrl, `/api/v1/resources/wiki/assets/pets/${pet.id}/icon.png`);
|
|
94
|
+
const bloodline = normalizeNamedIcon(team.bloodline, '未配置血脉', baseUrl);
|
|
95
|
+
const personality = normalizeNamedIcon(team.personality, '未配置性格', baseUrl);
|
|
96
|
+
const ivs = [];
|
|
97
|
+
for (const raw of Array.isArray(team.ivs_detail) ? team.ivs_detail : []) {
|
|
98
|
+
ivs.push(normalizeNamedIcon(raw, '未配置', baseUrl));
|
|
99
|
+
}
|
|
100
|
+
while (ivs.length < 3)
|
|
101
|
+
ivs.push(normalizeNamedIcon(null, '未配置', baseUrl));
|
|
102
|
+
const skills = [];
|
|
103
|
+
for (const raw of Array.isArray(team.skills) ? team.skills : []) {
|
|
104
|
+
const skill = normalizeNamedIcon(raw, '未配置技能', baseUrl);
|
|
105
|
+
if (skill.id)
|
|
106
|
+
skill.icon = wikiAssetUrl(baseUrl, `/api/v1/resources/wiki/assets/skills/${skill.id}.png`);
|
|
107
|
+
skills.push(skill);
|
|
108
|
+
}
|
|
109
|
+
while (skills.length < 4)
|
|
110
|
+
skills.push(normalizeNamedIcon(null, '未配置技能', baseUrl));
|
|
111
|
+
return {
|
|
112
|
+
slot: toInt(team.slot, index + 1),
|
|
113
|
+
pet,
|
|
114
|
+
bloodline,
|
|
115
|
+
personality,
|
|
116
|
+
ivs: ivs.slice(0, 3),
|
|
117
|
+
skills: skills.slice(0, 4),
|
|
118
|
+
empty: !pet.configured,
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
function unwrapRecord(record) {
|
|
122
|
+
if (!(0, client_1.isObject)(record))
|
|
123
|
+
return { payload: {}, meta: {} };
|
|
124
|
+
if ((0, client_1.isObject)(record.share_code))
|
|
125
|
+
return { payload: record.share_code, meta: record };
|
|
126
|
+
if ((0, client_1.isObject)(record.parsed))
|
|
127
|
+
return { payload: record.parsed, meta: record };
|
|
128
|
+
return { payload: record, meta: {} };
|
|
129
|
+
}
|
|
130
|
+
function shareCodePreview(code) {
|
|
131
|
+
if (code.length <= 36)
|
|
132
|
+
return code;
|
|
133
|
+
return `${code.slice(0, 20)}...${code.slice(-12)}`;
|
|
134
|
+
}
|
|
135
|
+
function buildShareCodeView(payload, source, record, baseUrl = '') {
|
|
136
|
+
let data = (0, client_1.isObject)(payload) ? payload : {};
|
|
137
|
+
let recordMeta = {};
|
|
138
|
+
if (record) {
|
|
139
|
+
const unwrapped = unwrapRecord(record);
|
|
140
|
+
if (!data.teams)
|
|
141
|
+
data = unwrapped.payload;
|
|
142
|
+
recordMeta = unwrapped.meta;
|
|
143
|
+
}
|
|
144
|
+
const teams = (Array.isArray(data.teams) ? data.teams : [])
|
|
145
|
+
.map((item, index) => normalizeTeam(item, index, baseUrl))
|
|
146
|
+
.sort((a, b) => a.slot - b.slot);
|
|
147
|
+
const shareCode = text(data.share_code);
|
|
148
|
+
const mode = normalizeNamedIcon(data.mode, '未知编队模式', baseUrl);
|
|
149
|
+
const magic = normalizeNamedIcon(data.magic, '未配置魔法', baseUrl);
|
|
150
|
+
if (magic.configured && magic.name === '未配置魔法')
|
|
151
|
+
magic.name = `魔法 ${magic.id}`;
|
|
152
|
+
const spriteCount = toInt(data.sprite_count, teams.length);
|
|
153
|
+
return {
|
|
154
|
+
source,
|
|
155
|
+
sourceLabel: source === 'record' ? '历史记录' : '即时解析',
|
|
156
|
+
shareCode,
|
|
157
|
+
shareCodePreview: shareCodePreview(shareCode),
|
|
158
|
+
version: toInt(data.version, 1),
|
|
159
|
+
spriteCount,
|
|
160
|
+
mode,
|
|
161
|
+
magic,
|
|
162
|
+
teams,
|
|
163
|
+
parseCount: toInt(recordMeta.parse_count),
|
|
164
|
+
firstSeen: formatTime(recordMeta.first_seen_at),
|
|
165
|
+
lastSeen: formatTime(recordMeta.last_seen_at),
|
|
166
|
+
shareCodeHash: text(recordMeta.share_code_hash),
|
|
167
|
+
commandHint: '阵容码 解析 <分享码或链接> · 阵容码 查询 <分享码>',
|
|
168
|
+
copyright: 'Koishi & WeGame 洛克王国插件',
|
|
169
|
+
};
|
|
170
|
+
}
|
|
171
|
+
function buildShareCodeText(data) {
|
|
172
|
+
const lines = [
|
|
173
|
+
`阵容码${data.sourceLabel || '解析'}:${data.mode?.name || '未知模式'},共 ${data.spriteCount || 0} 只精灵`,
|
|
174
|
+
];
|
|
175
|
+
for (const team of data.teams || []) {
|
|
176
|
+
const talents = (team.ivs || []).filter((item) => item.configured).map((item) => item.name);
|
|
177
|
+
const skillNames = (team.skills || []).filter((item) => item.configured).map((item) => item.name);
|
|
178
|
+
lines.push(`${team.slot}. ${team.pet.name} | ${team.bloodline.name} | ${team.personality.name} | 天赋:${talents.join('、') || '未配置'} | 技能:${skillNames.join('、') || '未配置'}`);
|
|
179
|
+
}
|
|
180
|
+
if (data.parseCount) {
|
|
181
|
+
lines.push(`解析次数:${data.parseCount},最近记录:${data.lastSeen || '未知'}`);
|
|
182
|
+
}
|
|
183
|
+
return lines.join('\n');
|
|
184
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.createRunner = createRunner;
|
|
4
|
+
// 每个插件实例的互斥与停止标记:取消在途任务、阻止重叠检查。
|
|
5
|
+
function createRunner(ctx) {
|
|
6
|
+
const controller = new AbortController();
|
|
7
|
+
let running = false;
|
|
8
|
+
ctx.on('dispose', () => controller.abort());
|
|
9
|
+
const runner = Object.assign(async (job) => {
|
|
10
|
+
if (running || controller.signal.aborted)
|
|
11
|
+
return;
|
|
12
|
+
running = true;
|
|
13
|
+
try {
|
|
14
|
+
return await job(controller.signal);
|
|
15
|
+
}
|
|
16
|
+
finally {
|
|
17
|
+
running = false;
|
|
18
|
+
}
|
|
19
|
+
}, { aborted: () => controller.signal.aborted });
|
|
20
|
+
return runner;
|
|
21
|
+
}
|
|
@@ -4,6 +4,8 @@ export interface SubscriptionTarget {
|
|
|
4
4
|
channelId?: string;
|
|
5
5
|
guildId?: string;
|
|
6
6
|
userId?: string;
|
|
7
|
+
selfId?: string;
|
|
7
8
|
}
|
|
8
|
-
export declare function
|
|
9
|
-
export declare function
|
|
9
|
+
export declare function sentMessageIds(value: unknown): boolean;
|
|
10
|
+
export declare function sendScheduledMessage(ctx: Context, target: SubscriptionTarget, message: any, signal?: AbortSignal): Promise<boolean>;
|
|
11
|
+
export declare function sendScheduledImageWithFallback(ctx: Context, target: SubscriptionTarget, image: Buffer | Buffer[] | null, fallbackText: string, mentionAll?: boolean, signal?: AbortSignal): Promise<boolean>;
|
package/lib/subscription-send.js
CHANGED
|
@@ -1,22 +1,39 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.sentMessageIds = sentMessageIds;
|
|
3
4
|
exports.sendScheduledMessage = sendScheduledMessage;
|
|
4
5
|
exports.sendScheduledImageWithFallback = sendScheduledImageWithFallback;
|
|
5
6
|
const koishi_1 = require("koishi");
|
|
6
7
|
const send_image_1 = require("./send-image");
|
|
7
8
|
const logger = new koishi_1.Logger('rocom-subscription-send');
|
|
8
|
-
function findBot(ctx, platform = '') {
|
|
9
|
+
function findBot(ctx, platform = '', selfId = '') {
|
|
9
10
|
if (!ctx.bots?.length)
|
|
10
11
|
return null;
|
|
11
|
-
if (
|
|
12
|
-
|
|
13
|
-
|
|
12
|
+
if (selfId) {
|
|
13
|
+
const exact = ctx.bots.find((bot) => String(bot.selfId) === String(selfId)
|
|
14
|
+
&& (!platform || bot.platform === platform));
|
|
15
|
+
return exact || null;
|
|
16
|
+
}
|
|
17
|
+
const candidates = platform ? ctx.bots.filter((bot) => bot.platform === platform) : ctx.bots;
|
|
18
|
+
if (!candidates.length)
|
|
19
|
+
return null;
|
|
20
|
+
// 缺少 self_id 的旧订阅只在对应平台恰有一个 Bot 时兼容,避免误发其他 Bot。
|
|
21
|
+
if (candidates.length > 1) {
|
|
22
|
+
logger.warn(`平台 ${platform || '(unknown)'} 存在多个 Bot 且订阅缺少 self_id,跳过发送`);
|
|
23
|
+
return null;
|
|
24
|
+
}
|
|
25
|
+
return candidates[0];
|
|
26
|
+
}
|
|
27
|
+
function sentMessageIds(value) {
|
|
28
|
+
return Array.isArray(value) && value.some(id => typeof id === 'string' && id.trim().length > 0);
|
|
14
29
|
}
|
|
15
|
-
async function sendScheduledMessage(ctx, target, message) {
|
|
30
|
+
async function sendScheduledMessage(ctx, target, message, signal) {
|
|
31
|
+
if (signal?.aborted)
|
|
32
|
+
return false;
|
|
16
33
|
const platform = target.platform || '';
|
|
17
34
|
const channelId = target.channelId || target.guildId || '';
|
|
18
35
|
const userId = target.userId || '';
|
|
19
|
-
const bot = findBot(ctx, platform);
|
|
36
|
+
const bot = findBot(ctx, platform, target.selfId);
|
|
20
37
|
if (!bot) {
|
|
21
38
|
logger.warn('no available bot, skip scheduled push');
|
|
22
39
|
return false;
|
|
@@ -24,21 +41,14 @@ async function sendScheduledMessage(ctx, target, message) {
|
|
|
24
41
|
try {
|
|
25
42
|
if (userId && !target.guildId) {
|
|
26
43
|
if (typeof bot.sendPrivateMessage === 'function') {
|
|
27
|
-
await bot.sendPrivateMessage(userId, message);
|
|
28
|
-
return true;
|
|
44
|
+
return sentMessageIds(await bot.sendPrivateMessage(userId, message)) && !signal?.aborted;
|
|
29
45
|
}
|
|
30
46
|
if (typeof bot.sendMessage === 'function') {
|
|
31
|
-
await bot.sendMessage(userId, message);
|
|
32
|
-
return true;
|
|
47
|
+
return sentMessageIds(await bot.sendMessage(userId, message)) && !signal?.aborted;
|
|
33
48
|
}
|
|
34
49
|
}
|
|
35
|
-
if (platform && channelId) {
|
|
36
|
-
await ctx.broadcast([`${platform}:${channelId}`], message);
|
|
37
|
-
return true;
|
|
38
|
-
}
|
|
39
50
|
if (channelId && typeof bot.sendMessage === 'function') {
|
|
40
|
-
await bot.sendMessage(channelId, message, target.guildId);
|
|
41
|
-
return true;
|
|
51
|
+
return sentMessageIds(await bot.sendMessage(channelId, message, target.guildId || undefined)) && !signal?.aborted;
|
|
42
52
|
}
|
|
43
53
|
}
|
|
44
54
|
catch (err) {
|
|
@@ -48,14 +58,29 @@ async function sendScheduledMessage(ctx, target, message) {
|
|
|
48
58
|
logger.warn(`scheduled push target is incomplete: ${JSON.stringify(target)}`);
|
|
49
59
|
return false;
|
|
50
60
|
}
|
|
51
|
-
async function sendScheduledImageWithFallback(ctx, target, image, fallbackText, mentionAll = false) {
|
|
61
|
+
async function sendScheduledImageWithFallback(ctx, target, image, fallbackText, mentionAll = false, signal) {
|
|
62
|
+
if (signal?.aborted)
|
|
63
|
+
return false;
|
|
64
|
+
if (Array.isArray(image)) {
|
|
65
|
+
if (!image.length)
|
|
66
|
+
return sendScheduledImageWithFallback(ctx, target, null, fallbackText, mentionAll, signal);
|
|
67
|
+
for (let i = 0; i < image.length; i++) {
|
|
68
|
+
const segment = koishi_1.h.image(image[i], (0, send_image_1.detectImageMime)(image[i]));
|
|
69
|
+
const content = mentionAll && i === 0 ? (0, koishi_1.h)('message', {}, (0, koishi_1.h)('at', { type: 'all' }), segment) : segment;
|
|
70
|
+
if (!await sendScheduledMessage(ctx, target, content, signal)) {
|
|
71
|
+
// One complete text fallback replaces the remaining image pages.
|
|
72
|
+
return sendScheduledMessage(ctx, target, koishi_1.h.text(fallbackText), signal);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
return true;
|
|
76
|
+
}
|
|
52
77
|
const buildContent = (body) => mentionAll ? (0, koishi_1.h)('message', {}, (0, koishi_1.h)('at', { type: 'all' }), koishi_1.h.text('\n'), body) : body;
|
|
53
78
|
if (!image) {
|
|
54
|
-
return sendScheduledMessage(ctx, target, buildContent(koishi_1.h.text(fallbackText)));
|
|
79
|
+
return sendScheduledMessage(ctx, target, buildContent(koishi_1.h.text(fallbackText)), signal);
|
|
55
80
|
}
|
|
56
81
|
const imageSegment = koishi_1.h.image(image, (0, send_image_1.detectImageMime)(image));
|
|
57
|
-
const sent = await sendScheduledMessage(ctx, target, buildContent(imageSegment));
|
|
82
|
+
const sent = await sendScheduledMessage(ctx, target, buildContent(imageSegment), signal);
|
|
58
83
|
if (sent)
|
|
59
84
|
return true;
|
|
60
|
-
return sendScheduledMessage(ctx, target, buildContent(koishi_1.h.text(fallbackText)));
|
|
85
|
+
return sendScheduledMessage(ctx, target, buildContent(koishi_1.h.text(fallbackText)), signal);
|
|
61
86
|
}
|