koishi-plugin-prism 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +83 -0
- package/dist-types/src/index.d.ts +69 -0
- package/dist-types/src/index.js +1239 -0
- package/dist-types/test/plugin.test.d.ts +1 -0
- package/dist-types/test/plugin.test.js +302 -0
- package/package.json +50 -0
|
@@ -0,0 +1,1239 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.PrismBotClientError = exports.Config = exports.name = void 0;
|
|
4
|
+
exports.apply = apply;
|
|
5
|
+
exports.applyPrismKoishiPlugin = applyPrismKoishiPlugin;
|
|
6
|
+
exports.humanReadableBotError = humanReadableBotError;
|
|
7
|
+
exports.parseMahjongTables = parseMahjongTables;
|
|
8
|
+
const koishi_1 = require("koishi");
|
|
9
|
+
exports.name = "prism";
|
|
10
|
+
exports.Config = koishi_1.Schema.object({
|
|
11
|
+
provider: koishi_1.Schema.string().required().description("平台提供商 (如 qq)"),
|
|
12
|
+
autoRegister: koishi_1.Schema.boolean().default(true).description("是否自动注册"),
|
|
13
|
+
baseUrl: koishi_1.Schema.string().description("PRiSM 后端 API Base URL"),
|
|
14
|
+
integrationToken: koishi_1.Schema.string().role("secret").description("集成 API Token"),
|
|
15
|
+
staffSessionToken: koishi_1.Schema.string().role("secret").description("Staff 管理 Token (可选)"),
|
|
16
|
+
currencyName: koishi_1.Schema.string().default("猫粮").description("代币名称"),
|
|
17
|
+
defaultDoorDeviceId: koishi_1.Schema.string().default("front-door").description("默认开门设备ID"),
|
|
18
|
+
defaultScanProvider: koishi_1.Schema.string().default("aime").description("默认刷卡提供商"),
|
|
19
|
+
enableStaffCommands: koishi_1.Schema.boolean().default(false).description("是否启用管理员指令"),
|
|
20
|
+
powerOffInterval: koishi_1.Schema.number().default(0).description("无人自动关机等待秒数 (0为禁用)"),
|
|
21
|
+
mahjongTables: koishi_1.Schema.string().description("麻将桌配置"),
|
|
22
|
+
mahjongTableSize: koishi_1.Schema.number().default(4).description("麻将桌人数限制"),
|
|
23
|
+
mahjongLabelPrefix: koishi_1.Schema.string().default("麻将桌").description("麻将账单前缀"),
|
|
24
|
+
});
|
|
25
|
+
function apply(ctx, config) {
|
|
26
|
+
applyPrismKoishiPlugin(ctx, config);
|
|
27
|
+
}
|
|
28
|
+
class PrismBotClientError extends Error {
|
|
29
|
+
code;
|
|
30
|
+
status;
|
|
31
|
+
body;
|
|
32
|
+
constructor(message, code, status, body) {
|
|
33
|
+
super(message);
|
|
34
|
+
this.code = code;
|
|
35
|
+
this.status = status;
|
|
36
|
+
this.body = body;
|
|
37
|
+
this.name = "PrismBotClientError";
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
exports.PrismBotClientError = PrismBotClientError;
|
|
41
|
+
const LOCAL_TZ_OFFSET_MINUTES = 8 * 60;
|
|
42
|
+
const USAGE = {
|
|
43
|
+
mahjong_join: "/上桌 <桌号>",
|
|
44
|
+
mahjong_leave: "/下桌 <桌号>",
|
|
45
|
+
prism_on: "/prism on <设备ID>",
|
|
46
|
+
prism_off: "/prism off <设备ID|all>",
|
|
47
|
+
prism_coin: "/prism coin <设备ID> [数量]",
|
|
48
|
+
prism_scan: "/prism scan <设备ID> <卡号>",
|
|
49
|
+
prism_redeem: "/prism redeem <兑换码>",
|
|
50
|
+
list: "/list",
|
|
51
|
+
show: "/show [设备ID]",
|
|
52
|
+
staff_create_player: "/prism.admin.create-player <玩家昵称>",
|
|
53
|
+
staff_grant_balance: "/prism.admin.grant-balance <玩家ID> <金额>",
|
|
54
|
+
staff_redeem_code: "/prism.admin.redeem-code <兑换码> <礼物ID>",
|
|
55
|
+
staff_checkout: "/prism.admin.checkout <玩家ID>",
|
|
56
|
+
};
|
|
57
|
+
function applyPrismKoishiPlugin(ctx, config) {
|
|
58
|
+
const service = new PrismKoishiService(ctx, config);
|
|
59
|
+
const wrap = (handler) => async (context, ...args) => {
|
|
60
|
+
try {
|
|
61
|
+
return await handler(context, ...args);
|
|
62
|
+
}
|
|
63
|
+
catch (error) {
|
|
64
|
+
return service.handleCommandError(error);
|
|
65
|
+
}
|
|
66
|
+
};
|
|
67
|
+
ctx.command("register", "绑定或注册当前平台用户到 PRiSM").action(wrap(async (context) => service.register(service.sender(context))));
|
|
68
|
+
ctx.command("login", "开启当前玩家的计费场次").action(async (context) => service.login(service.sender(context)));
|
|
69
|
+
ctx.command("入场", "入场 (alias of login)").action(wrap(async (context) => service.login(service.sender(context))));
|
|
70
|
+
ctx.command("mahjong <tableId>", "加入指定麻将桌").action(wrap(async (context, tableId) => service.mahjongJoin(service.sender(context), tableId)));
|
|
71
|
+
ctx.command("上桌 <tableId>", "加入指定麻将桌").action(wrap(async (context, tableId) => service.mahjongJoin(service.sender(context), tableId)));
|
|
72
|
+
ctx.command("下桌 <tableId>", "离开指定麻将桌").action(wrap(async (context, tableId) => service.mahjongLeave(service.sender(context), tableId)));
|
|
73
|
+
ctx.command("logout", "结算当前玩家的计费场次").action(wrap(async (context) => service.logout(service.sender(context))));
|
|
74
|
+
ctx.command("billing", "预览当前玩家的结账费用").action(wrap(async (context) => service.billing(service.sender(context))));
|
|
75
|
+
ctx.command("wallet", "查看当前玩家钱包余额").action(wrap(async (context) => service.wallet(service.sender(context))));
|
|
76
|
+
ctx.command("items", "查看当前玩家持有资产").action(wrap(async (context) => service.items(service.sender(context))));
|
|
77
|
+
ctx.command("list", "查看当前在线玩家列表").action(wrap(async (context) => service.listActiveSessions(service.sender(context))));
|
|
78
|
+
ctx.command("show [deviceId]", "查看设备电源状态").action(wrap(async (context, deviceId) => service.listDeviceStates(deviceId)));
|
|
79
|
+
ctx.command("history", "查看当前玩家历史场次").action(wrap(async (context) => service.history(service.sender(context))));
|
|
80
|
+
ctx.command("lock", "向默认门锁设备发送开门指令").action(wrap(async (context) => service.lock(service.sender(context))));
|
|
81
|
+
ctx.command("on <deviceId>", "请求启动指定设备电源").action(wrap(async (context, deviceId) => service.powerOn(service.sender(context), deviceId)));
|
|
82
|
+
ctx.command("off <deviceId>", "请求关闭指定设备电源").action(wrap(async (context, deviceId) => service.powerOff(service.sender(context), deviceId)));
|
|
83
|
+
ctx.command("coin <deviceId> [count]", "请求向指定设备投币").action(wrap(async (context, deviceId, count) => service.coin(service.sender(context), deviceId, count)));
|
|
84
|
+
ctx.command("scan <deviceId> <subject>", "请求指定设备模拟刷卡").action(wrap(async (context, deviceId, subject) => service.scan(service.sender(context), deviceId, subject)));
|
|
85
|
+
ctx.command("redeem <code>", "兑换 PRiSM 礼物码").action(wrap(async (context, code) => service.redeem(service.sender(context), code)));
|
|
86
|
+
if (config.enableStaffCommands) {
|
|
87
|
+
ctx.command("admin.players", "列出 PRiSM 玩家").action(wrap(async (context) => service.staffPlayers(service.sender(context))));
|
|
88
|
+
ctx.command("admin.create-player <displayName>", "创建 PRiSM 玩家").action(wrap(async (context, displayName) => service.staffCreatePlayer(service.sender(context), displayName)));
|
|
89
|
+
ctx.command("admin.grant-balance <playerId> <amount>", "给指定玩家发放充值余额").action(wrap(async (context, playerId, amount) => service.staffGrantBalance(service.sender(context), playerId, amount)));
|
|
90
|
+
ctx.command("admin.redeem-code <code> <presentId>", "创建单次使用兑换码").action(wrap(async (context, code, presentId) => service.staffRedeemCode(service.sender(context), code, presentId)));
|
|
91
|
+
ctx.command("admin.checkout <playerId>", "替指定玩家结账").action(wrap(async (context, playerId) => service.staffCheckout(service.sender(context), playerId)));
|
|
92
|
+
}
|
|
93
|
+
const intervalMs = (config.powerOffInterval ?? 0) * 1000;
|
|
94
|
+
if (intervalMs > 0 && typeof ctx.setInterval === "function") {
|
|
95
|
+
ctx.setInterval(() => {
|
|
96
|
+
service.autoPowerOffLoop().catch(() => {
|
|
97
|
+
/* swallow */
|
|
98
|
+
});
|
|
99
|
+
}, intervalMs);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
/* ----------------------------- api client ---------------------------------- */
|
|
103
|
+
class PrismApiClient {
|
|
104
|
+
http;
|
|
105
|
+
config;
|
|
106
|
+
constructor(http, config) {
|
|
107
|
+
this.http = http;
|
|
108
|
+
this.config = config;
|
|
109
|
+
}
|
|
110
|
+
get headers() {
|
|
111
|
+
return {
|
|
112
|
+
"Content-Type": "application/json",
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
async request(method, path, options) {
|
|
116
|
+
let url = path;
|
|
117
|
+
if (options.params) {
|
|
118
|
+
for (const [key, value] of Object.entries(options.params)) {
|
|
119
|
+
url = url.replace(`:${key}`, encodeURIComponent(String(value)));
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
const fullUrl = `${this.config.baseUrl.replace(/\/+$/, "")}${url}`;
|
|
123
|
+
const headers = {
|
|
124
|
+
...this.headers,
|
|
125
|
+
};
|
|
126
|
+
if (options.token) {
|
|
127
|
+
headers["Authorization"] = `Bearer ${options.token}`;
|
|
128
|
+
}
|
|
129
|
+
try {
|
|
130
|
+
const config = { headers, params: options.query };
|
|
131
|
+
let response;
|
|
132
|
+
if (method === "GET") {
|
|
133
|
+
response = await this.http.get(fullUrl, config);
|
|
134
|
+
}
|
|
135
|
+
else if (method === "POST") {
|
|
136
|
+
response = await this.http.post(fullUrl, options.body ?? {}, config);
|
|
137
|
+
}
|
|
138
|
+
else if (method === "PUT") {
|
|
139
|
+
response = await this.http.put(fullUrl, options.body ?? {}, config);
|
|
140
|
+
}
|
|
141
|
+
else {
|
|
142
|
+
throw new Error(`Unsupported method ${method}`);
|
|
143
|
+
}
|
|
144
|
+
return response;
|
|
145
|
+
}
|
|
146
|
+
catch (error) {
|
|
147
|
+
if (error.response && error.response.data) {
|
|
148
|
+
const body = error.response.data;
|
|
149
|
+
const err = body.error || {};
|
|
150
|
+
throw new PrismBotClientError(err.message || error.message, err.code || "API_ERROR", error.response.status || 500, body);
|
|
151
|
+
}
|
|
152
|
+
throw new PrismBotClientError(error.message || "Network error", "NETWORK_ERROR", 500, {});
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
requireStaffSessionToken() {
|
|
156
|
+
if (!this.config.staffSessionToken) {
|
|
157
|
+
throw new PrismBotClientError("Staff session token is required for this Bot shortcut.", "STAFF_TOKEN_REQUIRED", 0, {});
|
|
158
|
+
}
|
|
159
|
+
return this.config.staffSessionToken;
|
|
160
|
+
}
|
|
161
|
+
identityBody(identity) {
|
|
162
|
+
return {
|
|
163
|
+
identity: {
|
|
164
|
+
provider: identity.provider,
|
|
165
|
+
subject: identity.subject,
|
|
166
|
+
},
|
|
167
|
+
...(identity.autoRegister === undefined ? {} : { autoRegister: identity.autoRegister }),
|
|
168
|
+
...(identity.displayName === undefined ? {} : { displayName: identity.displayName }),
|
|
169
|
+
};
|
|
170
|
+
}
|
|
171
|
+
async resolveOrRegisterIdentity(identity) {
|
|
172
|
+
const endpoint = identity.autoRegister
|
|
173
|
+
? "/rpc/integration/players/by-identity/register"
|
|
174
|
+
: "/rpc/integration/players/by-identity/resolve";
|
|
175
|
+
const result = await this.request("POST", endpoint, {
|
|
176
|
+
token: this.config.integrationToken,
|
|
177
|
+
body: this.identityBody(identity),
|
|
178
|
+
});
|
|
179
|
+
return result.player;
|
|
180
|
+
}
|
|
181
|
+
async startSessionByIdentity(identity, body) {
|
|
182
|
+
return this.request("POST", "/rpc/integration/players/by-identity/session/start", {
|
|
183
|
+
token: this.config.integrationToken,
|
|
184
|
+
body: {
|
|
185
|
+
...this.identityBody(identity),
|
|
186
|
+
...(body ?? {}),
|
|
187
|
+
},
|
|
188
|
+
});
|
|
189
|
+
}
|
|
190
|
+
async stopSessionByIdentity(identity, sessionId) {
|
|
191
|
+
return this.request("POST", "/rpc/integration/players/by-identity/sessions/:sessionId/stop", {
|
|
192
|
+
token: this.config.integrationToken,
|
|
193
|
+
params: { sessionId },
|
|
194
|
+
body: this.identityBody(identity),
|
|
195
|
+
});
|
|
196
|
+
}
|
|
197
|
+
async getWalletByIdentity(identity) {
|
|
198
|
+
return this.request("POST", "/rpc/integration/players/by-identity/wallet", {
|
|
199
|
+
token: this.config.integrationToken,
|
|
200
|
+
body: this.identityBody(identity),
|
|
201
|
+
});
|
|
202
|
+
}
|
|
203
|
+
async getAssetsByIdentity(identity) {
|
|
204
|
+
return this.request("POST", "/rpc/integration/players/by-identity/assets", {
|
|
205
|
+
token: this.config.integrationToken,
|
|
206
|
+
body: this.identityBody(identity),
|
|
207
|
+
});
|
|
208
|
+
}
|
|
209
|
+
async getSessionHistoryByIdentity(identity) {
|
|
210
|
+
return this.request("POST", "/rpc/integration/players/by-identity/history", {
|
|
211
|
+
token: this.config.integrationToken,
|
|
212
|
+
body: this.identityBody(identity),
|
|
213
|
+
});
|
|
214
|
+
}
|
|
215
|
+
async previewCheckoutByIdentity(identity) {
|
|
216
|
+
return this.request("POST", "/rpc/integration/players/by-identity/checkout/preview", {
|
|
217
|
+
token: this.config.integrationToken,
|
|
218
|
+
body: this.identityBody(identity),
|
|
219
|
+
});
|
|
220
|
+
}
|
|
221
|
+
async confirmCheckoutByIdentity(identity) {
|
|
222
|
+
return this.request("POST", "/rpc/integration/players/by-identity/checkout/confirm", {
|
|
223
|
+
token: this.config.integrationToken,
|
|
224
|
+
body: this.identityBody(identity),
|
|
225
|
+
});
|
|
226
|
+
}
|
|
227
|
+
async redeemCodeByIdentity(identity, code) {
|
|
228
|
+
return this.request("POST", "/rpc/integration/players/by-identity/redeem", {
|
|
229
|
+
token: this.config.integrationToken,
|
|
230
|
+
body: {
|
|
231
|
+
...this.identityBody(identity),
|
|
232
|
+
code,
|
|
233
|
+
},
|
|
234
|
+
});
|
|
235
|
+
}
|
|
236
|
+
async requestDeviceCommandByIdentity(identity, command) {
|
|
237
|
+
return this.request("POST", "/rpc/integration/players/by-identity/device-actions", {
|
|
238
|
+
token: this.config.integrationToken,
|
|
239
|
+
body: {
|
|
240
|
+
...this.identityBody(identity),
|
|
241
|
+
target: command.target,
|
|
242
|
+
action: {
|
|
243
|
+
type: command.type,
|
|
244
|
+
...(command.payload === undefined ? {} : { payload: command.payload }),
|
|
245
|
+
},
|
|
246
|
+
},
|
|
247
|
+
});
|
|
248
|
+
}
|
|
249
|
+
async requestScanByIdentity(identity, scan) {
|
|
250
|
+
return this.request("POST", "/rpc/integration/players/by-identity/device-actions", {
|
|
251
|
+
token: this.config.integrationToken,
|
|
252
|
+
body: {
|
|
253
|
+
...this.identityBody(identity),
|
|
254
|
+
target: {
|
|
255
|
+
kind: "game_machine",
|
|
256
|
+
id: scan.deviceId,
|
|
257
|
+
},
|
|
258
|
+
action: {
|
|
259
|
+
type: "aime.scan",
|
|
260
|
+
payload: {
|
|
261
|
+
provider: scan.provider,
|
|
262
|
+
subject: scan.subject,
|
|
263
|
+
},
|
|
264
|
+
},
|
|
265
|
+
},
|
|
266
|
+
});
|
|
267
|
+
}
|
|
268
|
+
async listActiveSessions() {
|
|
269
|
+
return this.request("GET", "/rpc/integration/sessions/active", {
|
|
270
|
+
token: this.config.integrationToken,
|
|
271
|
+
});
|
|
272
|
+
}
|
|
273
|
+
async listDeviceStates() {
|
|
274
|
+
return this.request("GET", "/rpc/integration/device-states", {
|
|
275
|
+
token: this.config.integrationToken,
|
|
276
|
+
});
|
|
277
|
+
}
|
|
278
|
+
// Staff commands
|
|
279
|
+
async listStaffPlayers() {
|
|
280
|
+
return this.request("GET", "/rpc/staff/players", {
|
|
281
|
+
token: this.requireStaffSessionToken(),
|
|
282
|
+
});
|
|
283
|
+
}
|
|
284
|
+
async createStaffPlayer(displayName) {
|
|
285
|
+
return this.request("POST", "/rpc/staff/players", {
|
|
286
|
+
token: this.requireStaffSessionToken(),
|
|
287
|
+
body: { displayName },
|
|
288
|
+
});
|
|
289
|
+
}
|
|
290
|
+
async grantStaffAssets(playerId, assets) {
|
|
291
|
+
return this.request("POST", "/rpc/staff/players/:playerId/adjustments/assets", {
|
|
292
|
+
token: this.requireStaffSessionToken(),
|
|
293
|
+
params: { playerId },
|
|
294
|
+
body: { assets },
|
|
295
|
+
});
|
|
296
|
+
}
|
|
297
|
+
async createStaffRedeemCode(input) {
|
|
298
|
+
return this.request("POST", "/rpc/staff/redeem-codes", {
|
|
299
|
+
token: this.requireStaffSessionToken(),
|
|
300
|
+
body: input,
|
|
301
|
+
});
|
|
302
|
+
}
|
|
303
|
+
async staffCheckout(playerId) {
|
|
304
|
+
return this.request("POST", "/rpc/staff/players/:playerId/settlements/checkout", {
|
|
305
|
+
token: this.requireStaffSessionToken(),
|
|
306
|
+
params: { playerId },
|
|
307
|
+
});
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
/* ------------------------------- service ----------------------------------- */
|
|
311
|
+
class PrismKoishiService {
|
|
312
|
+
config;
|
|
313
|
+
mahjongTables = new Map();
|
|
314
|
+
client;
|
|
315
|
+
constructor(ctx, config) {
|
|
316
|
+
this.config = config;
|
|
317
|
+
if (config.client) {
|
|
318
|
+
this.client = config.client;
|
|
319
|
+
}
|
|
320
|
+
else {
|
|
321
|
+
if (!config.baseUrl) {
|
|
322
|
+
throw new Error("PRiSM Koishi plugin requires either 'client' or 'baseUrl' in config.");
|
|
323
|
+
}
|
|
324
|
+
if (!config.integrationToken) {
|
|
325
|
+
throw new Error("PRiSM Koishi plugin requires 'integrationToken' in config when 'client' is not provided.");
|
|
326
|
+
}
|
|
327
|
+
const http = ctx.http ?? {
|
|
328
|
+
async get(url, c) {
|
|
329
|
+
const res = await fetch(url, { method: "GET", headers: c.headers });
|
|
330
|
+
if (!res.ok)
|
|
331
|
+
throw { response: { data: await res.json(), status: res.status } };
|
|
332
|
+
return res.json();
|
|
333
|
+
},
|
|
334
|
+
async post(url, body, c) {
|
|
335
|
+
const res = await fetch(url, { method: "POST", headers: c.headers, body: JSON.stringify(body) });
|
|
336
|
+
if (!res.ok)
|
|
337
|
+
throw { response: { data: await res.json(), status: res.status } };
|
|
338
|
+
return res.json();
|
|
339
|
+
},
|
|
340
|
+
async put(url, body, c) {
|
|
341
|
+
const res = await fetch(url, { method: "PUT", headers: c.headers, body: JSON.stringify(body) });
|
|
342
|
+
if (!res.ok)
|
|
343
|
+
throw { response: { data: await res.json(), status: res.status } };
|
|
344
|
+
return res.json();
|
|
345
|
+
}
|
|
346
|
+
};
|
|
347
|
+
this.client = new PrismApiClient(http, {
|
|
348
|
+
baseUrl: config.baseUrl,
|
|
349
|
+
integrationToken: config.integrationToken,
|
|
350
|
+
staffSessionToken: config.staffSessionToken,
|
|
351
|
+
});
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
sender(context) {
|
|
355
|
+
const id = context.session?.senderId || context.session?.userId || "";
|
|
356
|
+
const name = context.session?.senderName || id;
|
|
357
|
+
return { id, name };
|
|
358
|
+
}
|
|
359
|
+
async register(sender) {
|
|
360
|
+
await this.client.resolveOrRegisterIdentity(this.identity(sender));
|
|
361
|
+
return "注册成功";
|
|
362
|
+
}
|
|
363
|
+
async login(sender) {
|
|
364
|
+
await this.client.startSessionByIdentity(this.identity(sender), this.loginSessionBody());
|
|
365
|
+
return "✅ 入场成功";
|
|
366
|
+
}
|
|
367
|
+
async mahjongJoin(sender, rawTableId) {
|
|
368
|
+
const tableId = cleanText(rawTableId);
|
|
369
|
+
if (!tableId)
|
|
370
|
+
return commandUsage("mahjong_join");
|
|
371
|
+
const tableConfig = this.mahjongTableConfigs().get(tableId);
|
|
372
|
+
if (!tableConfig)
|
|
373
|
+
return `找不到 ${tableId} 桌的麻将计费配置。`;
|
|
374
|
+
const tableKey = tableConfig.tableId;
|
|
375
|
+
const tableSubject = tableConfig.displayName || `${tableKey} 桌`;
|
|
376
|
+
const player = await this.resolvePlayer(sender);
|
|
377
|
+
const playerId = String(player.id ?? "");
|
|
378
|
+
const existing = this.mahjongTableForPlayer(playerId);
|
|
379
|
+
if (existing)
|
|
380
|
+
return `你已经在 ${existing} 桌了。`;
|
|
381
|
+
const state = this.mahjongTables.get(tableKey) ?? { waiting: [], activeSessions: {} };
|
|
382
|
+
this.mahjongTables.set(tableKey, state);
|
|
383
|
+
if (Object.keys(state.activeSessions).length > 0) {
|
|
384
|
+
return `${tableSubject}已经开始计费,请先等当前这一桌结束。`;
|
|
385
|
+
}
|
|
386
|
+
state.waiting.push({
|
|
387
|
+
playerId,
|
|
388
|
+
senderId: sender.id,
|
|
389
|
+
displayName: sender.name || playerId,
|
|
390
|
+
identity: this.identity(sender),
|
|
391
|
+
});
|
|
392
|
+
const tableSize = this.config.mahjongTableSize ?? 4;
|
|
393
|
+
if (state.waiting.length < tableSize) {
|
|
394
|
+
return `已加入 ${tableSubject},当前 ${state.waiting.length}/${tableSize} 人。`;
|
|
395
|
+
}
|
|
396
|
+
const seats = state.waiting.slice(0, tableSize);
|
|
397
|
+
state.waiting = state.waiting.slice(tableSize);
|
|
398
|
+
const label = tableConfig.displayName || `${this.config.mahjongLabelPrefix ?? "麻将桌"} ${tableKey}`;
|
|
399
|
+
for (const seat of seats) {
|
|
400
|
+
const result = (await this.client.startSessionByIdentity(seat.identity, {
|
|
401
|
+
pricingConfigIds: tableConfig.pricingConfigIds,
|
|
402
|
+
label,
|
|
403
|
+
}));
|
|
404
|
+
const session = (result?.session ?? {});
|
|
405
|
+
const sessionId = String(session.id ?? "");
|
|
406
|
+
if (sessionId)
|
|
407
|
+
state.activeSessions[seat.playerId] = sessionId;
|
|
408
|
+
}
|
|
409
|
+
return `${tableSubject}已满,麻将计费已开始。`;
|
|
410
|
+
}
|
|
411
|
+
async mahjongLeave(sender, rawTableId) {
|
|
412
|
+
const tableId = cleanText(rawTableId);
|
|
413
|
+
if (!tableId)
|
|
414
|
+
return commandUsage("mahjong_leave");
|
|
415
|
+
const tableConfig = this.mahjongTableConfigs().get(tableId);
|
|
416
|
+
const tableKey = tableConfig?.tableId ?? tableId;
|
|
417
|
+
const tableSubject = tableConfig?.displayName || `${tableId} 桌`;
|
|
418
|
+
const state = this.mahjongTables.get(tableKey);
|
|
419
|
+
if (!state)
|
|
420
|
+
return `你不在 ${tableSubject}。`;
|
|
421
|
+
const player = await this.resolvePlayer(sender);
|
|
422
|
+
const playerId = String(player.id ?? "");
|
|
423
|
+
const waitingBefore = state.waiting.length;
|
|
424
|
+
state.waiting = state.waiting.filter((seat) => seat.playerId !== playerId);
|
|
425
|
+
if (state.waiting.length !== waitingBefore) {
|
|
426
|
+
return `已离开 ${tableSubject},当前 ${state.waiting.length}/${this.config.mahjongTableSize ?? 4} 人。`;
|
|
427
|
+
}
|
|
428
|
+
const sessionId = state.activeSessions[playerId];
|
|
429
|
+
if (!sessionId)
|
|
430
|
+
return `你不在 ${tableSubject}。`;
|
|
431
|
+
delete state.activeSessions[playerId];
|
|
432
|
+
await this.client.stopSessionByIdentity(this.identity(sender), sessionId);
|
|
433
|
+
return `已离开 ${tableSubject},麻将计费已停止。`;
|
|
434
|
+
}
|
|
435
|
+
async billing(sender) {
|
|
436
|
+
const result = (await this.client.previewCheckoutByIdentity(this.identity(sender)));
|
|
437
|
+
return this.formatCheckoutPreview(result, sender);
|
|
438
|
+
}
|
|
439
|
+
async logout(sender) {
|
|
440
|
+
const result = (await this.client.confirmCheckoutByIdentity(this.identity(sender)));
|
|
441
|
+
const settlement = result?.playerSettlement ?? result?.settlement ?? {};
|
|
442
|
+
const records = result?.settlements ?? [];
|
|
443
|
+
const sessionPreviews = records.map((rec) => {
|
|
444
|
+
const s = rec?.settlement ?? {};
|
|
445
|
+
return {
|
|
446
|
+
sessionId: s.sessionId,
|
|
447
|
+
label: s.label,
|
|
448
|
+
startedAt: s.startedAt,
|
|
449
|
+
endedAt: s.endedAt ?? s.settledAt,
|
|
450
|
+
status: "closed",
|
|
451
|
+
subtotal: s.subtotal ?? 0,
|
|
452
|
+
total: s.total ?? 0,
|
|
453
|
+
chargeItems: rec?.chargeItems ?? [],
|
|
454
|
+
adjustments: rec?.adjustments ?? [],
|
|
455
|
+
};
|
|
456
|
+
});
|
|
457
|
+
const synthetic = {
|
|
458
|
+
settlementPreview: {
|
|
459
|
+
playerId: settlement.playerId,
|
|
460
|
+
subtotal: settlement.subtotal ?? 0,
|
|
461
|
+
total: settlement.total ?? 0,
|
|
462
|
+
},
|
|
463
|
+
sessionPreviews,
|
|
464
|
+
chargeItems: result?.chargeItems ?? [],
|
|
465
|
+
adjustments: result?.adjustments ?? [],
|
|
466
|
+
assetHoldings: result?.assetHoldings ?? [],
|
|
467
|
+
};
|
|
468
|
+
return this.formatCheckoutPreview(synthetic, sender, "✅ 退场成功 · 结算账单");
|
|
469
|
+
}
|
|
470
|
+
async wallet(sender) {
|
|
471
|
+
const result = (await this.client.getWalletByIdentity(this.identity(sender)));
|
|
472
|
+
return formatWallet(result, this.config.currencyName);
|
|
473
|
+
}
|
|
474
|
+
async items(sender) {
|
|
475
|
+
const holdings = extractRows((await this.client.getAssetsByIdentity(this.identity(sender))));
|
|
476
|
+
if (holdings.length === 0)
|
|
477
|
+
return "您当前没有任何物品。";
|
|
478
|
+
return ["🎒 --- 您拥有的物品 ---", ...holdings.map(formatInventoryItem)].join("\n");
|
|
479
|
+
}
|
|
480
|
+
async history(sender) {
|
|
481
|
+
return formatHistory((await this.client.getSessionHistoryByIdentity(this.identity(sender))), this.config.currencyName);
|
|
482
|
+
}
|
|
483
|
+
async lock(sender) {
|
|
484
|
+
await this.client.requestDeviceCommandByIdentity(this.identity(sender), {
|
|
485
|
+
type: "door.open",
|
|
486
|
+
target: { kind: "facility", id: this.config.defaultDoorDeviceId },
|
|
487
|
+
});
|
|
488
|
+
return "🔑 门锁指令已发送";
|
|
489
|
+
}
|
|
490
|
+
async powerOn(sender, rawDeviceId) {
|
|
491
|
+
const deviceId = cleanText(rawDeviceId);
|
|
492
|
+
if (!deviceId)
|
|
493
|
+
return commandUsage("prism_on");
|
|
494
|
+
return this.power(sender, deviceId, "on");
|
|
495
|
+
}
|
|
496
|
+
async powerOff(sender, rawDeviceId) {
|
|
497
|
+
const deviceId = cleanText(rawDeviceId);
|
|
498
|
+
if (!deviceId)
|
|
499
|
+
return commandUsage("prism_off");
|
|
500
|
+
return this.power(sender, deviceId, "off");
|
|
501
|
+
}
|
|
502
|
+
async coin(sender, rawDeviceId, rawCount) {
|
|
503
|
+
const deviceId = cleanText(rawDeviceId);
|
|
504
|
+
if (!deviceId)
|
|
505
|
+
return commandUsage("prism_coin");
|
|
506
|
+
const { value, error } = parsePositiveInt(rawCount, "prism_coin", "数量", 1);
|
|
507
|
+
if (error)
|
|
508
|
+
return error;
|
|
509
|
+
await this.client.requestDeviceCommandByIdentity(this.identity(sender), {
|
|
510
|
+
type: "coin",
|
|
511
|
+
target: { kind: "game_machine", id: deviceId },
|
|
512
|
+
payload: { count: value },
|
|
513
|
+
});
|
|
514
|
+
return `🪙 已为 ${deviceId} 投入 ${value} 个币`;
|
|
515
|
+
}
|
|
516
|
+
async scan(sender, rawDeviceId, rawSubject) {
|
|
517
|
+
const deviceId = cleanText(rawDeviceId);
|
|
518
|
+
const subject = cleanText(rawSubject);
|
|
519
|
+
if (!deviceId || !subject)
|
|
520
|
+
return commandUsage("prism_scan");
|
|
521
|
+
await this.client.requestScanByIdentity(this.identity(sender), {
|
|
522
|
+
deviceId,
|
|
523
|
+
provider: this.config.defaultScanProvider || "aime",
|
|
524
|
+
subject,
|
|
525
|
+
});
|
|
526
|
+
return `💳 使用尾号为 ${subject.slice(-4)} 的卡刷卡成功`;
|
|
527
|
+
}
|
|
528
|
+
async redeem(sender, rawCode) {
|
|
529
|
+
const code = cleanText(rawCode);
|
|
530
|
+
if (!code)
|
|
531
|
+
return commandUsage("prism_redeem");
|
|
532
|
+
const result = (await this.client.redeemCodeByIdentity(this.identity(sender), code));
|
|
533
|
+
const holdings = extractRows(result);
|
|
534
|
+
if (holdings.length === 0)
|
|
535
|
+
return "兑换成功,但没有获得任何物品。";
|
|
536
|
+
return ["✅ 兑换成功!您获得了以下物品:", ...holdings.map(formatRedeemedItem)].join("\n");
|
|
537
|
+
}
|
|
538
|
+
async listActiveSessions(sender) {
|
|
539
|
+
const result = (await this.client.listActiveSessions());
|
|
540
|
+
const sessions = (result?.sessions ?? []);
|
|
541
|
+
if (sessions.length === 0)
|
|
542
|
+
return "🫥 窝里目前没有玩家呢";
|
|
543
|
+
const lines = [`👥 窝里目前共有 ${sessions.length} 人`];
|
|
544
|
+
for (const session of sessions) {
|
|
545
|
+
const identitySubject = findSubjectForSession(session, this.config.provider);
|
|
546
|
+
let display;
|
|
547
|
+
if (identitySubject) {
|
|
548
|
+
const platformName = await this.resolvePlatformName(identitySubject);
|
|
549
|
+
if (platformName) {
|
|
550
|
+
display = `${platformName} ( ${identitySubject} )`;
|
|
551
|
+
}
|
|
552
|
+
else {
|
|
553
|
+
display = `${session.playerDisplayName || identitySubject} ( ${identitySubject} )`;
|
|
554
|
+
}
|
|
555
|
+
}
|
|
556
|
+
else {
|
|
557
|
+
display = session.playerDisplayName || session.playerId || "未知玩家";
|
|
558
|
+
}
|
|
559
|
+
const timeStr = formatDateTime(session.startedAt);
|
|
560
|
+
lines.push(`玩家: ${display}\n入场时间: ${timeStr}`);
|
|
561
|
+
}
|
|
562
|
+
return lines.join("\n\n");
|
|
563
|
+
}
|
|
564
|
+
async listDeviceStates(rawAlias) {
|
|
565
|
+
const alias = cleanText(rawAlias);
|
|
566
|
+
const result = (await this.client.listDeviceStates());
|
|
567
|
+
const states = (result?.deviceStates ?? []);
|
|
568
|
+
if (states.length === 0)
|
|
569
|
+
return "没有找到任何设备状态。";
|
|
570
|
+
if (alias) {
|
|
571
|
+
const matched = states.find((d) => d.deviceId === alias || d.label === alias);
|
|
572
|
+
if (!matched)
|
|
573
|
+
return `找不到设备: ${alias}`;
|
|
574
|
+
const stateVal = matched.state?.state ?? "unknown";
|
|
575
|
+
return `${matched.label || matched.deviceId}: ${stateVal}`;
|
|
576
|
+
}
|
|
577
|
+
return states
|
|
578
|
+
.map((d) => `${d.label || d.deviceId}: ${d.state?.state ?? "unknown"}`)
|
|
579
|
+
.join("\n");
|
|
580
|
+
}
|
|
581
|
+
async staffPlayers(sender) {
|
|
582
|
+
const denied = this.staffDenied(sender);
|
|
583
|
+
if (denied)
|
|
584
|
+
return denied;
|
|
585
|
+
const result = (await this.client.listStaffPlayers());
|
|
586
|
+
const players = (result?.players ?? []);
|
|
587
|
+
if (players.length === 0)
|
|
588
|
+
return "🫥 窝里目前没有玩家呢";
|
|
589
|
+
return [
|
|
590
|
+
`👥 窝里目前共有 ${players.length} 人`,
|
|
591
|
+
"",
|
|
592
|
+
...players.map((p) => {
|
|
593
|
+
const lines = [`玩家: ${p?.displayName ?? p?.id ?? ""} (${p?.id ?? ""})`];
|
|
594
|
+
if (p?.status)
|
|
595
|
+
lines.push(`状态: ${p.status}`);
|
|
596
|
+
if (p?.walletTotal != null)
|
|
597
|
+
lines.push(`余额: ${p.walletTotal} ${this.config.currencyName}`);
|
|
598
|
+
return lines.join("\n");
|
|
599
|
+
}),
|
|
600
|
+
].join("\n");
|
|
601
|
+
}
|
|
602
|
+
async staffCreatePlayer(sender, rawDisplayName) {
|
|
603
|
+
const displayName = cleanText(rawDisplayName);
|
|
604
|
+
if (!displayName)
|
|
605
|
+
return commandUsage("staff_create_player");
|
|
606
|
+
const denied = this.staffDenied(sender);
|
|
607
|
+
if (denied)
|
|
608
|
+
return denied;
|
|
609
|
+
const result = (await this.client.createStaffPlayer?.(displayName));
|
|
610
|
+
const player = result?.player ?? {};
|
|
611
|
+
return `创建成功\n玩家: ${player?.displayName ?? displayName}\nID: ${player?.id ?? ""}`;
|
|
612
|
+
}
|
|
613
|
+
async staffGrantBalance(sender, rawPlayerId, rawAmount) {
|
|
614
|
+
const playerId = cleanText(rawPlayerId);
|
|
615
|
+
const amount = cleanText(rawAmount);
|
|
616
|
+
if (!playerId || !amount)
|
|
617
|
+
return commandUsage("staff_grant_balance");
|
|
618
|
+
const denied = this.staffDenied(sender);
|
|
619
|
+
if (denied)
|
|
620
|
+
return denied;
|
|
621
|
+
await this.client.grantStaffAssets?.(playerId, [
|
|
622
|
+
{
|
|
623
|
+
assetType: "currency",
|
|
624
|
+
assetCode: "paid",
|
|
625
|
+
amount: Number(amount),
|
|
626
|
+
mergeStrategy: "stack",
|
|
627
|
+
activeAt: null,
|
|
628
|
+
expiresAt: null,
|
|
629
|
+
},
|
|
630
|
+
]);
|
|
631
|
+
return `✅ 已为玩家 ${playerId} 发放 ${amount} ${this.config.currencyName}`;
|
|
632
|
+
}
|
|
633
|
+
async staffRedeemCode(sender, rawCode, rawPresentId) {
|
|
634
|
+
const code = cleanText(rawCode);
|
|
635
|
+
const presentId = cleanText(rawPresentId);
|
|
636
|
+
if (!code || !presentId)
|
|
637
|
+
return commandUsage("staff_redeem_code");
|
|
638
|
+
const denied = this.staffDenied(sender);
|
|
639
|
+
if (denied)
|
|
640
|
+
return denied;
|
|
641
|
+
const result = (await this.client.createStaffRedeemCode?.({
|
|
642
|
+
code,
|
|
643
|
+
presentId,
|
|
644
|
+
activeAt: null,
|
|
645
|
+
expiresAt: null,
|
|
646
|
+
maxUseCount: 1,
|
|
647
|
+
}));
|
|
648
|
+
const redeemCode = result?.redeemCode?.code ?? code;
|
|
649
|
+
return `成功生成 1 个兑换码:\n${redeemCode}`;
|
|
650
|
+
}
|
|
651
|
+
async staffCheckout(sender, rawPlayerId) {
|
|
652
|
+
const playerId = cleanText(rawPlayerId);
|
|
653
|
+
if (!playerId)
|
|
654
|
+
return commandUsage("staff_checkout");
|
|
655
|
+
const denied = this.staffDenied(sender);
|
|
656
|
+
if (denied)
|
|
657
|
+
return denied;
|
|
658
|
+
const result = (await this.client.staffCheckout?.(playerId));
|
|
659
|
+
const settlement = result?.settlement ?? {};
|
|
660
|
+
return `\n✅ 已为用户 ${playerId} 退场\n消费: ${formatNumber(settlement?.total ?? 0)} ${this.config.currencyName}`;
|
|
661
|
+
}
|
|
662
|
+
async autoPowerOffLoop() {
|
|
663
|
+
const interval = this.config.powerOffInterval ?? 0;
|
|
664
|
+
if (interval <= 0)
|
|
665
|
+
return;
|
|
666
|
+
const result = (await this.client.listActiveSessions());
|
|
667
|
+
const sessions = (result?.sessions ?? []);
|
|
668
|
+
if (sessions.length > 0)
|
|
669
|
+
return;
|
|
670
|
+
const statesResult = (await this.client.listDeviceStates());
|
|
671
|
+
const states = (statesResult?.deviceStates ?? []);
|
|
672
|
+
const anyOn = states.some((d) => d.state?.state !== "off");
|
|
673
|
+
if (!anyOn)
|
|
674
|
+
return;
|
|
675
|
+
const dummySender = { id: "system", name: "system" };
|
|
676
|
+
await this.powerOff(dummySender, "all");
|
|
677
|
+
}
|
|
678
|
+
/* ---------------------------- helpers ---------------------------------- */
|
|
679
|
+
async power(sender, deviceId, state) {
|
|
680
|
+
await this.client.requestDeviceCommandByIdentity(this.identity(sender), {
|
|
681
|
+
type: state === "on" ? "power.on" : "power.off",
|
|
682
|
+
target: { kind: "facility", id: deviceId },
|
|
683
|
+
payload: { state },
|
|
684
|
+
});
|
|
685
|
+
if (state === "on")
|
|
686
|
+
return `✅ ${deviceId} 启动成功`;
|
|
687
|
+
if (deviceId === "all")
|
|
688
|
+
return `🛑 全部机器关闭成功`;
|
|
689
|
+
return `🛑 ${deviceId} 关闭成功`;
|
|
690
|
+
}
|
|
691
|
+
async resolvePlatformName(subject) {
|
|
692
|
+
if (!this.config.resolveDisplayName)
|
|
693
|
+
return null;
|
|
694
|
+
try {
|
|
695
|
+
return (await this.config.resolveDisplayName(subject)) ?? null;
|
|
696
|
+
}
|
|
697
|
+
catch {
|
|
698
|
+
return null;
|
|
699
|
+
}
|
|
700
|
+
}
|
|
701
|
+
mahjongTableForPlayer(playerId) {
|
|
702
|
+
for (const [tableId, state] of this.mahjongTables) {
|
|
703
|
+
if (state.activeSessions[playerId])
|
|
704
|
+
return tableId;
|
|
705
|
+
if (state.waiting.some((seat) => seat.playerId === playerId))
|
|
706
|
+
return tableId;
|
|
707
|
+
}
|
|
708
|
+
return null;
|
|
709
|
+
}
|
|
710
|
+
mahjongTableConfigs() {
|
|
711
|
+
return parseMahjongTables(this.config.mahjongTables ?? "", this.config.mahjongLabelPrefix ?? "麻将桌");
|
|
712
|
+
}
|
|
713
|
+
async resolvePlayer(sender) {
|
|
714
|
+
return (await this.client.resolveOrRegisterIdentity(this.identity(sender)));
|
|
715
|
+
}
|
|
716
|
+
identity(sender) {
|
|
717
|
+
return {
|
|
718
|
+
provider: this.config.provider,
|
|
719
|
+
subject: sender.id,
|
|
720
|
+
autoRegister: this.config.autoRegister,
|
|
721
|
+
displayName: sender.name || `${this.config.provider.toUpperCase()} ${sender.id}`,
|
|
722
|
+
};
|
|
723
|
+
}
|
|
724
|
+
loginSessionBody() {
|
|
725
|
+
const pricingConfigIds = (this.config.loginPricingConfigIds ?? [])
|
|
726
|
+
.map((id) => id.trim())
|
|
727
|
+
.filter(Boolean);
|
|
728
|
+
const label = this.config.loginSessionLabel?.trim();
|
|
729
|
+
if (pricingConfigIds.length === 0 && !label)
|
|
730
|
+
return undefined;
|
|
731
|
+
return {
|
|
732
|
+
...(pricingConfigIds.length === 0 ? {} : { pricingConfigIds }),
|
|
733
|
+
...(label ? { label } : {}),
|
|
734
|
+
};
|
|
735
|
+
}
|
|
736
|
+
staffDenied(sender) {
|
|
737
|
+
if (!this.config.enableStaffCommands)
|
|
738
|
+
return "员工命令未启用";
|
|
739
|
+
const allowed = this.config.staffUserIds ?? [];
|
|
740
|
+
if (allowed.length > 0 && !allowed.includes(sender.id))
|
|
741
|
+
return "权限不足";
|
|
742
|
+
return null;
|
|
743
|
+
}
|
|
744
|
+
formatCheckoutPreview(result, sender, title = "【结算账单】") {
|
|
745
|
+
if (result?.billing && result?.session) {
|
|
746
|
+
return formatLegacyBilling(result, this.config.currencyName);
|
|
747
|
+
}
|
|
748
|
+
const currency = this.config.currencyName;
|
|
749
|
+
const preview = result?.settlementPreview ?? result?.settlement ?? {};
|
|
750
|
+
const playerId = preview?.playerId ?? "";
|
|
751
|
+
const subtotal = firstDefined(preview, "subtotal", "originalCost", 0);
|
|
752
|
+
const total = firstDefined(preview, "total", "finalCost", "amount", subtotal);
|
|
753
|
+
const previewedAt = parseDateTime(preview?.previewedAt);
|
|
754
|
+
let sessionPreviews = (result?.sessionPreviews ?? []);
|
|
755
|
+
if (sessionPreviews.length === 0 && (result?.chargeItems || result?.session)) {
|
|
756
|
+
const session = result?.session ?? {};
|
|
757
|
+
sessionPreviews = [
|
|
758
|
+
{
|
|
759
|
+
sessionId: session?.id ?? session?.sessionId,
|
|
760
|
+
label: session?.label ?? "计时区间",
|
|
761
|
+
startedAt: firstDefined(session, "startedAt", "createdAt"),
|
|
762
|
+
endedAt: firstDefined(preview, "endedAt", "settledAt", "endTime"),
|
|
763
|
+
status: session?.status ?? "active",
|
|
764
|
+
subtotal,
|
|
765
|
+
total,
|
|
766
|
+
chargeItems: result?.chargeItems ?? [],
|
|
767
|
+
adjustments: [],
|
|
768
|
+
},
|
|
769
|
+
];
|
|
770
|
+
}
|
|
771
|
+
const adjustments = result?.adjustments ?? [];
|
|
772
|
+
const assetHoldings = result?.assetHoldings ?? [];
|
|
773
|
+
const lines = [];
|
|
774
|
+
const headerParts = [title];
|
|
775
|
+
if (playerId) {
|
|
776
|
+
const identitySuffix = sender ? `(${this.config.provider.toUpperCase()}:${sender.id})` : "";
|
|
777
|
+
headerParts.push(`玩家ID:${playerId}${identitySuffix}`);
|
|
778
|
+
}
|
|
779
|
+
else if (sender) {
|
|
780
|
+
headerParts.push(`玩家:${sender.name || sender.id}(${this.config.provider.toUpperCase()}:${sender.id})`);
|
|
781
|
+
}
|
|
782
|
+
lines.push(headerParts.join("\n"));
|
|
783
|
+
const validStarts = sessionPreviews.map((s) => parseDateTime(s?.startedAt)).filter(Boolean);
|
|
784
|
+
const validEnds = sessionPreviews.map((s) => sessionDisplayEnd(s, previewedAt)).filter(Boolean);
|
|
785
|
+
if (validStarts.length > 0) {
|
|
786
|
+
const overallStart = minDate(validStarts);
|
|
787
|
+
const overallEnd = validEnds.length > 0 ? maxDate(validEnds) : now(this.config);
|
|
788
|
+
lines.push(`⏰全场到店时段:${formatHM(overallStart)}–${formatHM(overallEnd)}`);
|
|
789
|
+
}
|
|
790
|
+
lines.push("");
|
|
791
|
+
for (const sPrev of sessionPreviews) {
|
|
792
|
+
const label = sPrev?.label || "计时区间";
|
|
793
|
+
const startDt = parseDateTime(sPrev?.startedAt);
|
|
794
|
+
const endDt = sessionDisplayEnd(sPrev, previewedAt);
|
|
795
|
+
const status = sPrev?.status ?? "active";
|
|
796
|
+
const sTotal = toNumber(sPrev?.total ?? 0);
|
|
797
|
+
lines.push(label);
|
|
798
|
+
if (startDt && endDt) {
|
|
799
|
+
lines.push(`游玩时段:${formatHM(startDt)}-${formatHM(endDt)}`);
|
|
800
|
+
lines.push(`游玩时长:${formatDurationValue(Math.floor((endDt.getTime() - startDt.getTime()) / 60_000))} | 消费:${formatNumber(sTotal)}${currency}`);
|
|
801
|
+
}
|
|
802
|
+
else if (startDt) {
|
|
803
|
+
lines.push(`入场:${formatHM(startDt)} (${status === "active" ? "计费中" : "已关闭"})`);
|
|
804
|
+
}
|
|
805
|
+
lines.push("");
|
|
806
|
+
}
|
|
807
|
+
lines.push("————————————");
|
|
808
|
+
const balanceParts = [];
|
|
809
|
+
for (const holding of assetHoldings) {
|
|
810
|
+
const qty = toNumber(holding?.quantity ?? 0);
|
|
811
|
+
const code = String(holding?.assetCode ?? "").toLowerCase();
|
|
812
|
+
if (code.includes("paid") || code.includes("free") || code.includes("currency")) {
|
|
813
|
+
balanceParts.push(`${formatNumber(qty)}${currency}`);
|
|
814
|
+
}
|
|
815
|
+
}
|
|
816
|
+
if (balanceParts.length > 0)
|
|
817
|
+
lines.push(`扣款后余额:${balanceParts.join("+")}`);
|
|
818
|
+
lines.push(`计费总价:${formatNumber(subtotal)}${currency}`);
|
|
819
|
+
const hasDiscount = adjustments.some((adj) => {
|
|
820
|
+
const amount = toNumber(firstDefined(adj ?? {}, "amount", "saved", 0));
|
|
821
|
+
return amount !== 0;
|
|
822
|
+
});
|
|
823
|
+
if (hasDiscount)
|
|
824
|
+
lines.push(`优惠后价格:${formatNumber(total)}${currency}`);
|
|
825
|
+
return lines.join("\n");
|
|
826
|
+
}
|
|
827
|
+
handleCommandError(error) {
|
|
828
|
+
if (error instanceof PrismBotClientError) {
|
|
829
|
+
return humanReadableBotError(error);
|
|
830
|
+
}
|
|
831
|
+
if (error instanceof Error) {
|
|
832
|
+
return `操作失败: ${error.message}`;
|
|
833
|
+
}
|
|
834
|
+
return "操作失败";
|
|
835
|
+
}
|
|
836
|
+
}
|
|
837
|
+
function humanReadableBotError(error) {
|
|
838
|
+
if (error.code === "INSUFFICIENT_BALANCE") {
|
|
839
|
+
return "余额不足,暂时不能结账。请先充值,或由店员在后台改价后再结账。";
|
|
840
|
+
}
|
|
841
|
+
if (error.code === "API_UNREACHABLE" || error.code === "HTTP_0") {
|
|
842
|
+
return "连接不到 PRiSM 后端,请确认后端服务正在运行。";
|
|
843
|
+
}
|
|
844
|
+
if (error.code === "API_TIMEOUT") {
|
|
845
|
+
return "PRiSM 后端响应超时,请稍后再试。";
|
|
846
|
+
}
|
|
847
|
+
if (error.code === "INVALID_JSON_RESPONSE") {
|
|
848
|
+
return "PRiSM 后端返回了非 JSON 响应,请检查后端是否正常运行。";
|
|
849
|
+
}
|
|
850
|
+
if (error.code === "STAFF_TOKEN_REQUIRED") {
|
|
851
|
+
return "缺少管理面板令牌。";
|
|
852
|
+
}
|
|
853
|
+
return String(error?.message ?? error);
|
|
854
|
+
}
|
|
855
|
+
function parseMahjongTables(value, labelPrefix) {
|
|
856
|
+
const tables = new Map();
|
|
857
|
+
for (const item of value.replace(/\n/g, ";").split(";")) {
|
|
858
|
+
const text = item.trim();
|
|
859
|
+
if (!text)
|
|
860
|
+
continue;
|
|
861
|
+
let aliasPart;
|
|
862
|
+
let rest;
|
|
863
|
+
let displayName = "";
|
|
864
|
+
if (text.includes(":")) {
|
|
865
|
+
[aliasPart, rest] = splitOnce(text, ":");
|
|
866
|
+
if (!rest.includes("="))
|
|
867
|
+
continue;
|
|
868
|
+
[displayName, rest] = splitOnce(rest, "=");
|
|
869
|
+
displayName = displayName.trim();
|
|
870
|
+
}
|
|
871
|
+
else {
|
|
872
|
+
if (!text.includes("="))
|
|
873
|
+
continue;
|
|
874
|
+
[aliasPart, rest] = splitOnce(text, "=");
|
|
875
|
+
}
|
|
876
|
+
const aliases = aliasPart.split(",").map((a) => a.trim()).filter(Boolean);
|
|
877
|
+
const pricingConfigIds = rest.split("+").map((p) => p.trim()).filter(Boolean);
|
|
878
|
+
if (aliases.length === 0 || pricingConfigIds.length === 0)
|
|
879
|
+
continue;
|
|
880
|
+
const tableId = aliases[0];
|
|
881
|
+
const config = {
|
|
882
|
+
tableId,
|
|
883
|
+
displayName,
|
|
884
|
+
aliases,
|
|
885
|
+
pricingConfigIds,
|
|
886
|
+
};
|
|
887
|
+
for (const alias of aliases)
|
|
888
|
+
tables.set(alias, config);
|
|
889
|
+
}
|
|
890
|
+
return tables;
|
|
891
|
+
}
|
|
892
|
+
function splitOnce(text, sep) {
|
|
893
|
+
const idx = text.indexOf(sep);
|
|
894
|
+
if (idx === -1)
|
|
895
|
+
return [text, ""];
|
|
896
|
+
return [text.slice(0, idx), text.slice(idx + sep.length)];
|
|
897
|
+
}
|
|
898
|
+
function commandUsage(command) {
|
|
899
|
+
return `用法: ${USAGE[command] ?? command}`;
|
|
900
|
+
}
|
|
901
|
+
function parsePositiveInt(value, command, label, fallback) {
|
|
902
|
+
const text = cleanText(value);
|
|
903
|
+
if (!text) {
|
|
904
|
+
if (fallback !== undefined)
|
|
905
|
+
return { value: fallback, error: null };
|
|
906
|
+
return { value: 0, error: commandUsage(command) };
|
|
907
|
+
}
|
|
908
|
+
const parsed = Number.parseInt(text, 10);
|
|
909
|
+
if (Number.isNaN(parsed) || parsed <= 0) {
|
|
910
|
+
return { value: 0, error: `${label}必须是正整数\n${commandUsage(command)}` };
|
|
911
|
+
}
|
|
912
|
+
return { value: parsed, error: null };
|
|
913
|
+
}
|
|
914
|
+
function cleanText(value) {
|
|
915
|
+
return value == null ? "" : String(value).trim();
|
|
916
|
+
}
|
|
917
|
+
function toNumber(value) {
|
|
918
|
+
if (value == null)
|
|
919
|
+
return 0;
|
|
920
|
+
if (typeof value === "boolean")
|
|
921
|
+
return value ? 1 : 0;
|
|
922
|
+
if (typeof value === "number")
|
|
923
|
+
return value;
|
|
924
|
+
const text = String(value).trim();
|
|
925
|
+
if (!text)
|
|
926
|
+
return 0;
|
|
927
|
+
const asInt = Number.parseInt(text, 10);
|
|
928
|
+
if (!Number.isNaN(asInt))
|
|
929
|
+
return asInt;
|
|
930
|
+
const asFloat = Number.parseFloat(text);
|
|
931
|
+
if (!Number.isNaN(asFloat))
|
|
932
|
+
return asFloat;
|
|
933
|
+
return 0;
|
|
934
|
+
}
|
|
935
|
+
function formatNumber(value) {
|
|
936
|
+
if (typeof value === "boolean")
|
|
937
|
+
return value ? "1" : "0";
|
|
938
|
+
if (typeof value === "number") {
|
|
939
|
+
return Number.isInteger(value) ? String(value) : String(value);
|
|
940
|
+
}
|
|
941
|
+
const num = toNumber(value);
|
|
942
|
+
if (num || String(value ?? "").trim() === "0" || String(value ?? "").trim() === "0.0") {
|
|
943
|
+
return Number.isInteger(num) ? String(num) : String(num);
|
|
944
|
+
}
|
|
945
|
+
return String(value);
|
|
946
|
+
}
|
|
947
|
+
function parseDateTime(value) {
|
|
948
|
+
if (!value)
|
|
949
|
+
return null;
|
|
950
|
+
if (value instanceof Date) {
|
|
951
|
+
return ensureLocal(value);
|
|
952
|
+
}
|
|
953
|
+
const text = String(value).trim();
|
|
954
|
+
if (!text)
|
|
955
|
+
return null;
|
|
956
|
+
let normalized = text;
|
|
957
|
+
if (normalized.endsWith("Z"))
|
|
958
|
+
normalized = `${normalized.slice(0, -1)}+00:00`;
|
|
959
|
+
const dt = new Date(normalized);
|
|
960
|
+
if (!Number.isNaN(dt.getTime()))
|
|
961
|
+
return ensureLocal(dt);
|
|
962
|
+
return null;
|
|
963
|
+
}
|
|
964
|
+
function ensureLocal(dt) {
|
|
965
|
+
const offsetMs = LOCAL_TZ_OFFSET_MINUTES * 60_000;
|
|
966
|
+
const local = new Date(dt.getTime() + offsetMs);
|
|
967
|
+
void local;
|
|
968
|
+
return dt;
|
|
969
|
+
}
|
|
970
|
+
function formatHM(dt) {
|
|
971
|
+
const pad = (n) => String(n).padStart(2, "0");
|
|
972
|
+
return `${pad(dt.getHours())}:${pad(dt.getMinutes())}`;
|
|
973
|
+
}
|
|
974
|
+
function formatDateTime(value) {
|
|
975
|
+
const dt = parseDateTime(value);
|
|
976
|
+
if (!dt)
|
|
977
|
+
return "永不过期";
|
|
978
|
+
const pad = (n) => String(n).padStart(2, "0");
|
|
979
|
+
return `${dt.getFullYear()}/${pad(dt.getMonth() + 1)}/${pad(dt.getDate())} ${pad(dt.getHours())}:${pad(dt.getMinutes())}:${pad(dt.getSeconds())}`;
|
|
980
|
+
}
|
|
981
|
+
function formatTimeRange(start, end) {
|
|
982
|
+
const startDt = parseDateTime(start);
|
|
983
|
+
const endDt = parseDateTime(end);
|
|
984
|
+
if (!startDt || !endDt)
|
|
985
|
+
return `${formatDateTime(start)} - ${formatDateTime(end)}`;
|
|
986
|
+
const pad = (n) => String(n).padStart(2, "0");
|
|
987
|
+
const startTime = `${pad(startDt.getHours())}:${pad(startDt.getMinutes())}:${pad(startDt.getSeconds())}`;
|
|
988
|
+
const endTime = `${pad(endDt.getHours())}:${pad(endDt.getMinutes())}:${pad(endDt.getSeconds())}`;
|
|
989
|
+
if (startDt.toDateString() === endDt.toDateString())
|
|
990
|
+
return `${startTime} - ${endTime}`;
|
|
991
|
+
return `${startDt.getMonth() + 1}/${startDt.getDate()} ${startTime} - ${endDt.getMonth() + 1}/${endDt.getDate()} ${endTime}`;
|
|
992
|
+
}
|
|
993
|
+
function formatDurationMinutes(start, end) {
|
|
994
|
+
const startDt = parseDateTime(start);
|
|
995
|
+
const endDt = parseDateTime(end);
|
|
996
|
+
if (!startDt || !endDt)
|
|
997
|
+
return "0分钟";
|
|
998
|
+
return formatDurationValue(Math.floor((endDt.getTime() - startDt.getTime()) / 60_000));
|
|
999
|
+
}
|
|
1000
|
+
function formatDurationValue(minutes) {
|
|
1001
|
+
const total = Math.floor(toNumber(minutes));
|
|
1002
|
+
if (total >= 60) {
|
|
1003
|
+
const hours = Math.floor(total / 60);
|
|
1004
|
+
const mins = total % 60;
|
|
1005
|
+
return `${hours}小时${mins}分钟`;
|
|
1006
|
+
}
|
|
1007
|
+
return `${total}分钟`;
|
|
1008
|
+
}
|
|
1009
|
+
function sessionDisplayEnd(session, previewedAt) {
|
|
1010
|
+
const endedAt = parseDateTime(session?.endedAt);
|
|
1011
|
+
if (endedAt)
|
|
1012
|
+
return endedAt;
|
|
1013
|
+
if (session?.status === "active")
|
|
1014
|
+
return previewedAt ?? now(undefined);
|
|
1015
|
+
return null;
|
|
1016
|
+
}
|
|
1017
|
+
function now(config) {
|
|
1018
|
+
return config?.now ? config.now() : new Date();
|
|
1019
|
+
}
|
|
1020
|
+
function minDate(dates) {
|
|
1021
|
+
return dates.reduce((acc, d) => (d.getTime() < acc.getTime() ? d : acc), dates[0]);
|
|
1022
|
+
}
|
|
1023
|
+
function maxDate(dates) {
|
|
1024
|
+
return dates.reduce((acc, d) => (d.getTime() > acc.getTime() ? d : acc), dates[0]);
|
|
1025
|
+
}
|
|
1026
|
+
function extractRows(value) {
|
|
1027
|
+
if (Array.isArray(value))
|
|
1028
|
+
return value.filter((row) => row && typeof row === "object");
|
|
1029
|
+
if (!value || typeof value !== "object")
|
|
1030
|
+
return [];
|
|
1031
|
+
for (const key of ["holdings", "assets", "items", "wallet", "sessions"]) {
|
|
1032
|
+
const rows = value[key];
|
|
1033
|
+
if (Array.isArray(rows))
|
|
1034
|
+
return rows.filter((row) => row && typeof row === "object");
|
|
1035
|
+
}
|
|
1036
|
+
return [];
|
|
1037
|
+
}
|
|
1038
|
+
function rowQuantity(row) {
|
|
1039
|
+
return toNumber(firstDefined(row, "quantity", "amount", "count", 0));
|
|
1040
|
+
}
|
|
1041
|
+
function holdingName(row) {
|
|
1042
|
+
return (row.name ||
|
|
1043
|
+
row.assetName ||
|
|
1044
|
+
assetName(row) ||
|
|
1045
|
+
row.assetCode ||
|
|
1046
|
+
row.type ||
|
|
1047
|
+
"资产");
|
|
1048
|
+
}
|
|
1049
|
+
function assetName(row) {
|
|
1050
|
+
const asset = row?.asset;
|
|
1051
|
+
if (asset && typeof asset === "object") {
|
|
1052
|
+
return asset.name || asset.code || "";
|
|
1053
|
+
}
|
|
1054
|
+
return row.assetName || row.name || row.assetCode || "资产";
|
|
1055
|
+
}
|
|
1056
|
+
function isPaidBalance(row) {
|
|
1057
|
+
const value = `${row?.assetCode ?? ""} ${row?.assetName ?? ""} ${row?.type ?? ""}`.toLowerCase();
|
|
1058
|
+
return value.includes("paid") || value.includes("充值");
|
|
1059
|
+
}
|
|
1060
|
+
function isFreeBalance(row) {
|
|
1061
|
+
const value = `${row?.assetCode ?? ""} ${row?.assetName ?? ""} ${row?.type ?? ""}`.toLowerCase();
|
|
1062
|
+
return value.includes("free") || value.includes("免费") || value.includes("赠送");
|
|
1063
|
+
}
|
|
1064
|
+
function firstDefined(mapping, ...keys) {
|
|
1065
|
+
const last = keys[keys.length - 1];
|
|
1066
|
+
let keyList = keys;
|
|
1067
|
+
let fallback = undefined;
|
|
1068
|
+
if (typeof last !== "string") {
|
|
1069
|
+
fallback = last;
|
|
1070
|
+
keyList = keys.slice(0, -1);
|
|
1071
|
+
}
|
|
1072
|
+
for (const key of keyList) {
|
|
1073
|
+
if (mapping && typeof mapping === "object" && key in mapping && mapping[key] != null)
|
|
1074
|
+
return mapping[key];
|
|
1075
|
+
}
|
|
1076
|
+
return fallback;
|
|
1077
|
+
}
|
|
1078
|
+
function formatInventoryItem(row) {
|
|
1079
|
+
let line = `- ${holdingName(row)} (x${formatNumber(rowQuantity(row))})`;
|
|
1080
|
+
const expiresAt = row?.expireAt ?? row?.expiresAt;
|
|
1081
|
+
if (expiresAt)
|
|
1082
|
+
line += `\n 到期: ${formatDateTime(expiresAt)}`;
|
|
1083
|
+
return line;
|
|
1084
|
+
}
|
|
1085
|
+
function formatRedeemedItem(row) {
|
|
1086
|
+
let name = holdingName(row);
|
|
1087
|
+
const assetType = row?.assetType ?? row?.asset?.type;
|
|
1088
|
+
const durationMs = row?.durationMs;
|
|
1089
|
+
if (assetType === "PASS" && durationMs) {
|
|
1090
|
+
const days = Math.floor(toNumber(durationMs) / (1000 * 60 * 60 * 24));
|
|
1091
|
+
if (days > 0)
|
|
1092
|
+
name += ` (${days}天)`;
|
|
1093
|
+
}
|
|
1094
|
+
return `- ${name} x${formatNumber(rowQuantity(row))}`;
|
|
1095
|
+
}
|
|
1096
|
+
function formatWallet(result, currency) {
|
|
1097
|
+
if (result && typeof result.total === "object") {
|
|
1098
|
+
return formatLegacyWallet(result, currency);
|
|
1099
|
+
}
|
|
1100
|
+
const rows = extractRows(result?.wallet ?? result);
|
|
1101
|
+
const paid = rows.filter(isPaidBalance).reduce((acc, row) => acc + rowQuantity(row), 0);
|
|
1102
|
+
const free = rows.filter(isFreeBalance).reduce((acc, row) => acc + rowQuantity(row), 0);
|
|
1103
|
+
const other = rows
|
|
1104
|
+
.filter((row) => !isPaidBalance(row) && !isFreeBalance(row))
|
|
1105
|
+
.reduce((acc, row) => acc + rowQuantity(row), 0);
|
|
1106
|
+
const total = paid + free + other;
|
|
1107
|
+
return [
|
|
1108
|
+
"💰 --- 钱包余额 ---",
|
|
1109
|
+
`可用: ${formatNumber(total)} ${currency} (共 ${formatNumber(total)})`,
|
|
1110
|
+
` - 付费: ${formatNumber(paid)}`,
|
|
1111
|
+
` - 免费: ${formatNumber(free)}`,
|
|
1112
|
+
].join("\n");
|
|
1113
|
+
}
|
|
1114
|
+
function formatLegacyWallet(result, currency) {
|
|
1115
|
+
const totalInfo = result?.total ?? {};
|
|
1116
|
+
const paidInfo = result?.paid ?? {};
|
|
1117
|
+
const freeInfo = result?.free ?? {};
|
|
1118
|
+
const available = firstDefined(totalInfo, "available", 0);
|
|
1119
|
+
const allBalance = firstDefined(totalInfo, "all", available);
|
|
1120
|
+
const paid = firstDefined(paidInfo, "available", 0);
|
|
1121
|
+
const free = firstDefined(freeInfo, "available", 0);
|
|
1122
|
+
const lines = [
|
|
1123
|
+
"💰 --- 钱包余额 ---",
|
|
1124
|
+
`可用: ${formatNumber(available)} ${currency} (共 ${formatNumber(allBalance)})`,
|
|
1125
|
+
` - 付费: ${formatNumber(paid)}`,
|
|
1126
|
+
` - 免费: ${formatNumber(free)}`,
|
|
1127
|
+
];
|
|
1128
|
+
const unavailable = toNumber(allBalance) - toNumber(available);
|
|
1129
|
+
if (unavailable > 0) {
|
|
1130
|
+
lines.push(`\n您还有 ${formatNumber(unavailable)} ${currency}未到可用时间。`);
|
|
1131
|
+
}
|
|
1132
|
+
const expiringFree = availableDetails(freeInfo).filter((item) => item.expireAt);
|
|
1133
|
+
expiringFree.sort((a, b) => (parseDateTime(a.expireAt)?.getTime() ?? 0) - (parseDateTime(b.expireAt)?.getTime() ?? 0));
|
|
1134
|
+
if (expiringFree.length > 0) {
|
|
1135
|
+
const soonest = expiringFree[0];
|
|
1136
|
+
lines.push(`\n注意:您有 ${formatNumber(soonest.count ?? 0)} 免费${currency}将于 ${formatDateTime(soonest.expireAt)} 过期。`);
|
|
1137
|
+
}
|
|
1138
|
+
const passes = availableDetails(result?.passes);
|
|
1139
|
+
if (passes.length > 0) {
|
|
1140
|
+
lines.push(`\n--- 可用月卡 (${passes.length}) ---`);
|
|
1141
|
+
for (const item of passes) {
|
|
1142
|
+
lines.push(`- ${assetName(item)}`);
|
|
1143
|
+
lines.push(` 到期: ${formatDateTime(item?.expireAt ?? item?.expiresAt)}`);
|
|
1144
|
+
}
|
|
1145
|
+
}
|
|
1146
|
+
const tickets = availableDetails(result?.tickets);
|
|
1147
|
+
if (tickets.length > 0) {
|
|
1148
|
+
lines.push(`\n--- 可用优惠券 (${tickets.length}) ---`);
|
|
1149
|
+
for (const item of tickets) {
|
|
1150
|
+
lines.push(`- ${assetName(item)} (x${formatNumber(item?.count ?? rowQuantity(item))})`);
|
|
1151
|
+
lines.push(` 到期: ${formatDateTime(item?.expireAt ?? item?.expiresAt)}`);
|
|
1152
|
+
}
|
|
1153
|
+
}
|
|
1154
|
+
return lines.join("\n");
|
|
1155
|
+
}
|
|
1156
|
+
function availableDetails(value) {
|
|
1157
|
+
const details = value?.details ?? {};
|
|
1158
|
+
const rows = details?.available ?? [];
|
|
1159
|
+
return Array.isArray(rows) ? rows.filter((row) => row && typeof row === "object") : [];
|
|
1160
|
+
}
|
|
1161
|
+
function formatHistory(result, currency) {
|
|
1162
|
+
const sessions = extractRows(result?.sessions ?? result);
|
|
1163
|
+
if (sessions.length === 0)
|
|
1164
|
+
return "暂无历史记录";
|
|
1165
|
+
const lines = [`📜 最近 ${sessions.length} 条记录:`];
|
|
1166
|
+
for (const session of sessions) {
|
|
1167
|
+
const sessionId = firstDefined(session, "id", "sessionId", "");
|
|
1168
|
+
const start = formatDateTime(firstDefined(session, "createdAt", "startedAt", "startTime"));
|
|
1169
|
+
const endRaw = firstDefined(session, "closedAt", "endedAt", "endTime");
|
|
1170
|
+
const end = endRaw ? formatDateTime(endRaw) : "进行中";
|
|
1171
|
+
const finalCost = firstDefined(session, "finalCost", "total");
|
|
1172
|
+
const cost = finalCost == null ? "未结算" : `${formatNumber(finalCost)} ${currency}`;
|
|
1173
|
+
lines.push(`- [${sessionId}] ${start} -> ${end} (${cost})`);
|
|
1174
|
+
}
|
|
1175
|
+
return lines.join("\n");
|
|
1176
|
+
}
|
|
1177
|
+
function formatLegacyBilling(result, currency) {
|
|
1178
|
+
const billing = result?.billing ?? {};
|
|
1179
|
+
const session = result?.session ?? {};
|
|
1180
|
+
const discount = result?.discount;
|
|
1181
|
+
const wallet = result?.wallet ?? {};
|
|
1182
|
+
const lines = ["--- 账单详情 ---"];
|
|
1183
|
+
const start = session.createdAt;
|
|
1184
|
+
const end = billing.endTime;
|
|
1185
|
+
lines.push(`入场: ${formatDateTime(start)}`);
|
|
1186
|
+
lines.push(`结算: ${formatDateTime(end)}`);
|
|
1187
|
+
lines.push(`时长: ${formatDurationMinutes(start, end)}`);
|
|
1188
|
+
lines.push("---");
|
|
1189
|
+
const originalCost = discount ? discount.originalCost : billing.totalCost ?? 0;
|
|
1190
|
+
let finalCost = discount ? discount.finalCost : billing.totalCost ?? 0;
|
|
1191
|
+
if (session.costOverwrite)
|
|
1192
|
+
finalCost = session.costOverwrite;
|
|
1193
|
+
lines.push(`计费价: ${formatNumber(originalCost)} ${currency}`);
|
|
1194
|
+
if (discount) {
|
|
1195
|
+
for (const log of discount.appliedLogs ?? []) {
|
|
1196
|
+
lines.push(` -「${log?.asset ?? ""}」: -${formatNumber(log?.saved ?? 0)} ${currency}`);
|
|
1197
|
+
}
|
|
1198
|
+
}
|
|
1199
|
+
lines.push(`结算价: ${formatNumber(finalCost)} ${currency}`);
|
|
1200
|
+
const walletTotal = wallet?.total ?? {};
|
|
1201
|
+
if (walletTotal.available != null) {
|
|
1202
|
+
const currentBalance = walletTotal.available ?? 0;
|
|
1203
|
+
lines.push("---");
|
|
1204
|
+
lines.push(`当前余额: ${formatNumber(currentBalance)} ${currency}`);
|
|
1205
|
+
lines.push(`扣款后: ${formatNumber(toNumber(currentBalance) - toNumber(finalCost))} ${currency}`);
|
|
1206
|
+
}
|
|
1207
|
+
lines.push("---");
|
|
1208
|
+
lines.push("计费区间:");
|
|
1209
|
+
const segments = billing.segments ?? [];
|
|
1210
|
+
if (segments.length === 0) {
|
|
1211
|
+
lines.push(" (无)");
|
|
1212
|
+
}
|
|
1213
|
+
for (const segment of segments) {
|
|
1214
|
+
if (toNumber(segment?.cost ?? 0) < 0)
|
|
1215
|
+
continue;
|
|
1216
|
+
lines.push(`- ${segment?.ruleName ?? ""}`);
|
|
1217
|
+
if (segment?.startTime && segment?.endTime) {
|
|
1218
|
+
lines.push(` 时段: ${formatTimeRange(segment.startTime, segment.endTime)}`);
|
|
1219
|
+
}
|
|
1220
|
+
lines.push(` 时长: ${formatDurationValue(segment?.durationMinutes ?? 0)}`);
|
|
1221
|
+
const capped = segment?.isCapped ? " (已封顶)" : "";
|
|
1222
|
+
lines.push(` 费用: ${formatNumber(segment?.cost ?? 0)} ${currency}${capped}`.trim());
|
|
1223
|
+
}
|
|
1224
|
+
const monthlyPass = (wallet?.passes?.details?.available ?? [null])[0];
|
|
1225
|
+
if (monthlyPass && monthlyPass?.expireAt) {
|
|
1226
|
+
lines.push("---");
|
|
1227
|
+
lines.push(`您的月卡将于 ${formatDateTime(monthlyPass.expireAt)} 到期。`);
|
|
1228
|
+
}
|
|
1229
|
+
return lines.join("\n");
|
|
1230
|
+
}
|
|
1231
|
+
function findSubjectForSession(session, provider) {
|
|
1232
|
+
const identities = session.identities ?? [];
|
|
1233
|
+
if (identities.length === 0)
|
|
1234
|
+
return null;
|
|
1235
|
+
const qq = identities.find((id) => id.provider === provider);
|
|
1236
|
+
if (qq)
|
|
1237
|
+
return qq.subject;
|
|
1238
|
+
return identities[0].subject ?? null;
|
|
1239
|
+
}
|