@tencent-connect/openclaw-qqbot 1.6.5-alpha.2 → 1.6.5-alpha.3
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/dist/src/api.js +36 -14
- package/dist/src/slash-commands.js +2 -9
- package/dist/src/update-checker.js +2 -8
- package/dist/src/utils/pkg-version.d.ts +5 -0
- package/dist/src/utils/pkg-version.js +51 -0
- package/package.json +1 -1
- package/scripts/upgrade-via-npm.ps1 +2 -1
- package/scripts/upgrade-via-npm.sh +2 -1
- package/src/api.ts +35 -11
- package/src/slash-commands.ts +2 -8
- package/src/update-checker.ts +2 -7
- package/src/utils/pkg-version.ts +54 -0
package/dist/src/api.js
CHANGED
|
@@ -2,7 +2,6 @@
|
|
|
2
2
|
* QQ Bot API 鉴权和请求封装
|
|
3
3
|
* [修复版] 已重构为支持多实例并发,消除全局变量冲突
|
|
4
4
|
*/
|
|
5
|
-
import { createRequire } from "node:module";
|
|
6
5
|
import os from "node:os";
|
|
7
6
|
import { computeFileHash, getCachedFileInfo, setCachedFileInfo } from "./utils/upload-cache.js";
|
|
8
7
|
import { sanitizeFileName } from "./utils/platform.js";
|
|
@@ -11,12 +10,8 @@ const TOKEN_URL = "https://bots.qq.com/app/getAppAccessToken";
|
|
|
11
10
|
// ============ Plugin User-Agent ============
|
|
12
11
|
// 格式: QQBotPlugin/{version} (Node/{nodeVersion}; {os})
|
|
13
12
|
// 示例: QQBotPlugin/1.6.0 (Node/22.14.0; darwin)
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
try {
|
|
17
|
-
_pluginVersion = _require("../package.json").version ?? "unknown";
|
|
18
|
-
}
|
|
19
|
-
catch { /* fallback */ }
|
|
13
|
+
import { getPackageVersion } from "./utils/pkg-version.js";
|
|
14
|
+
const _pluginVersion = getPackageVersion(import.meta.url);
|
|
20
15
|
export const PLUGIN_USER_AGENT = `QQBotPlugin/${_pluginVersion} (Node/${process.versions.node}; ${os.platform()})`;
|
|
21
16
|
// 运行时配置
|
|
22
17
|
let currentMarkdownSupport = false;
|
|
@@ -234,21 +229,48 @@ export async function apiRequest(accessToken, method, path, body, timeoutMs) {
|
|
|
234
229
|
});
|
|
235
230
|
const traceId = res.headers.get("x-tps-trace-id") ?? "";
|
|
236
231
|
console.log(`[qqbot-api] <<< Status: ${res.status} ${res.statusText}${traceId ? ` | TraceId: ${traceId}` : ""}`);
|
|
237
|
-
let data;
|
|
238
232
|
let rawBody;
|
|
239
233
|
try {
|
|
240
234
|
rawBody = await res.text();
|
|
241
|
-
console.log(`[qqbot-api] <<< Body:`, rawBody);
|
|
242
|
-
data = JSON.parse(rawBody);
|
|
243
235
|
}
|
|
244
236
|
catch (err) {
|
|
245
|
-
throw new Error(
|
|
237
|
+
throw new Error(`读取响应失败[${path}]: ${err instanceof Error ? err.message : String(err)}`);
|
|
246
238
|
}
|
|
239
|
+
console.log(`[qqbot-api] <<< Body:`, rawBody);
|
|
240
|
+
// 检测非 JSON 响应(HTML 网关错误页 / CDN 限流页等)
|
|
241
|
+
const contentType = res.headers.get("content-type") ?? "";
|
|
242
|
+
const isHtmlResponse = contentType.includes("text/html") || rawBody.trimStart().startsWith("<");
|
|
247
243
|
if (!res.ok) {
|
|
248
|
-
|
|
249
|
-
|
|
244
|
+
if (isHtmlResponse) {
|
|
245
|
+
// HTML 响应 = 网关/限流层返回的错误页,给出友好提示
|
|
246
|
+
const statusHint = res.status === 502 || res.status === 503 || res.status === 504
|
|
247
|
+
? "调用发生异常,请稍候重试"
|
|
248
|
+
: res.status === 429
|
|
249
|
+
? "请求过于频繁,已被限流"
|
|
250
|
+
: `开放平台返回 HTTP ${res.status}`;
|
|
251
|
+
throw new Error(`${statusHint}(${path}),请稍后重试`);
|
|
252
|
+
}
|
|
253
|
+
// JSON 错误响应
|
|
254
|
+
try {
|
|
255
|
+
const error = JSON.parse(rawBody);
|
|
256
|
+
throw new Error(`API Error [${path}]: ${error.message ?? rawBody}`);
|
|
257
|
+
}
|
|
258
|
+
catch (parseErr) {
|
|
259
|
+
if (parseErr instanceof Error && parseErr.message.startsWith("API Error"))
|
|
260
|
+
throw parseErr;
|
|
261
|
+
throw new Error(`API Error [${path}] HTTP ${res.status}: ${rawBody.slice(0, 200)}`);
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
// 成功响应但不是 JSON(极端异常情况)
|
|
265
|
+
if (isHtmlResponse) {
|
|
266
|
+
throw new Error(`QQ 服务端返回了非 JSON 响应(${path}),可能是临时故障,请稍后重试`);
|
|
267
|
+
}
|
|
268
|
+
try {
|
|
269
|
+
return JSON.parse(rawBody);
|
|
270
|
+
}
|
|
271
|
+
catch {
|
|
272
|
+
throw new Error(`开放平台响应格式异常(${path}),请稍后重试`);
|
|
250
273
|
}
|
|
251
|
-
return data;
|
|
252
274
|
}
|
|
253
275
|
// ============ 上传重试(指数退避) ============
|
|
254
276
|
const UPLOAD_MAX_RETRIES = 2;
|
|
@@ -18,16 +18,9 @@ import { getUpdateInfo, checkVersionExists } from "./update-checker.js";
|
|
|
18
18
|
import { getHomeDir, getQQBotDataDir, isWindows } from "./utils/platform.js";
|
|
19
19
|
import { saveCredentialBackup } from "./credential-backup.js";
|
|
20
20
|
import { fileURLToPath } from "node:url";
|
|
21
|
+
import { getPackageVersion } from "./utils/pkg-version.js";
|
|
21
22
|
const require = createRequire(import.meta.url);
|
|
22
|
-
|
|
23
|
-
let PLUGIN_VERSION = "unknown";
|
|
24
|
-
try {
|
|
25
|
-
const pkg = require("../package.json");
|
|
26
|
-
PLUGIN_VERSION = pkg.version ?? "unknown";
|
|
27
|
-
}
|
|
28
|
-
catch {
|
|
29
|
-
// fallback
|
|
30
|
-
}
|
|
23
|
+
let PLUGIN_VERSION = getPackageVersion(import.meta.url);
|
|
31
24
|
// 获取 openclaw 框架版本(缓存结果,只执行一次)
|
|
32
25
|
let _frameworkVersion = null;
|
|
33
26
|
function getFrameworkVersion() {
|
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
*/
|
|
10
10
|
import { createRequire } from "node:module";
|
|
11
11
|
import https from "node:https";
|
|
12
|
+
import { getPackageVersion } from "./utils/pkg-version.js";
|
|
12
13
|
const require = createRequire(import.meta.url);
|
|
13
14
|
const PKG_NAME = "@tencent-connect/openclaw-qqbot";
|
|
14
15
|
const ENCODED_PKG = encodeURIComponent(PKG_NAME);
|
|
@@ -16,14 +17,7 @@ const REGISTRIES = [
|
|
|
16
17
|
`https://registry.npmjs.org/${ENCODED_PKG}`,
|
|
17
18
|
`https://registry.npmmirror.com/${ENCODED_PKG}`,
|
|
18
19
|
];
|
|
19
|
-
let CURRENT_VERSION =
|
|
20
|
-
try {
|
|
21
|
-
const pkg = require("../package.json");
|
|
22
|
-
CURRENT_VERSION = pkg.version ?? "unknown";
|
|
23
|
-
}
|
|
24
|
-
catch {
|
|
25
|
-
// fallback
|
|
26
|
-
}
|
|
20
|
+
let CURRENT_VERSION = getPackageVersion(import.meta.url);
|
|
27
21
|
let _log;
|
|
28
22
|
function fetchJson(url, timeoutMs) {
|
|
29
23
|
return new Promise((resolve, reject) => {
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 从 import.meta.url 向上遍历目录树查找 package.json 并读取 version。
|
|
3
|
+
* 不依赖硬编码的 "../" 层级,无论编译输出结构如何变化都能可靠找到。
|
|
4
|
+
*/
|
|
5
|
+
import { fileURLToPath } from "node:url";
|
|
6
|
+
import { createRequire } from "node:module";
|
|
7
|
+
import path from "node:path";
|
|
8
|
+
import fs from "node:fs";
|
|
9
|
+
let _cached = null;
|
|
10
|
+
export function getPackageVersion(metaUrl) {
|
|
11
|
+
if (_cached !== null)
|
|
12
|
+
return _cached;
|
|
13
|
+
// Strategy 1: 从调用者的 import.meta.url(或本模块)向上遍历找 package.json
|
|
14
|
+
const startFile = metaUrl ? fileURLToPath(metaUrl) : fileURLToPath(import.meta.url);
|
|
15
|
+
let dir = path.dirname(startFile);
|
|
16
|
+
const root = path.parse(dir).root;
|
|
17
|
+
while (dir !== root) {
|
|
18
|
+
const candidate = path.join(dir, "package.json");
|
|
19
|
+
try {
|
|
20
|
+
if (fs.existsSync(candidate)) {
|
|
21
|
+
const pkg = JSON.parse(fs.readFileSync(candidate, "utf8"));
|
|
22
|
+
// 确认是我们自己的包(避免找到其他 package.json)
|
|
23
|
+
if (pkg.name === "@tencent-connect/openclaw-qqbot" && pkg.version) {
|
|
24
|
+
_cached = pkg.version;
|
|
25
|
+
return _cached;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
catch {
|
|
30
|
+
// ignore and try parent
|
|
31
|
+
}
|
|
32
|
+
dir = path.dirname(dir);
|
|
33
|
+
}
|
|
34
|
+
// Strategy 2: fallback 用 createRequire 尝试常见相对路径
|
|
35
|
+
try {
|
|
36
|
+
const require = createRequire(metaUrl ?? import.meta.url);
|
|
37
|
+
for (const rel of ["../../package.json", "../package.json", "./package.json"]) {
|
|
38
|
+
try {
|
|
39
|
+
const pkg = require(rel);
|
|
40
|
+
if (pkg?.version) {
|
|
41
|
+
_cached = pkg.version;
|
|
42
|
+
return _cached;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
catch { /* next */ }
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
catch { /* fallback */ }
|
|
49
|
+
_cached = "unknown";
|
|
50
|
+
return _cached;
|
|
51
|
+
}
|
package/package.json
CHANGED
|
@@ -242,7 +242,7 @@ if ($MissingModules.Count -gt 0) {
|
|
|
242
242
|
$nmDir = Join-Path $STAGING_DIR "node_modules"
|
|
243
243
|
if (Test-Path $nmDir) {
|
|
244
244
|
$BundledOK = $true
|
|
245
|
-
foreach ($dep in @("ws", "
|
|
245
|
+
foreach ($dep in @("ws", "silk-wasm")) {
|
|
246
246
|
if (-not (Test-Path (Join-Path $nmDir $dep))) {
|
|
247
247
|
Write-Host " [WARN] Bundled dependency missing: $dep" -ForegroundColor Yellow
|
|
248
248
|
$BundledOK = $false
|
|
@@ -273,6 +273,7 @@ Write-Host " [OK] All preflight checks passed"
|
|
|
273
273
|
# [3/5] Replace plugin directory (in-place overwrite to avoid file-lock issues)
|
|
274
274
|
Write-Host ""
|
|
275
275
|
Write-Host "[3/5] Replacing plugin directory..."
|
|
276
|
+
if (-not (Test-Path $EXTENSIONS_DIR)) { New-Item -ItemType Directory -Path $EXTENSIONS_DIR -Force | Out-Null }
|
|
276
277
|
$TARGET_DIR = Join-Path $EXTENSIONS_DIR "openclaw-qqbot"
|
|
277
278
|
|
|
278
279
|
if (-not (Test-Path $TARGET_DIR)) {
|
|
@@ -236,7 +236,7 @@ fi
|
|
|
236
236
|
# (e) bundled node_modules 健康检查
|
|
237
237
|
if [ -d "$STAGING_DIR/node_modules" ]; then
|
|
238
238
|
BUNDLED_OK=true
|
|
239
|
-
for dep in "ws" "
|
|
239
|
+
for dep in "ws" "silk-wasm"; do
|
|
240
240
|
if [ ! -d "$STAGING_DIR/node_modules/$dep" ]; then
|
|
241
241
|
echo " ⚠️ bundled 依赖缺失: $dep"
|
|
242
242
|
BUNDLED_OK=false
|
|
@@ -268,6 +268,7 @@ echo " ✅ Preflight 检查全部通过"
|
|
|
268
268
|
# 策略:先把 staging 放到 extensions/ 同级的临时名,再做单次 mv 替换
|
|
269
269
|
echo ""
|
|
270
270
|
echo "[3/5] 原子替换插件目录..."
|
|
271
|
+
mkdir -p "$EXTENSIONS_DIR"
|
|
271
272
|
TARGET_DIR="$EXTENSIONS_DIR/openclaw-qqbot"
|
|
272
273
|
OLD_DIR="$(dirname "$EXTENSIONS_DIR")/.qqbot-upgrade-old"
|
|
273
274
|
|
package/src/api.ts
CHANGED
|
@@ -3,7 +3,6 @@
|
|
|
3
3
|
* [修复版] 已重构为支持多实例并发,消除全局变量冲突
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
|
-
import { createRequire } from "node:module";
|
|
7
6
|
import os from "node:os";
|
|
8
7
|
import { computeFileHash, getCachedFileInfo, setCachedFileInfo } from "./utils/upload-cache.js";
|
|
9
8
|
import { sanitizeFileName } from "./utils/platform.js";
|
|
@@ -14,9 +13,8 @@ const TOKEN_URL = "https://bots.qq.com/app/getAppAccessToken";
|
|
|
14
13
|
// ============ Plugin User-Agent ============
|
|
15
14
|
// 格式: QQBotPlugin/{version} (Node/{nodeVersion}; {os})
|
|
16
15
|
// 示例: QQBotPlugin/1.6.0 (Node/22.14.0; darwin)
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
try { _pluginVersion = _require("../package.json").version ?? "unknown"; } catch { /* fallback */ }
|
|
16
|
+
import { getPackageVersion } from "./utils/pkg-version.js";
|
|
17
|
+
const _pluginVersion = getPackageVersion(import.meta.url);
|
|
20
18
|
export const PLUGIN_USER_AGENT = `QQBotPlugin/${_pluginVersion} (Node/${process.versions.node}; ${os.platform()})`;
|
|
21
19
|
|
|
22
20
|
// 运行时配置
|
|
@@ -285,22 +283,48 @@ export async function apiRequest<T = unknown>(
|
|
|
285
283
|
const traceId = res.headers.get("x-tps-trace-id") ?? "";
|
|
286
284
|
console.log(`[qqbot-api] <<< Status: ${res.status} ${res.statusText}${traceId ? ` | TraceId: ${traceId}` : ""}`);
|
|
287
285
|
|
|
288
|
-
let data: T;
|
|
289
286
|
let rawBody: string;
|
|
290
287
|
try {
|
|
291
288
|
rawBody = await res.text();
|
|
292
|
-
console.log(`[qqbot-api] <<< Body:`, rawBody);
|
|
293
|
-
data = JSON.parse(rawBody) as T;
|
|
294
289
|
} catch (err) {
|
|
295
|
-
throw new Error(
|
|
290
|
+
throw new Error(`读取响应失败[${path}]: ${err instanceof Error ? err.message : String(err)}`);
|
|
296
291
|
}
|
|
292
|
+
console.log(`[qqbot-api] <<< Body:`, rawBody);
|
|
293
|
+
|
|
294
|
+
// 检测非 JSON 响应(HTML 网关错误页 / CDN 限流页等)
|
|
295
|
+
const contentType = res.headers.get("content-type") ?? "";
|
|
296
|
+
const isHtmlResponse = contentType.includes("text/html") || rawBody.trimStart().startsWith("<");
|
|
297
297
|
|
|
298
298
|
if (!res.ok) {
|
|
299
|
-
|
|
300
|
-
|
|
299
|
+
if (isHtmlResponse) {
|
|
300
|
+
// HTML 响应 = 网关/限流层返回的错误页,给出友好提示
|
|
301
|
+
const statusHint = res.status === 502 || res.status === 503 || res.status === 504
|
|
302
|
+
? "调用发生异常,请稍候重试"
|
|
303
|
+
: res.status === 429
|
|
304
|
+
? "请求过于频繁,已被限流"
|
|
305
|
+
: `开放平台返回 HTTP ${res.status}`;
|
|
306
|
+
throw new Error(`${statusHint}(${path}),请稍后重试`);
|
|
307
|
+
}
|
|
308
|
+
// JSON 错误响应
|
|
309
|
+
try {
|
|
310
|
+
const error = JSON.parse(rawBody) as { message?: string; code?: number };
|
|
311
|
+
throw new Error(`API Error [${path}]: ${error.message ?? rawBody}`);
|
|
312
|
+
} catch (parseErr) {
|
|
313
|
+
if (parseErr instanceof Error && parseErr.message.startsWith("API Error")) throw parseErr;
|
|
314
|
+
throw new Error(`API Error [${path}] HTTP ${res.status}: ${rawBody.slice(0, 200)}`);
|
|
315
|
+
}
|
|
301
316
|
}
|
|
302
317
|
|
|
303
|
-
|
|
318
|
+
// 成功响应但不是 JSON(极端异常情况)
|
|
319
|
+
if (isHtmlResponse) {
|
|
320
|
+
throw new Error(`QQ 服务端返回了非 JSON 响应(${path}),可能是临时故障,请稍后重试`);
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
try {
|
|
324
|
+
return JSON.parse(rawBody) as T;
|
|
325
|
+
} catch {
|
|
326
|
+
throw new Error(`开放平台响应格式异常(${path}),请稍后重试`);
|
|
327
|
+
}
|
|
304
328
|
}
|
|
305
329
|
|
|
306
330
|
// ============ 上传重试(指数退避) ============
|
package/src/slash-commands.ts
CHANGED
|
@@ -20,16 +20,10 @@ import { getUpdateInfo, checkVersionExists } from "./update-checker.js";
|
|
|
20
20
|
import { getHomeDir, getQQBotDataDir, isWindows } from "./utils/platform.js";
|
|
21
21
|
import { saveCredentialBackup } from "./credential-backup.js";
|
|
22
22
|
import { fileURLToPath } from "node:url";
|
|
23
|
+
import { getPackageVersion } from "./utils/pkg-version.js";
|
|
23
24
|
const require = createRequire(import.meta.url);
|
|
24
25
|
|
|
25
|
-
|
|
26
|
-
let PLUGIN_VERSION = "unknown";
|
|
27
|
-
try {
|
|
28
|
-
const pkg = require("../package.json");
|
|
29
|
-
PLUGIN_VERSION = pkg.version ?? "unknown";
|
|
30
|
-
} catch {
|
|
31
|
-
// fallback
|
|
32
|
-
}
|
|
26
|
+
let PLUGIN_VERSION = getPackageVersion(import.meta.url);
|
|
33
27
|
|
|
34
28
|
// 获取 openclaw 框架版本(缓存结果,只执行一次)
|
|
35
29
|
let _frameworkVersion: string | null = null;
|
package/src/update-checker.ts
CHANGED
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
|
|
11
11
|
import { createRequire } from "node:module";
|
|
12
12
|
import https from "node:https";
|
|
13
|
+
import { getPackageVersion } from "./utils/pkg-version.js";
|
|
13
14
|
|
|
14
15
|
const require = createRequire(import.meta.url);
|
|
15
16
|
|
|
@@ -21,13 +22,7 @@ const REGISTRIES = [
|
|
|
21
22
|
`https://registry.npmmirror.com/${ENCODED_PKG}`,
|
|
22
23
|
];
|
|
23
24
|
|
|
24
|
-
let CURRENT_VERSION =
|
|
25
|
-
try {
|
|
26
|
-
const pkg = require("../package.json");
|
|
27
|
-
CURRENT_VERSION = pkg.version ?? "unknown";
|
|
28
|
-
} catch {
|
|
29
|
-
// fallback
|
|
30
|
-
}
|
|
25
|
+
let CURRENT_VERSION = getPackageVersion(import.meta.url);
|
|
31
26
|
|
|
32
27
|
export interface UpdateInfo {
|
|
33
28
|
current: string;
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 从 import.meta.url 向上遍历目录树查找 package.json 并读取 version。
|
|
3
|
+
* 不依赖硬编码的 "../" 层级,无论编译输出结构如何变化都能可靠找到。
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { fileURLToPath } from "node:url";
|
|
7
|
+
import { createRequire } from "node:module";
|
|
8
|
+
import path from "node:path";
|
|
9
|
+
import fs from "node:fs";
|
|
10
|
+
|
|
11
|
+
let _cached: string | null = null;
|
|
12
|
+
|
|
13
|
+
export function getPackageVersion(metaUrl?: string): string {
|
|
14
|
+
if (_cached !== null) return _cached;
|
|
15
|
+
|
|
16
|
+
// Strategy 1: 从调用者的 import.meta.url(或本模块)向上遍历找 package.json
|
|
17
|
+
const startFile = metaUrl ? fileURLToPath(metaUrl) : fileURLToPath(import.meta.url);
|
|
18
|
+
let dir = path.dirname(startFile);
|
|
19
|
+
const root = path.parse(dir).root;
|
|
20
|
+
|
|
21
|
+
while (dir !== root) {
|
|
22
|
+
const candidate = path.join(dir, "package.json");
|
|
23
|
+
try {
|
|
24
|
+
if (fs.existsSync(candidate)) {
|
|
25
|
+
const pkg = JSON.parse(fs.readFileSync(candidate, "utf8"));
|
|
26
|
+
// 确认是我们自己的包(避免找到其他 package.json)
|
|
27
|
+
if (pkg.name === "@tencent-connect/openclaw-qqbot" && pkg.version) {
|
|
28
|
+
_cached = pkg.version as string;
|
|
29
|
+
return _cached;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
} catch {
|
|
33
|
+
// ignore and try parent
|
|
34
|
+
}
|
|
35
|
+
dir = path.dirname(dir);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// Strategy 2: fallback 用 createRequire 尝试常见相对路径
|
|
39
|
+
try {
|
|
40
|
+
const require = createRequire(metaUrl ?? import.meta.url);
|
|
41
|
+
for (const rel of ["../../package.json", "../package.json", "./package.json"]) {
|
|
42
|
+
try {
|
|
43
|
+
const pkg = require(rel);
|
|
44
|
+
if (pkg?.version) {
|
|
45
|
+
_cached = pkg.version as string;
|
|
46
|
+
return _cached;
|
|
47
|
+
}
|
|
48
|
+
} catch { /* next */ }
|
|
49
|
+
}
|
|
50
|
+
} catch { /* fallback */ }
|
|
51
|
+
|
|
52
|
+
_cached = "unknown";
|
|
53
|
+
return _cached;
|
|
54
|
+
}
|