koishi-plugin-chat-analyse 0.5.0 → 0.5.2
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/Collector.d.ts +88 -0
- package/lib/Renderer.d.ts +49 -0
- package/lib/index.js +72 -32
- package/package.json +1 -1
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { Context } from 'koishi';
|
|
2
|
+
import { Config } from './index';
|
|
3
|
+
declare module 'koishi' {
|
|
4
|
+
interface Tables {
|
|
5
|
+
analyse_user: {
|
|
6
|
+
uid: number;
|
|
7
|
+
channelId: string;
|
|
8
|
+
userId: string;
|
|
9
|
+
channelName: string;
|
|
10
|
+
userName: string;
|
|
11
|
+
};
|
|
12
|
+
analyse_cmd: {
|
|
13
|
+
uid: number;
|
|
14
|
+
command: string;
|
|
15
|
+
count: number;
|
|
16
|
+
timestamp: Date;
|
|
17
|
+
};
|
|
18
|
+
analyse_msg: {
|
|
19
|
+
uid: number;
|
|
20
|
+
type: string;
|
|
21
|
+
count: number;
|
|
22
|
+
timestamp: Date;
|
|
23
|
+
};
|
|
24
|
+
analyse_rank: {
|
|
25
|
+
uid: number;
|
|
26
|
+
type: string;
|
|
27
|
+
count: number;
|
|
28
|
+
timestamp: Date;
|
|
29
|
+
};
|
|
30
|
+
analyse_cache: {
|
|
31
|
+
id: number;
|
|
32
|
+
uid: number;
|
|
33
|
+
content: string;
|
|
34
|
+
timestamp: Date;
|
|
35
|
+
};
|
|
36
|
+
analyse_at: {
|
|
37
|
+
id: number;
|
|
38
|
+
uid: number;
|
|
39
|
+
target: string;
|
|
40
|
+
content: string;
|
|
41
|
+
timestamp: Date;
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* @class Collector
|
|
47
|
+
* @description 核心数据收集器。根据配置,高效地监听、收集、缓冲并持久化聊天数据。
|
|
48
|
+
*/
|
|
49
|
+
export declare class Collector {
|
|
50
|
+
private ctx;
|
|
51
|
+
private config;
|
|
52
|
+
/** @property FLUSH_INTERVAL - 内存缓存区定时刷入数据库的间隔(毫秒)。 */
|
|
53
|
+
private static readonly FLUSH_INTERVAL;
|
|
54
|
+
/** @property BUFFER_THRESHOLD - 内存缓存区触发刷新的消息数量阈值。 */
|
|
55
|
+
private static readonly BUFFER_THRESHOLD;
|
|
56
|
+
private msgStatBuffer;
|
|
57
|
+
private rankStatBuffer;
|
|
58
|
+
private cmdStatBuffer;
|
|
59
|
+
private oriCacheBuffer;
|
|
60
|
+
private whoAtBuffer;
|
|
61
|
+
private userCache;
|
|
62
|
+
private channelCache;
|
|
63
|
+
private pendingRequests;
|
|
64
|
+
private flushInterval;
|
|
65
|
+
/**
|
|
66
|
+
* @param ctx - Koishi 的插件上下文。
|
|
67
|
+
* @param config - 插件的配置对象。
|
|
68
|
+
*/
|
|
69
|
+
constructor(ctx: Context, config: Config);
|
|
70
|
+
/**
|
|
71
|
+
* @private @method onMessage
|
|
72
|
+
* @description 统一的消息事件处理器,解析消息并更新各类统计数据的缓冲区。
|
|
73
|
+
* @param session - Koishi 的会话对象。
|
|
74
|
+
*/
|
|
75
|
+
private onMessage;
|
|
76
|
+
/**
|
|
77
|
+
* @private @method sanitizeContent
|
|
78
|
+
* @description 将 Koishi 消息元素数组净化为纯文本字符串。
|
|
79
|
+
* @param elements - 消息元素数组。
|
|
80
|
+
* @returns 净化后的纯文本。
|
|
81
|
+
*/
|
|
82
|
+
private sanitizeContent;
|
|
83
|
+
/**
|
|
84
|
+
* @private @method flushBuffers
|
|
85
|
+
* @description 将所有内存中的数据缓冲区批量写入数据库,并清空缓冲区。
|
|
86
|
+
*/
|
|
87
|
+
private flushBuffers;
|
|
88
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { Context } from 'koishi';
|
|
2
|
+
/** 定义了渲染列表中单行数据的格式,是一个由字符串、数字或 `Date` 对象构成的数组。 */
|
|
3
|
+
export type RenderListItem = (string | number | Date)[];
|
|
4
|
+
/**
|
|
5
|
+
* @interface ListRenderData
|
|
6
|
+
* @description 定义了调用 `renderList` 方法所需的数据结构,包含了渲染一张完整列表图片所必需的所有信息。
|
|
7
|
+
*/
|
|
8
|
+
export interface ListRenderData {
|
|
9
|
+
title: string;
|
|
10
|
+
time: Date;
|
|
11
|
+
total?: string | number;
|
|
12
|
+
list: RenderListItem[];
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* @class Renderer
|
|
16
|
+
* @description 负责将结构化的列表数据渲染为设计精美的 PNG 图片。其核心特性是能够动态计算内容尺寸,生成布局紧凑、自适应的图片。
|
|
17
|
+
*/
|
|
18
|
+
export declare class Renderer {
|
|
19
|
+
private ctx;
|
|
20
|
+
/**
|
|
21
|
+
* @param ctx - Koishi 的插件上下文,用于访问 `puppeteer` 等核心服务。
|
|
22
|
+
*/
|
|
23
|
+
constructor(ctx: Context);
|
|
24
|
+
/**
|
|
25
|
+
* @private
|
|
26
|
+
* @method htmlToImage
|
|
27
|
+
* @description 将一个完整的 HTML 文档字符串转换为 PNG 图片 Buffer。
|
|
28
|
+
* @param fullHtmlContent - 要渲染的、包含 `<html>...</html>` 的完整HTML字符串。
|
|
29
|
+
* @returns 返回一个包含 PNG 图片数据的 Buffer,失败则返回 null。
|
|
30
|
+
*/
|
|
31
|
+
private htmlToImage;
|
|
32
|
+
/**
|
|
33
|
+
* @private
|
|
34
|
+
* @method formatDate
|
|
35
|
+
* @description 将 `Date` 对象格式化为易于理解的相对时间或绝对日期字符串。
|
|
36
|
+
* @param date - 需要格式化的日期对象。
|
|
37
|
+
* @returns 格式化后的时间字符串。
|
|
38
|
+
*/
|
|
39
|
+
private formatDate;
|
|
40
|
+
/**
|
|
41
|
+
* @public
|
|
42
|
+
* @method renderList
|
|
43
|
+
* @description 构建并渲染一个包含标题、统计信息和数据表格的 HTML 卡片为图片。如果数据过多,则会分片渲染成多张图片。
|
|
44
|
+
* @param data - 包含渲染所需全部信息的对象。
|
|
45
|
+
* @param headers - (可选) 表格的表头字符串数组。
|
|
46
|
+
* @returns 成功时返回包含 PNG 图片的 Buffer 数组,若列表为空则返回提示字符串。
|
|
47
|
+
*/
|
|
48
|
+
renderList(data: ListRenderData, headers?: string[]): Promise<string | Buffer[]>;
|
|
49
|
+
}
|
package/lib/index.js
CHANGED
|
@@ -68,7 +68,7 @@ var Collector = class _Collector {
|
|
|
68
68
|
/** @property FLUSH_INTERVAL - 内存缓存区定时刷入数据库的间隔(毫秒)。 */
|
|
69
69
|
static FLUSH_INTERVAL = import_koishi.Time.minute;
|
|
70
70
|
/** @property BUFFER_THRESHOLD - 内存缓存区触发刷新的消息数量阈值。 */
|
|
71
|
-
static BUFFER_THRESHOLD =
|
|
71
|
+
static BUFFER_THRESHOLD = 60;
|
|
72
72
|
// 统一的数据缓冲区
|
|
73
73
|
msgStatBuffer = /* @__PURE__ */ new Map();
|
|
74
74
|
rankStatBuffer = /* @__PURE__ */ new Map();
|
|
@@ -76,7 +76,8 @@ var Collector = class _Collector {
|
|
|
76
76
|
oriCacheBuffer = [];
|
|
77
77
|
whoAtBuffer = [];
|
|
78
78
|
userCache = /* @__PURE__ */ new Map();
|
|
79
|
-
|
|
79
|
+
channelCache = /* @__PURE__ */ new Map();
|
|
80
|
+
pendingRequests = /* @__PURE__ */ new Map();
|
|
80
81
|
flushInterval;
|
|
81
82
|
/**
|
|
82
83
|
* @private @method onMessage
|
|
@@ -90,35 +91,41 @@ var Collector = class _Collector {
|
|
|
90
91
|
let user;
|
|
91
92
|
if (this.userCache.has(cacheKey)) {
|
|
92
93
|
user = this.userCache.get(cacheKey);
|
|
93
|
-
} else if (this.
|
|
94
|
-
user = await this.
|
|
94
|
+
} else if (this.pendingRequests.has(cacheKey)) {
|
|
95
|
+
user = await this.pendingRequests.get(cacheKey);
|
|
95
96
|
} else {
|
|
96
97
|
const promise = (async () => {
|
|
97
98
|
try {
|
|
98
99
|
const [dbUser] = await this.ctx.database.get("analyse_user", { channelId, userId });
|
|
99
100
|
const currentUserName = session.username ?? "";
|
|
100
|
-
|
|
101
|
-
|
|
101
|
+
let currentChannelName = this.channelCache.get(channelId);
|
|
102
|
+
if (currentChannelName === void 0) {
|
|
103
|
+
const guild = await bot.getGuild(channelId).catch(() => null);
|
|
104
|
+
currentChannelName = guild?.name ?? "";
|
|
105
|
+
if (currentChannelName) this.channelCache.set(channelId, currentChannelName);
|
|
106
|
+
}
|
|
102
107
|
if (dbUser) {
|
|
103
108
|
if (currentUserName && dbUser.userName !== currentUserName || currentChannelName && dbUser.channelName !== currentChannelName) {
|
|
104
109
|
await this.ctx.database.set("analyse_user", { uid: dbUser.uid }, { userName: currentUserName, channelName: currentChannelName });
|
|
105
110
|
dbUser.userName = currentUserName;
|
|
106
111
|
dbUser.channelName = currentChannelName;
|
|
107
112
|
}
|
|
108
|
-
|
|
109
|
-
|
|
113
|
+
const cacheData2 = { uid: dbUser.uid, userName: dbUser.userName };
|
|
114
|
+
this.userCache.set(cacheKey, cacheData2);
|
|
115
|
+
return cacheData2;
|
|
110
116
|
}
|
|
111
117
|
const createdUser = await this.ctx.database.create("analyse_user", { channelId, userId, userName: currentUserName, channelName: currentChannelName });
|
|
112
|
-
|
|
113
|
-
|
|
118
|
+
const cacheData = { uid: createdUser.uid, userName: createdUser.userName };
|
|
119
|
+
this.userCache.set(cacheKey, cacheData);
|
|
120
|
+
return cacheData;
|
|
114
121
|
} catch (error) {
|
|
115
122
|
this.ctx.logger.error(`创建或获取用户(${cacheKey})失败:`, error);
|
|
116
123
|
return null;
|
|
117
124
|
} finally {
|
|
118
|
-
this.
|
|
125
|
+
this.pendingRequests.delete(cacheKey);
|
|
119
126
|
}
|
|
120
127
|
})();
|
|
121
|
-
this.
|
|
128
|
+
this.pendingRequests.set(cacheKey, promise);
|
|
122
129
|
user = await promise;
|
|
123
130
|
}
|
|
124
131
|
if (!user) return;
|
|
@@ -215,15 +222,15 @@ var Renderer = class {
|
|
|
215
222
|
/**
|
|
216
223
|
* @private
|
|
217
224
|
* @method htmlToImage
|
|
218
|
-
* @description
|
|
219
|
-
* @param
|
|
220
|
-
* @returns 返回一个包含 PNG 图片数据的 Buffer。
|
|
225
|
+
* @description 将一个完整的 HTML 文档字符串转换为 PNG 图片 Buffer。
|
|
226
|
+
* @param fullHtmlContent - 要渲染的、包含 `<html>...</html>` 的完整HTML字符串。
|
|
227
|
+
* @returns 返回一个包含 PNG 图片数据的 Buffer,失败则返回 null。
|
|
221
228
|
*/
|
|
222
|
-
async htmlToImage(
|
|
229
|
+
async htmlToImage(fullHtmlContent) {
|
|
223
230
|
const page = await this.ctx.puppeteer.page();
|
|
224
231
|
try {
|
|
225
232
|
await page.setViewport({ width: 720, height: 1080, deviceScaleFactor: 2 });
|
|
226
|
-
await page.setContent(
|
|
233
|
+
await page.setContent(fullHtmlContent, { waitUntil: "networkidle0" });
|
|
227
234
|
const dimensions = await page.evaluate(() => ({ width: document.body.scrollWidth, height: document.body.scrollHeight }));
|
|
228
235
|
await page.setViewport({ ...dimensions, deviceScaleFactor: 2 });
|
|
229
236
|
return await page.screenshot({ type: "png", fullPage: true, omitBackground: true });
|
|
@@ -257,17 +264,19 @@ var Renderer = class {
|
|
|
257
264
|
/**
|
|
258
265
|
* @public
|
|
259
266
|
* @method renderList
|
|
260
|
-
* @description 构建并渲染一个包含标题、统计信息和数据表格的 HTML
|
|
267
|
+
* @description 构建并渲染一个包含标题、统计信息和数据表格的 HTML 卡片为图片。如果数据过多,则会分片渲染成多张图片。
|
|
261
268
|
* @param data - 包含渲染所需全部信息的对象。
|
|
262
269
|
* @param headers - (可选) 表格的表头字符串数组。
|
|
263
|
-
* @returns 成功时返回包含 PNG 图片的 Buffer
|
|
270
|
+
* @returns 成功时返回包含 PNG 图片的 Buffer 数组,若列表为空则返回提示字符串。
|
|
264
271
|
*/
|
|
265
272
|
async renderList(data, headers) {
|
|
266
273
|
const { title, time, list } = data;
|
|
267
274
|
if (!list?.length) return "暂无数据可供渲染";
|
|
275
|
+
const CHUNK_SIZE = 100;
|
|
276
|
+
const imageBuffers = [];
|
|
277
|
+
const totalItems = list.length;
|
|
268
278
|
const countHeaderIndex = headers?.findIndex((h4) => ["总计发言", "条数", "次数", "数量"].includes(h4)) ?? -1;
|
|
269
|
-
const
|
|
270
|
-
const totalCount = data.total || totalValue;
|
|
279
|
+
const totalCount = data.total || (countHeaderIndex > -1 ? list.reduce((sum, row) => sum + (Number(row[countHeaderIndex]) || 0), 0) : totalItems);
|
|
271
280
|
const renderCell = /* @__PURE__ */ __name((cell, i) => {
|
|
272
281
|
const headerText = headers?.[i] || "";
|
|
273
282
|
if (headerText.includes("占比")) {
|
|
@@ -278,14 +287,34 @@ var Renderer = class {
|
|
|
278
287
|
if (typeof cell === "number") return `<td class="count-cell">${cell.toLocaleString()}</td>`;
|
|
279
288
|
return `<td class="name-cell">${String(cell)}</td>`;
|
|
280
289
|
}, "renderCell");
|
|
281
|
-
const
|
|
282
|
-
|
|
283
|
-
const
|
|
284
|
-
const
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
290
|
+
const totalPages = Math.ceil(totalItems / CHUNK_SIZE);
|
|
291
|
+
for (let i = 0; i < totalItems; i += CHUNK_SIZE) {
|
|
292
|
+
const chunk = list.slice(i, i + CHUNK_SIZE);
|
|
293
|
+
const pageNum = Math.floor(i / CHUNK_SIZE) + 1;
|
|
294
|
+
const pageTitle = totalPages > 1 ? `${title} (第 ${pageNum}/${totalPages} 页)` : title;
|
|
295
|
+
const tableRowsHtml = chunk.map((row, index) => {
|
|
296
|
+
const rank = i + index + 1;
|
|
297
|
+
const rankClass = rank === 1 ? "rank-gold" : rank === 2 ? "rank-silver" : rank === 3 ? "rank-bronze" : "";
|
|
298
|
+
return `<tr><td class="rank-cell ${rankClass}">${rank}</td>${row.map(renderCell).join("")}</tr>`;
|
|
299
|
+
}).join("");
|
|
300
|
+
const tableHeadHtml = headers?.length ? `<thead><tr><th class="rank-cell">#</th>${headers.map((h4) => `<th class="${typeof list[0]?.[headers.indexOf(h4)] === "string" ? "name-header" : "header-right-align"}">${h4}</th>`).join("")}</tr></thead>` : "";
|
|
301
|
+
const cardHtml = `<div class="container"><div class="header"><table class="header-table"><tr><td class="header-table-left"><div class="stat-chip">总计: <span>${typeof totalCount === "number" ? totalCount.toLocaleString() : totalCount}</span></div></td><td class="header-table-center"><h1 class="title-text">${pageTitle}</h1></td><td class="header-table-right"><div class="time-label">${time.toLocaleString("zh-CN", { hour12: false }).replace(/\//g, "-")}</div></td></tr></table></div><div class="table-container"><table class="main-table">${tableHeadHtml}<tbody>${tableRowsHtml}</tbody></table></div></div>`;
|
|
302
|
+
const fullHtml = `<!DOCTYPE html>
|
|
303
|
+
<html>
|
|
304
|
+
<head>
|
|
305
|
+
<meta charset="UTF-8">
|
|
306
|
+
<style>:root{--card-bg:#fff;--text-color:#111827;--header-color:#111827;--sub-text-color:#6b7280;--border-color:#e5e7eb;--accent-color:#4a6ee0;--chip-bg:#f3f4f6;--stripe-bg:#f9fafb;--gold:#f59e0b;--silver:#9ca3af;--bronze:#a16207}body{display:inline-block;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,'Helvetica Neue',Arial,sans-serif;background:0 0;margin:0;padding:8px;-webkit-font-smoothing:antialiased}.container{display:inline-block;background:var(--card-bg);border-radius:12px;padding:0;overflow:hidden;box-shadow:0 2px 4px rgba(0,0,0,.05)}.header{padding:10px 14px}.header-table{border-collapse:collapse;width:100%}.header-table-left,.header-table-right{width:1%;white-space:nowrap}.header-table-left{text-align:left}.header-table-center{text-align:center}.header-table-right{text-align:right}.title-text{font-size:18px;font-weight:600;color:var(--header-color);margin:0}.stat-chip,.time-label{display:inline-flex;align-items:baseline;padding:4px 8px;border-radius:8px;background:var(--chip-bg);font-size:13px;color:var(--sub-text-color)}.stat-chip span{font-weight:600;color:var(--text-color);margin-left:4px}.table-container{border-top:1px solid var(--border-color)}.main-table{border-collapse:collapse;width:100%}.main-table th,.main-table td{padding:8px 14px;vertical-align:middle}.main-table th{font-size:12px;font-weight:500;color:var(--sub-text-color);text-transform:uppercase;letter-spacing:.05em;background:var(--stripe-bg)}.main-table td{font-size:14px;color:var(--text-color)}.main-table tbody tr:nth-child(even){background-color:var(--stripe-bg)}.main-table .name-cell,.main-table .name-header{text-align:left}.main-table .rank-cell,.main-table .count-cell,.main-table .date-cell,.main-table .percent-cell,.main-table .header-right-align{text-align:right;white-space:nowrap;width:1%;font-variant-numeric:tabular-nums}.name-cell{font-weight:500}.rank-cell{font-weight:500;color:var(--sub-text-color)}.count-cell{font-weight:600;color:var(--accent-color)}.date-cell{color:var(--sub-text-color)}.rank-gold,.rank-silver,.rank-bronze{font-weight:600}.rank-gold{color:var(--gold)!important}.rank-silver{color:var(--silver)!important}.rank-bronze{color:var(--bronze)!important}.percent-cell{position:relative}.percent-bar{position:absolute;top:0;right:0;height:100%;background-color:var(--accent-color);opacity:.15}.percent-text{position:relative;z-index:1}</style>
|
|
307
|
+
</head>
|
|
308
|
+
<body>
|
|
309
|
+
${cardHtml}
|
|
310
|
+
</body>
|
|
311
|
+
</html>`;
|
|
312
|
+
const imageBuffer = await this.htmlToImage(fullHtml);
|
|
313
|
+
if (imageBuffer) {
|
|
314
|
+
imageBuffers.push(imageBuffer);
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
return imageBuffers.length > 0 ? imageBuffers : "图片渲染失败";
|
|
289
318
|
}
|
|
290
319
|
};
|
|
291
320
|
|
|
@@ -322,7 +351,13 @@ var Stat = class {
|
|
|
322
351
|
if (scope.error) return scope.error;
|
|
323
352
|
try {
|
|
324
353
|
const result = await handler(scope, options);
|
|
325
|
-
|
|
354
|
+
if (typeof result === "string") return result;
|
|
355
|
+
if (Array.isArray(result)) {
|
|
356
|
+
if (result.length === 0) return "图片渲染失败";
|
|
357
|
+
for (const buffer of result) await session.sendQueued(import_koishi3.h.image(buffer, "image/png"));
|
|
358
|
+
return;
|
|
359
|
+
}
|
|
360
|
+
if (Buffer.isBuffer(result)) return import_koishi3.h.image(result, "image/png");
|
|
326
361
|
} catch (error) {
|
|
327
362
|
this.ctx.logger.error("渲染统计图片失败:", error);
|
|
328
363
|
return "渲染统计图片失败";
|
|
@@ -370,7 +405,7 @@ var Stat = class {
|
|
|
370
405
|
const uidsInScope = guildId ? (await this.ctx.database.get("analyse_user", { channelId: guildId }, ["uid"])).map((u) => u.uid) : void 0;
|
|
371
406
|
if (guildId && uidsInScope.length === 0) return "暂无指定时段内发言记录";
|
|
372
407
|
if (uidsInScope) baseQuery.uid = { $in: uidsInScope };
|
|
373
|
-
const rankStats = await this.ctx.database.select("analyse_rank").where(baseQuery).groupBy("uid", { count: /* @__PURE__ */ __name((row) => import_koishi3.$.sum(row.count), "count") }).orderBy("count", "desc").
|
|
408
|
+
const rankStats = await this.ctx.database.select("analyse_rank").where(baseQuery).groupBy("uid", { count: /* @__PURE__ */ __name((row) => import_koishi3.$.sum(row.count), "count") }).orderBy("count", "desc").execute();
|
|
374
409
|
if (rankStats.length === 0) return "暂无指定时段内发言记录";
|
|
375
410
|
const uids = rankStats.map((s) => s.uid);
|
|
376
411
|
const users = await this.ctx.database.get("analyse_user", { uid: { $in: uids } }, ["uid", "userName"]);
|
|
@@ -380,7 +415,12 @@ var Stat = class {
|
|
|
380
415
|
const listWithPercentage = list.map((row) => [...row, total > 0 ? `${(row[1] / total * 100).toFixed(2)}%` : "0.00%"]);
|
|
381
416
|
const title = await this.generateTitle({ guildId }, { main: "发言排行", timeRange: hours, subtype: type });
|
|
382
417
|
const result = await this.renderer.renderList({ title, time: /* @__PURE__ */ new Date(), total, list: listWithPercentage }, ["用户", "总计发言", "占比"]);
|
|
383
|
-
|
|
418
|
+
if (typeof result === "string") return result;
|
|
419
|
+
if (Array.isArray(result)) {
|
|
420
|
+
if (result.length === 0) return "图片渲染失败";
|
|
421
|
+
for (const buffer of result) await session.sendQueued(import_koishi3.h.image(buffer, "image/png"));
|
|
422
|
+
return;
|
|
423
|
+
}
|
|
384
424
|
} catch (error) {
|
|
385
425
|
this.ctx.logger.error("渲染发言排行图片失败:", error);
|
|
386
426
|
return "渲染发言排行图片失败";
|