@thclouds/openclaw-channel-taidesk 0.1.1 → 0.1.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/README.md +34 -6
- package/index.ts +4 -2
- package/openclaw.plugin.json +0 -1
- package/package.json +7 -2
- package/src/api/{taidesk-outbound.ts → api.ts} +4 -4
- package/src/channel.ts +7 -7
- package/src/compat.ts +47 -4
- package/src/{config.ts → config/config.ts} +1 -1
- package/src/inbound-handler.ts +47 -15
- package/src/messaging/{process-inbound.ts → process-message.ts} +7 -7
- package/src/monitor/{taidesk-monitor.ts → monitor.ts} +14 -8
- package/src/mqtt/taidesk-mqtt-client.ts +69 -9
- package/src/types.ts +6 -3
- package/src/util/logger.ts +145 -0
- /package/src/{config-schema.ts → config/config-schema.ts} +0 -0
package/README.md
CHANGED
|
@@ -1,12 +1,10 @@
|
|
|
1
1
|
# openclaw-channel-taidesk
|
|
2
2
|
|
|
3
|
-
OpenClaw 渠道插件:**Taidesk MQTT 入站**(订阅
|
|
3
|
+
OpenClaw 渠道插件:**Taidesk MQTT 入站**(订阅 **`/open` + agentId**,中间无 `/`)+ **HTTP 出站**。
|
|
4
4
|
|
|
5
|
-
##
|
|
5
|
+
## 技术说明
|
|
6
6
|
|
|
7
|
-
- [MQTT / HTTP
|
|
8
|
-
- [四渠道对比与选型](docs/CHANNEL-COMPARISON.zh-CN.md)
|
|
9
|
-
- [开发计划](docs/TAIDESK-PLUGIN-DEV-PLAN.zh-CN.md)
|
|
7
|
+
- [MQTT / HTTP 契约与字段说明](docs/TAIDESK-WS-CONTRACT.zh-CN.md)
|
|
10
8
|
|
|
11
9
|
## 依赖
|
|
12
10
|
|
|
@@ -35,13 +33,43 @@ OpenClaw 渠道插件:**Taidesk MQTT 入站**(订阅 `/open/{agentId}`)+ *
|
|
|
35
33
|
}
|
|
36
34
|
```
|
|
37
35
|
|
|
38
|
-
|
|
36
|
+
**MQTT 鉴权**:未配置 `mqttUsername` 且未显式配置 `mqttPassword` 时为**匿名连接**(`apiKey` 仅用于 HTTP 出站,不会当作 MQTT 密码)。若 Broker 要求用户名/密码,请配置 `mqttUsername` 或 `mqttPassword`。
|
|
37
|
+
|
|
38
|
+
订阅 topic:**`/open` + `agentId`**(例:`agentId` 为 `2014147198056767490` 时 topic 为 `/open2014147198056767490`)。入站 JSON 见契约文档中的 `agent_msg` 示例。
|
|
39
|
+
|
|
40
|
+
## 本地 MQTT 自测(连接 + 可选触发下行)
|
|
41
|
+
|
|
42
|
+
联调脚本位于 **`tests/live/taidesk-mqtt-smoke.ts`**(与 `tests/unit` 同属 `tests/`,目录说明见 [`tests/README.md`](tests/README.md))。脚本会:
|
|
43
|
+
|
|
44
|
+
1. 用与线上一致的参数连接 MQTT、订阅 `/open`+`agentId`;
|
|
45
|
+
2. 若设置 **`TAIDESK_TEST_BEARER`**,在连接成功后 **GET** `TAIDESK_TEST_GET_URL`(默认
|
|
46
|
+
`https://demo.devlake.thclouds.com:17443/api/app-console/ai/v1/agent/<agentId>/test`),由控制台经 MQTT 下发测试消息,脚本打印收到的 payload;
|
|
47
|
+
3. 默认运行 **120s**(`TAIDESK_SMOKE_MS` 可改)。
|
|
48
|
+
|
|
49
|
+
仅连 MQTT、不调用 HTTP(例如只等自然消息):
|
|
50
|
+
|
|
51
|
+
```bash
|
|
52
|
+
npm run smoke:mqtt
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
连接 + 触发测试接口(Bearer **不要提交到仓库**,只在本地或 CI 密钥里配置):
|
|
56
|
+
|
|
57
|
+
```powershell
|
|
58
|
+
$env:TAIDESK_MQTT_TLS_INSECURE='1'
|
|
59
|
+
$env:TAIDESK_TEST_BEARER='你的BearerToken'
|
|
60
|
+
npm run smoke:mqtt
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
若 Node 报错 **`certificate has expired`**,说明 HTTPS/MQTT 的 TLS 证书已过期;`TAIDESK_MQTT_TLS_INSECURE=1` 会同时作用于 **MQTT 与上述测试 GET**(**仅开发**)。OpenClaw 网关跑插件时也可设该变量;**根治**请更换服务端证书。
|
|
64
|
+
|
|
65
|
+
可用 `TAIDESK_MQTT_URL`、`TAIDESK_AGENT_ID`、`TAIDESK_TEST_GET_URL` 等覆盖;`TAIDESK_SKIP_HTTP_TEST=1` 只测 MQTT、不发 GET。
|
|
39
66
|
|
|
40
67
|
## 脚本
|
|
41
68
|
|
|
42
69
|
| 命令 | 说明 |
|
|
43
70
|
|------|------|
|
|
44
71
|
| `npm run type-check` | TypeScript 检查 |
|
|
72
|
+
| `npm run smoke:mqtt` | 实连 MQTT,收消息冒烟 |
|
|
45
73
|
| `npm test` | Vitest 单元测试 |
|
|
46
74
|
|
|
47
75
|
## 许可
|
package/index.ts
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
|
-
import { buildChannelConfigSchema } from "openclaw/plugin-sdk/channel-config-schema";
|
|
2
1
|
import type { OpenClawPluginApi } from "openclaw/plugin-sdk/plugin-entry";
|
|
2
|
+
import { buildChannelConfigSchema } from "openclaw/plugin-sdk/channel-config-schema";
|
|
3
3
|
|
|
4
4
|
import { taideskPlugin } from "./src/channel.js";
|
|
5
5
|
import { assertHostCompatibility } from "./src/compat.js";
|
|
6
|
-
import { TaideskChannelConfigSchema } from "./src/config-schema.js";
|
|
6
|
+
import { TaideskChannelConfigSchema } from "./src/config/config-schema.js";
|
|
7
7
|
import { setTaideskRuntime } from "./src/runtime.js";
|
|
8
8
|
|
|
9
9
|
export { taideskPlugin, setTaideskRuntime };
|
|
@@ -14,6 +14,7 @@ export default {
|
|
|
14
14
|
description: "Taidesk channel (MQTT inbound + HTTP webhook outbound)",
|
|
15
15
|
configSchema: buildChannelConfigSchema(TaideskChannelConfigSchema),
|
|
16
16
|
register(api: OpenClawPluginApi) {
|
|
17
|
+
// Fail-fast: reject incompatible host versions before any side-effects.
|
|
17
18
|
assertHostCompatibility(api.runtime?.version);
|
|
18
19
|
|
|
19
20
|
if (api.runtime) {
|
|
@@ -22,6 +23,7 @@ export default {
|
|
|
22
23
|
|
|
23
24
|
api.registerChannel({ plugin: taideskPlugin });
|
|
24
25
|
|
|
26
|
+
// registrationMode exists in 2026.3.22+; skip heavy registrations in setup-only mode.
|
|
25
27
|
const mode = (api as { registrationMode?: string }).registrationMode;
|
|
26
28
|
if (mode && mode !== "full") return;
|
|
27
29
|
},
|
package/openclaw.plugin.json
CHANGED
package/package.json
CHANGED
|
@@ -1,20 +1,24 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@thclouds/openclaw-channel-taidesk",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.3",
|
|
4
4
|
"description": "OpenClaw Taidesk channel plugin (MQTT inbound + HTTP outbound)",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "thclouds",
|
|
7
7
|
"type": "module",
|
|
8
8
|
"files": [
|
|
9
9
|
"src/",
|
|
10
|
+
"!src/**/*.test.ts",
|
|
11
|
+
"!src/**/node_modules/",
|
|
10
12
|
"index.ts",
|
|
11
13
|
"openclaw.plugin.json",
|
|
12
14
|
"README.md"
|
|
13
15
|
],
|
|
14
16
|
"scripts": {
|
|
15
17
|
"type-check": "tsc -p tsconfig.json --noEmit",
|
|
18
|
+
"typecheck": "tsc -p tsconfig.json --noEmit",
|
|
16
19
|
"test": "vitest run",
|
|
17
|
-
"
|
|
20
|
+
"smoke:mqtt": "tsx tests/live/taidesk-mqtt-smoke.ts",
|
|
21
|
+
"prepublishOnly": "npm run typecheck && npm test"
|
|
18
22
|
},
|
|
19
23
|
"publishConfig": {
|
|
20
24
|
"access": "public",
|
|
@@ -33,6 +37,7 @@
|
|
|
33
37
|
"devDependencies": {
|
|
34
38
|
"@types/node": "^22.10.0",
|
|
35
39
|
"openclaw": "2026.3.23",
|
|
40
|
+
"tsx": "^4.19.0",
|
|
36
41
|
"typescript": "^5.8.0",
|
|
37
42
|
"vitest": "^3.1.0"
|
|
38
43
|
},
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { OpenClawConfig } from "openclaw/plugin-sdk/core";
|
|
2
2
|
|
|
3
|
-
import { resolveTaideskAccount } from "../config.js";
|
|
3
|
+
import { resolveTaideskAccount } from "../config/config.js";
|
|
4
4
|
import type { TaideskAccountConfig } from "../types.js";
|
|
5
5
|
|
|
6
6
|
function resolveTaideskWebhookUrl(config: TaideskAccountConfig): string {
|
|
@@ -10,12 +10,12 @@ function resolveTaideskWebhookUrl(config: TaideskAccountConfig): string {
|
|
|
10
10
|
export async function sendTaideskText(params: {
|
|
11
11
|
cfg: OpenClawConfig;
|
|
12
12
|
accountId: string;
|
|
13
|
-
|
|
13
|
+
sessionId: string;
|
|
14
14
|
text: string;
|
|
15
15
|
log?: (m: string) => void;
|
|
16
16
|
errLog?: (m: string) => void;
|
|
17
17
|
}): Promise<{ ok: boolean; status: number; body?: string }> {
|
|
18
|
-
const { cfg, accountId,
|
|
18
|
+
const { cfg, accountId, sessionId, text } = params;
|
|
19
19
|
const log = params.log ?? (() => {});
|
|
20
20
|
const errLog = params.errLog ?? log;
|
|
21
21
|
const { config } = resolveTaideskAccount(cfg, accountId);
|
|
@@ -28,7 +28,7 @@ export async function sendTaideskText(params: {
|
|
|
28
28
|
headers.Authorization = `Bearer ${bearer}`;
|
|
29
29
|
}
|
|
30
30
|
const body = JSON.stringify({
|
|
31
|
-
|
|
31
|
+
sessionId,
|
|
32
32
|
text,
|
|
33
33
|
});
|
|
34
34
|
log(`taidesk outbound POST ${url.toString()} len=${body.length}`);
|
package/src/channel.ts
CHANGED
|
@@ -7,9 +7,9 @@ import {
|
|
|
7
7
|
applyTaideskAccountDefaults,
|
|
8
8
|
getTaideskConfig,
|
|
9
9
|
isTaideskAccountConfigured,
|
|
10
|
-
} from "./config.js";
|
|
11
|
-
import {
|
|
12
|
-
import {
|
|
10
|
+
} from "./config/config.js";
|
|
11
|
+
import { sendTaideskText } from "./api/api.js";
|
|
12
|
+
import { monitorTaideskProvider } from "./monitor/monitor.js";
|
|
13
13
|
import type { TaideskAccountConfig } from "./types.js";
|
|
14
14
|
|
|
15
15
|
export type ResolvedTaideskAccount = {
|
|
@@ -39,7 +39,7 @@ export const taideskPlugin: ChannelPlugin<ResolvedTaideskAccount> = {
|
|
|
39
39
|
selectionLabel: "Taidesk (MQTT)",
|
|
40
40
|
docsPath: "/channels/taidesk",
|
|
41
41
|
docsLabel: "Taidesk",
|
|
42
|
-
blurb: "Taidesk:MQTT 入站 `/open
|
|
42
|
+
blurb: "Taidesk:MQTT 入站 `/open`+agentId + HTTP 出站",
|
|
43
43
|
order: 76,
|
|
44
44
|
},
|
|
45
45
|
configSchema: {
|
|
@@ -117,7 +117,7 @@ export const taideskPlugin: ChannelPlugin<ResolvedTaideskAccount> = {
|
|
|
117
117
|
resolveTarget: ({ to }: { to?: string }) => {
|
|
118
118
|
const trimmed = to?.trim();
|
|
119
119
|
if (!trimmed) {
|
|
120
|
-
return { ok: false as const, error: new Error("taidesk: --to <
|
|
120
|
+
return { ok: false as const, error: new Error("taidesk: --to <sessionId> required") };
|
|
121
121
|
}
|
|
122
122
|
return { ok: true as const, to: trimmed };
|
|
123
123
|
},
|
|
@@ -127,7 +127,7 @@ export const taideskPlugin: ChannelPlugin<ResolvedTaideskAccount> = {
|
|
|
127
127
|
const result = await sendTaideskText({
|
|
128
128
|
cfg,
|
|
129
129
|
accountId: id,
|
|
130
|
-
|
|
130
|
+
sessionId: to,
|
|
131
131
|
text,
|
|
132
132
|
log: (m) => {
|
|
133
133
|
console.info(`[taidesk] ${m}`);
|
|
@@ -151,7 +151,7 @@ export const taideskPlugin: ChannelPlugin<ResolvedTaideskAccount> = {
|
|
|
151
151
|
if (!isTaideskAccountConfigured(account.config)) {
|
|
152
152
|
throw new Error("taidesk: mqttUrl、agentId、webhook、apiKey 为必填(且需替换默认占位 agentId/apiKey)");
|
|
153
153
|
}
|
|
154
|
-
const mon =
|
|
154
|
+
const mon = monitorTaideskProvider({
|
|
155
155
|
cfg,
|
|
156
156
|
accountId: account.accountId,
|
|
157
157
|
accountConfig: account.config,
|
package/src/compat.ts
CHANGED
|
@@ -1,9 +1,30 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
3
|
-
*
|
|
2
|
+
* Runtime host-version compatibility check for openclaw-channel-taidesk.
|
|
3
|
+
*
|
|
4
|
+
* OpenClaw uses a date-based version format: YYYY.M.DD (e.g. 2026.3.22).
|
|
5
|
+
* This module parses that format and validates the running host is within
|
|
6
|
+
* the supported range for this plugin version.
|
|
4
7
|
*/
|
|
5
8
|
|
|
6
|
-
|
|
9
|
+
import fs from "node:fs";
|
|
10
|
+
import path from "node:path";
|
|
11
|
+
import { fileURLToPath } from "node:url";
|
|
12
|
+
|
|
13
|
+
import { logger } from "./util/logger.js";
|
|
14
|
+
|
|
15
|
+
function readPluginVersion(): string {
|
|
16
|
+
try {
|
|
17
|
+
const dir = path.dirname(fileURLToPath(import.meta.url));
|
|
18
|
+
const pkgPath = path.resolve(dir, "..", "package.json");
|
|
19
|
+
const pkg = JSON.parse(fs.readFileSync(pkgPath, "utf-8")) as { version?: string };
|
|
20
|
+
return pkg.version ?? "unknown";
|
|
21
|
+
} catch {
|
|
22
|
+
return "unknown";
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Package version (from package.json); single source of truth with npm publish. */
|
|
27
|
+
export const PLUGIN_VERSION = readPluginVersion();
|
|
7
28
|
|
|
8
29
|
export const SUPPORTED_HOST_MIN = "2026.3.22";
|
|
9
30
|
|
|
@@ -13,7 +34,12 @@ export interface OpenClawVersion {
|
|
|
13
34
|
day: number;
|
|
14
35
|
}
|
|
15
36
|
|
|
37
|
+
/**
|
|
38
|
+
* Parse an OpenClaw date version string (e.g. "2026.3.22") into components.
|
|
39
|
+
* Returns null for unparseable strings.
|
|
40
|
+
*/
|
|
16
41
|
export function parseOpenClawVersion(version: string): OpenClawVersion | null {
|
|
42
|
+
// Strip any pre-release suffix (e.g. "2026.3.22-beta.1" -> "2026.3.22")
|
|
17
43
|
const base = version.trim().split("-")[0];
|
|
18
44
|
const parts = base.split(".");
|
|
19
45
|
if (parts.length !== 3) return null;
|
|
@@ -22,6 +48,9 @@ export function parseOpenClawVersion(version: string): OpenClawVersion | null {
|
|
|
22
48
|
return { year, month, day };
|
|
23
49
|
}
|
|
24
50
|
|
|
51
|
+
/**
|
|
52
|
+
* Compare two parsed versions. Returns -1 | 0 | 1.
|
|
53
|
+
*/
|
|
25
54
|
export function compareVersions(a: OpenClawVersion, b: OpenClawVersion): -1 | 0 | 1 {
|
|
26
55
|
for (const key of ["year", "month", "day"] as const) {
|
|
27
56
|
if (a[key] < b[key]) return -1;
|
|
@@ -30,6 +59,9 @@ export function compareVersions(a: OpenClawVersion, b: OpenClawVersion): -1 | 0
|
|
|
30
59
|
return 0;
|
|
31
60
|
}
|
|
32
61
|
|
|
62
|
+
/**
|
|
63
|
+
* Check whether a host version string is >= SUPPORTED_HOST_MIN.
|
|
64
|
+
*/
|
|
33
65
|
export function isHostVersionSupported(hostVersion: string): boolean {
|
|
34
66
|
const host = parseOpenClawVersion(hostVersion);
|
|
35
67
|
if (!host) return false;
|
|
@@ -37,15 +69,26 @@ export function isHostVersionSupported(hostVersion: string): boolean {
|
|
|
37
69
|
return compareVersions(host, min) >= 0;
|
|
38
70
|
}
|
|
39
71
|
|
|
72
|
+
/**
|
|
73
|
+
* Fail-fast guard. Call at the very start of `register()` to prevent the
|
|
74
|
+
* plugin from loading on an incompatible host.
|
|
75
|
+
*
|
|
76
|
+
* @throws {Error} with a human-readable message when the host is out of range.
|
|
77
|
+
*/
|
|
40
78
|
export function assertHostCompatibility(hostVersion: string | undefined): void {
|
|
41
79
|
if (!hostVersion || hostVersion === "unknown") {
|
|
80
|
+
logger.warn(
|
|
81
|
+
`[compat] Could not determine host OpenClaw version; skipping compatibility check.`,
|
|
82
|
+
);
|
|
42
83
|
return;
|
|
43
84
|
}
|
|
44
85
|
if (isHostVersionSupported(hostVersion)) {
|
|
86
|
+
logger.info(`[compat] Host OpenClaw ${hostVersion} >= ${SUPPORTED_HOST_MIN}, OK.`);
|
|
45
87
|
return;
|
|
46
88
|
}
|
|
47
89
|
throw new Error(
|
|
48
90
|
`@thclouds/openclaw-channel-taidesk@${PLUGIN_VERSION} requires OpenClaw >=${SUPPORTED_HOST_MIN}, ` +
|
|
49
|
-
`but found ${hostVersion}.
|
|
91
|
+
`but found ${hostVersion}. ` +
|
|
92
|
+
`Please upgrade OpenClaw.`,
|
|
50
93
|
);
|
|
51
94
|
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import type { OpenClawConfig } from "openclaw/plugin-sdk/core";
|
|
2
2
|
|
|
3
3
|
import { TAIDESK_ACCOUNT_DEFAULTS } from "./config-schema.js";
|
|
4
|
-
import type { TaideskAccountConfig } from "
|
|
4
|
+
import type { TaideskAccountConfig } from "../types.js";
|
|
5
5
|
|
|
6
6
|
export function getTaideskSection(cfg: OpenClawConfig): Record<string, unknown> {
|
|
7
7
|
const ch = cfg.channels as Record<string, unknown> | undefined;
|
package/src/inbound-handler.ts
CHANGED
|
@@ -1,5 +1,11 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
|
|
1
3
|
import type { TaideskInboundEvent } from "./types.js";
|
|
2
4
|
|
|
5
|
+
function stableEventIdFromRawPayload(raw: string): string {
|
|
6
|
+
return createHash("sha256").update(raw, "utf8").digest("hex").slice(0, 32);
|
|
7
|
+
}
|
|
8
|
+
|
|
3
9
|
function parseTaideskCreateTimeMs(createTime: string): number {
|
|
4
10
|
if (!createTime.trim()) {
|
|
5
11
|
return Date.now();
|
|
@@ -11,9 +17,9 @@ function parseTaideskCreateTimeMs(createTime: string): number {
|
|
|
11
17
|
|
|
12
18
|
/**
|
|
13
19
|
* Taidesk MQTT 载荷:`event: agent_msg`,`data` 为业务体。
|
|
14
|
-
*
|
|
20
|
+
* 完整形态含 `data.id` / `sessionId` / `userId`;根级发送方回退为 **`agentId`**(兼容旧版根级 `userId`);精简形态仅根级 `agentId` 作会话路由,不作发送方。
|
|
15
21
|
*/
|
|
16
|
-
function parseAgentMsgEnvelope(v: Record<string, unknown
|
|
22
|
+
function parseAgentMsgEnvelope(v: Record<string, unknown>, raw: string): TaideskInboundEvent | null {
|
|
17
23
|
if (v.event !== "agent_msg") {
|
|
18
24
|
return null;
|
|
19
25
|
}
|
|
@@ -22,9 +28,32 @@ function parseAgentMsgEnvelope(v: Record<string, unknown>): TaideskInboundEvent
|
|
|
22
28
|
return null;
|
|
23
29
|
}
|
|
24
30
|
const d = data as Record<string, unknown>;
|
|
25
|
-
const
|
|
26
|
-
const
|
|
27
|
-
|
|
31
|
+
const idStr = d.id != null ? String(d.id).trim() : "";
|
|
32
|
+
const eventId = idStr !== "" ? idStr : stableEventIdFromRawPayload(raw);
|
|
33
|
+
|
|
34
|
+
const sessionStr = d.sessionId != null ? String(d.sessionId).trim() : "";
|
|
35
|
+
const rootAgent =
|
|
36
|
+
v.agentId != null && String(v.agentId).trim() !== "" ? String(v.agentId).trim() : "";
|
|
37
|
+
/** 会话维度:优先 `data.sessionId`,否则根级 `agentId`(控制台精简推送无 sessionId 时)。 */
|
|
38
|
+
const sessionId = sessionStr !== "" ? sessionStr : rootAgent;
|
|
39
|
+
if (!sessionId) {
|
|
40
|
+
return null;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const fromData = d.userId != null ? String(d.userId).trim() : "";
|
|
44
|
+
/** 有 `data.sessionId` 时根级 `agentId` 表示发送方回退;无 `data.sessionId` 时根级 `agentId` 已用于会话路由,发送方仅认根级 `userId`(兼容)。 */
|
|
45
|
+
let fromRoot = "";
|
|
46
|
+
if (sessionStr !== "") {
|
|
47
|
+
const rootAgentId =
|
|
48
|
+
v.agentId != null && String(v.agentId).trim() !== "" ? String(v.agentId).trim() : "";
|
|
49
|
+
const rootUserId =
|
|
50
|
+
typeof v.userId === "string" && v.userId.trim() !== "" ? v.userId.trim() : "";
|
|
51
|
+
fromRoot = rootAgentId !== "" ? rootAgentId : rootUserId;
|
|
52
|
+
} else {
|
|
53
|
+
fromRoot = typeof v.userId === "string" && v.userId.trim() !== "" ? v.userId.trim() : "";
|
|
54
|
+
}
|
|
55
|
+
const userId = fromData !== "" ? fromData : fromRoot !== "" ? fromRoot : "0";
|
|
56
|
+
|
|
28
57
|
const content = d.content;
|
|
29
58
|
let text = "";
|
|
30
59
|
if (content && typeof content === "object") {
|
|
@@ -35,14 +64,11 @@ function parseAgentMsgEnvelope(v: Record<string, unknown>): TaideskInboundEvent
|
|
|
35
64
|
}
|
|
36
65
|
const tenantId = typeof d.tenantId === "string" ? d.tenantId : undefined;
|
|
37
66
|
const createTime = typeof d.createTime === "string" ? d.createTime : "";
|
|
38
|
-
const timestamp = parseTaideskCreateTimeMs(createTime);
|
|
39
|
-
if (!eventId || !sessionId || !userId) {
|
|
40
|
-
return null;
|
|
41
|
-
}
|
|
67
|
+
const timestamp = createTime ? parseTaideskCreateTimeMs(createTime) : Date.now();
|
|
42
68
|
return {
|
|
43
69
|
type: "agent_msg",
|
|
44
70
|
eventId,
|
|
45
|
-
|
|
71
|
+
sessionId,
|
|
46
72
|
tenantId,
|
|
47
73
|
userId,
|
|
48
74
|
text,
|
|
@@ -50,23 +76,29 @@ function parseAgentMsgEnvelope(v: Record<string, unknown>): TaideskInboundEvent
|
|
|
50
76
|
};
|
|
51
77
|
}
|
|
52
78
|
|
|
53
|
-
/** 兼容旧版扁平 JSON
|
|
79
|
+
/** 兼容旧版扁平 JSON(测试/迁移);会话字段优先 `sessionId`,否则 `conversationId`。 */
|
|
54
80
|
function parseLegacyFlat(v: Record<string, unknown>): TaideskInboundEvent | null {
|
|
55
81
|
if (v.type === "ping" || v.type === "pong") {
|
|
56
82
|
return null;
|
|
57
83
|
}
|
|
58
84
|
const eventId = typeof v.eventId === "string" ? v.eventId : "";
|
|
59
|
-
const
|
|
85
|
+
const sessionIdFromSession =
|
|
86
|
+
typeof v.sessionId === "string" && v.sessionId.trim() !== "" ? v.sessionId.trim() : "";
|
|
87
|
+
const sessionIdFromConv =
|
|
88
|
+
typeof v.conversationId === "string" && v.conversationId.trim() !== ""
|
|
89
|
+
? v.conversationId.trim()
|
|
90
|
+
: "";
|
|
91
|
+
const sessionId = sessionIdFromSession !== "" ? sessionIdFromSession : sessionIdFromConv;
|
|
60
92
|
const userId = typeof v.userId === "string" ? v.userId : "";
|
|
61
93
|
const text = typeof v.text === "string" ? v.text : "";
|
|
62
94
|
const timestamp = typeof v.timestamp === "number" ? v.timestamp : Date.now();
|
|
63
|
-
if (!eventId || !
|
|
95
|
+
if (!eventId || !sessionId || !userId) {
|
|
64
96
|
return null;
|
|
65
97
|
}
|
|
66
98
|
return {
|
|
67
99
|
type: typeof v.type === "string" ? v.type : "message",
|
|
68
100
|
eventId,
|
|
69
|
-
|
|
101
|
+
sessionId,
|
|
70
102
|
tenantId: typeof v.tenantId === "string" ? v.tenantId : undefined,
|
|
71
103
|
userId,
|
|
72
104
|
text,
|
|
@@ -77,7 +109,7 @@ function parseLegacyFlat(v: Record<string, unknown>): TaideskInboundEvent | null
|
|
|
77
109
|
export function parseTaideskInboundJson(raw: string): TaideskInboundEvent | null {
|
|
78
110
|
try {
|
|
79
111
|
const v = JSON.parse(raw) as Record<string, unknown>;
|
|
80
|
-
const agent = parseAgentMsgEnvelope(v);
|
|
112
|
+
const agent = parseAgentMsgEnvelope(v, raw);
|
|
81
113
|
if (agent) {
|
|
82
114
|
return agent;
|
|
83
115
|
}
|
|
@@ -3,7 +3,7 @@ import type { PluginRuntime } from "openclaw/plugin-sdk/core";
|
|
|
3
3
|
import { createTypingCallbacks } from "openclaw/plugin-sdk/channel-runtime";
|
|
4
4
|
import type { MsgContext } from "openclaw/plugin-sdk/reply-runtime";
|
|
5
5
|
|
|
6
|
-
import { sendTaideskText } from "../api/
|
|
6
|
+
import { sendTaideskText } from "../api/api.js";
|
|
7
7
|
import type { TaideskInboundEvent } from "../types.js";
|
|
8
8
|
|
|
9
9
|
export async function processTaideskInbound(params: {
|
|
@@ -21,14 +21,14 @@ export async function processTaideskInbound(params: {
|
|
|
21
21
|
const ctx: MsgContext = {
|
|
22
22
|
Body: event.text ?? "",
|
|
23
23
|
From: event.userId,
|
|
24
|
-
To: event.
|
|
24
|
+
To: event.sessionId,
|
|
25
25
|
MessageSid: event.eventId,
|
|
26
26
|
Timestamp: event.timestamp,
|
|
27
27
|
OriginatingChannel: "taidesk",
|
|
28
|
-
OriginatingTo: event.
|
|
28
|
+
OriginatingTo: event.sessionId,
|
|
29
29
|
AccountId: accountId,
|
|
30
30
|
ChatType: "direct",
|
|
31
|
-
NativeChannelId: event.
|
|
31
|
+
NativeChannelId: event.sessionId,
|
|
32
32
|
CommandAuthorized: true,
|
|
33
33
|
};
|
|
34
34
|
|
|
@@ -36,7 +36,7 @@ export async function processTaideskInbound(params: {
|
|
|
36
36
|
cfg,
|
|
37
37
|
channel: "taidesk",
|
|
38
38
|
accountId,
|
|
39
|
-
peer: { kind: "direct", id: event.
|
|
39
|
+
peer: { kind: "direct", id: event.sessionId },
|
|
40
40
|
});
|
|
41
41
|
|
|
42
42
|
ctx.SessionKey = route.sessionKey;
|
|
@@ -54,7 +54,7 @@ export async function processTaideskInbound(params: {
|
|
|
54
54
|
updateLastRoute: {
|
|
55
55
|
sessionKey: route.mainSessionKey,
|
|
56
56
|
channel: "taidesk",
|
|
57
|
-
to: event.
|
|
57
|
+
to: event.sessionId,
|
|
58
58
|
accountId,
|
|
59
59
|
},
|
|
60
60
|
onRecordError: (err) => errLog(`recordInboundSession: ${String(err)}`),
|
|
@@ -78,7 +78,7 @@ export async function processTaideskInbound(params: {
|
|
|
78
78
|
await sendTaideskText({
|
|
79
79
|
cfg,
|
|
80
80
|
accountId,
|
|
81
|
-
|
|
81
|
+
sessionId: event.sessionId,
|
|
82
82
|
text,
|
|
83
83
|
log,
|
|
84
84
|
errLog,
|
|
@@ -3,8 +3,11 @@ import type { PluginRuntime } from "openclaw/plugin-sdk/core";
|
|
|
3
3
|
|
|
4
4
|
import { shouldSkipDedup } from "../dedup.js";
|
|
5
5
|
import { parseTaideskInboundJson } from "../inbound-handler.js";
|
|
6
|
-
import {
|
|
7
|
-
|
|
6
|
+
import {
|
|
7
|
+
buildMqttConnectAuth,
|
|
8
|
+
createTaideskMqttConnection,
|
|
9
|
+
} from "../mqtt/taidesk-mqtt-client.js";
|
|
10
|
+
import { processTaideskInbound } from "../messaging/process-message.js";
|
|
8
11
|
import { resolveTaideskChannelRuntime } from "../runtime.js";
|
|
9
12
|
import type { TaideskAccountConfig } from "../types.js";
|
|
10
13
|
|
|
@@ -19,23 +22,26 @@ export type MonitorTaideskOpts = {
|
|
|
19
22
|
};
|
|
20
23
|
|
|
21
24
|
/**
|
|
22
|
-
*
|
|
25
|
+
* Taidesk 入站:MQTT 订阅 `/open`+agentId → 单条处理(对应微信 `monitorWeixinProvider`)。
|
|
23
26
|
*/
|
|
24
|
-
export function
|
|
27
|
+
export function monitorTaideskProvider(opts: MonitorTaideskOpts): { close: () => void } {
|
|
25
28
|
const log = opts.log ?? (() => {});
|
|
26
29
|
const errLog = opts.errLog ?? log;
|
|
27
|
-
const
|
|
30
|
+
const mqttAuth = buildMqttConnectAuth(opts.accountConfig);
|
|
31
|
+
log(
|
|
32
|
+
`taidesk mqtt connecting account=${opts.accountId} topic=/open${opts.accountConfig.agentId} auth=${mqttAuth.mode}`,
|
|
33
|
+
);
|
|
28
34
|
|
|
29
35
|
const conn = createTaideskMqttConnection({
|
|
30
36
|
mqttUrl: opts.accountConfig.mqttUrl,
|
|
31
37
|
agentId: opts.accountConfig.agentId,
|
|
32
|
-
mqttUsername:
|
|
33
|
-
mqttPassword:
|
|
38
|
+
mqttUsername: mqttAuth.username,
|
|
39
|
+
mqttPassword: mqttAuth.password,
|
|
34
40
|
clientId: opts.accountConfig.mqttClientId,
|
|
35
41
|
abortSignal: opts.abortSignal,
|
|
36
42
|
onConnect: () => {
|
|
37
43
|
log(
|
|
38
|
-
`taidesk mqtt connected account=${opts.accountId} topic=/open
|
|
44
|
+
`taidesk mqtt connected account=${opts.accountId} topic=/open${opts.accountConfig.agentId}`,
|
|
39
45
|
);
|
|
40
46
|
},
|
|
41
47
|
onMessage: (_topic, raw) => {
|
|
@@ -1,4 +1,46 @@
|
|
|
1
|
-
import mqtt from "mqtt";
|
|
1
|
+
import mqtt, { type IClientOptions } from "mqtt";
|
|
2
|
+
|
|
3
|
+
export type MqttConnectAuthMode = "none" | "password-only" | "user-pass";
|
|
4
|
+
|
|
5
|
+
export type MqttConnectAuth = {
|
|
6
|
+
mode: MqttConnectAuthMode;
|
|
7
|
+
username?: string;
|
|
8
|
+
password?: string;
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* 从账号配置推导 MQTT CONNECT 鉴权。
|
|
13
|
+
* 多数 Taidesk/DevLake Broker 为**匿名**:不要把 HTTP 的 apiKey 默认当作 MQTT 密码,否则会被服务端断开。
|
|
14
|
+
* - 未配置 mqttUsername 且未显式配置 mqttPassword → 不传用户名/密码
|
|
15
|
+
* - 配置了 mqttUsername → 密码为 mqttPassword ?? apiKey ?? token
|
|
16
|
+
* - 仅显式配置 mqttPassword(非空)且无 username → 仅密码(password-only)
|
|
17
|
+
* - mqttPassword 为空字符串 → 不传密码(可与 mqttUsername 组合表示「只要用户名」)
|
|
18
|
+
*/
|
|
19
|
+
export function buildMqttConnectAuth(account: {
|
|
20
|
+
mqttUsername?: string;
|
|
21
|
+
mqttPassword?: string;
|
|
22
|
+
apiKey?: string;
|
|
23
|
+
token?: string;
|
|
24
|
+
}): MqttConnectAuth {
|
|
25
|
+
const user = account.mqttUsername?.trim();
|
|
26
|
+
const explicitPwd = account.mqttPassword;
|
|
27
|
+
|
|
28
|
+
if (explicitPwd !== undefined) {
|
|
29
|
+
if (explicitPwd === "") {
|
|
30
|
+
if (user) return { mode: "user-pass", username: user };
|
|
31
|
+
return { mode: "none" };
|
|
32
|
+
}
|
|
33
|
+
if (user) return { mode: "user-pass", username: user, password: explicitPwd };
|
|
34
|
+
return { mode: "password-only", password: explicitPwd };
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
if (user) {
|
|
38
|
+
const password = account.apiKey ?? account.token ?? "";
|
|
39
|
+
return { mode: "user-pass", username: user, password };
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
return { mode: "none" };
|
|
43
|
+
}
|
|
2
44
|
|
|
3
45
|
/** Taidesk 控制台常用 https://host/ws,mqtt.js WebSocket 需 wss:// */
|
|
4
46
|
export function normalizeMqttConnectUrl(mqttUrl: string): string {
|
|
@@ -14,8 +56,9 @@ export function normalizeMqttConnectUrl(mqttUrl: string): string {
|
|
|
14
56
|
export type TaideskMqttClientOptions = {
|
|
15
57
|
/** 例如 mqtt://、mqtts://、ws://、wss://(MQTT over WebSocket);https/http 会规范为 wss/ws */
|
|
16
58
|
mqttUrl: string;
|
|
17
|
-
/** 订阅 topic:`/open
|
|
59
|
+
/** 订阅 topic:`/open` + agentId(与 Taidesk Broker 一致,中间无 `/`) */
|
|
18
60
|
agentId: string;
|
|
61
|
+
/** 已由 buildMqttConnectAuth 解析;未设置时不要传 apiKey 作为默认密码 */
|
|
19
62
|
mqttUsername?: string;
|
|
20
63
|
mqttPassword?: string;
|
|
21
64
|
clientId?: string;
|
|
@@ -24,22 +67,39 @@ export type TaideskMqttClientOptions = {
|
|
|
24
67
|
onClose?: () => void;
|
|
25
68
|
onError?: (err: Error) => void;
|
|
26
69
|
abortSignal?: AbortSignal;
|
|
70
|
+
/**
|
|
71
|
+
* 默认遵循 Node TLS 校验。设为 `false` 仅用于自签/过期证书的开发环境(勿用于生产)。
|
|
72
|
+
* 也可设置环境变量 `TAIDESK_MQTT_TLS_INSECURE=1`(在 options 未传时生效)。
|
|
73
|
+
*/
|
|
74
|
+
rejectUnauthorized?: boolean;
|
|
27
75
|
};
|
|
28
76
|
|
|
29
77
|
/**
|
|
30
|
-
* Taidesk 入站:MQTT 订阅 `/open
|
|
78
|
+
* Taidesk 入站:MQTT 订阅 `/open` + agentId(示例:`/open2014147198056767490`)。
|
|
31
79
|
* 重连由 mqtt.js 内置 `reconnectPeriod` 处理。
|
|
32
80
|
*/
|
|
33
81
|
export function createTaideskMqttConnection(opts: TaideskMqttClientOptions): { close: () => void } {
|
|
34
|
-
const topic = `/open
|
|
82
|
+
const topic = `/open${opts.agentId.trim()}`;
|
|
35
83
|
const connectUrl = normalizeMqttConnectUrl(opts.mqttUrl);
|
|
36
|
-
const
|
|
37
|
-
|
|
38
|
-
|
|
84
|
+
const clientOpts: IClientOptions = {
|
|
85
|
+
protocolVersion: 4,
|
|
86
|
+
clean: true,
|
|
39
87
|
clientId: opts.clientId ?? `openclaw-taidesk-${Date.now()}`,
|
|
40
88
|
reconnectPeriod: 5_000,
|
|
41
89
|
connectTimeout: 30_000,
|
|
42
|
-
}
|
|
90
|
+
};
|
|
91
|
+
if (opts.mqttUsername !== undefined && opts.mqttUsername !== "") {
|
|
92
|
+
clientOpts.username = opts.mqttUsername;
|
|
93
|
+
}
|
|
94
|
+
if (opts.mqttPassword !== undefined && opts.mqttPassword !== "") {
|
|
95
|
+
clientOpts.password = opts.mqttPassword;
|
|
96
|
+
}
|
|
97
|
+
const tlsInsecure =
|
|
98
|
+
opts.rejectUnauthorized === false || process.env.TAIDESK_MQTT_TLS_INSECURE === "1";
|
|
99
|
+
if (tlsInsecure) {
|
|
100
|
+
clientOpts.rejectUnauthorized = false;
|
|
101
|
+
}
|
|
102
|
+
const client = mqtt.connect(connectUrl, clientOpts);
|
|
43
103
|
|
|
44
104
|
const onAbort = (): void => {
|
|
45
105
|
client.end(true);
|
|
@@ -49,7 +109,7 @@ export function createTaideskMqttConnection(opts: TaideskMqttClientOptions): { c
|
|
|
49
109
|
client.on("connect", () => {
|
|
50
110
|
client.subscribe(topic, { qos: 0 }, (err) => {
|
|
51
111
|
if (err) {
|
|
52
|
-
opts.onError?.(err);
|
|
112
|
+
opts.onError?.(err instanceof Error ? err : new Error(String(err)));
|
|
53
113
|
return;
|
|
54
114
|
}
|
|
55
115
|
opts.onConnect?.();
|
package/src/types.ts
CHANGED
|
@@ -4,7 +4,8 @@
|
|
|
4
4
|
export type TaideskInboundEvent = {
|
|
5
5
|
type: string;
|
|
6
6
|
eventId: string;
|
|
7
|
-
|
|
7
|
+
/** Taidesk 会话:`data.sessionId`;无则根级 `agentId`(精简推送)。用于 peer 路由与出站 HTTP。 */
|
|
8
|
+
sessionId: string;
|
|
8
9
|
tenantId?: string;
|
|
9
10
|
userId: string;
|
|
10
11
|
text?: string;
|
|
@@ -16,14 +17,16 @@ export type TaideskAccountConfig = {
|
|
|
16
17
|
name?: string;
|
|
17
18
|
/** MQTT Broker,如 mqtts://host:8883、wss://host/mqtt;https:// 会在连接前规范为 wss:// */
|
|
18
19
|
mqttUrl: string;
|
|
19
|
-
/** 订阅 topic `/open
|
|
20
|
+
/** 订阅 topic 为 `/open` + 本字段(无中间斜杠,如 `/open2014147198056767490`) */
|
|
20
21
|
agentId: string;
|
|
21
22
|
/** 出站 Webhook 完整 URL;可用 `{id}` 占位,发送前替换为 agentId */
|
|
22
23
|
webhook: string;
|
|
23
24
|
apiKey: string;
|
|
25
|
+
/** 若设置:MQTT 密码为 mqttPassword ?? apiKey ?? token */
|
|
24
26
|
mqttUsername?: string;
|
|
27
|
+
/** 显式 MQTT 密码;与 mqttUsername 均未配置时**不会**把 apiKey 当作 MQTT 密码(匿名连接) */
|
|
25
28
|
mqttPassword?: string;
|
|
26
29
|
mqttClientId?: string;
|
|
27
|
-
/** HTTP 出站 Bearer
|
|
30
|
+
/** HTTP 出站 Bearer;在已配置 mqttUsername 时可作为 MQTT 密码后备 */
|
|
28
31
|
token?: string;
|
|
29
32
|
};
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import os from "node:os";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
|
|
5
|
+
import { resolvePreferredOpenClawTmpDir } from "openclaw/plugin-sdk/infra-runtime";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Plugin logger — writes JSON lines to the main openclaw log file:
|
|
9
|
+
* <tmpDir>/openclaw-YYYY-MM-DD.log
|
|
10
|
+
* Same file and format used by all other channels.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
const MAIN_LOG_DIR = resolvePreferredOpenClawTmpDir();
|
|
14
|
+
const SUBSYSTEM = "gateway/channels/taidesk";
|
|
15
|
+
const RUNTIME = "node";
|
|
16
|
+
const RUNTIME_VERSION = process.versions.node;
|
|
17
|
+
const HOSTNAME = os.hostname() || "unknown";
|
|
18
|
+
const PARENT_NAMES = ["openclaw"];
|
|
19
|
+
|
|
20
|
+
/** tslog-compatible level IDs (higher = more severe). */
|
|
21
|
+
const LEVEL_IDS: Record<string, number> = {
|
|
22
|
+
TRACE: 1,
|
|
23
|
+
DEBUG: 2,
|
|
24
|
+
INFO: 3,
|
|
25
|
+
WARN: 4,
|
|
26
|
+
ERROR: 5,
|
|
27
|
+
FATAL: 6,
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
const DEFAULT_LOG_LEVEL = "INFO";
|
|
31
|
+
|
|
32
|
+
function resolveMinLevel(): number {
|
|
33
|
+
const env = process.env.OPENCLAW_LOG_LEVEL?.toUpperCase();
|
|
34
|
+
if (env && env in LEVEL_IDS) return LEVEL_IDS[env];
|
|
35
|
+
return LEVEL_IDS[DEFAULT_LOG_LEVEL];
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
let minLevelId = resolveMinLevel();
|
|
39
|
+
|
|
40
|
+
/** Dynamically change the minimum log level at runtime. */
|
|
41
|
+
export function setLogLevel(level: string): void {
|
|
42
|
+
const upper = level.toUpperCase();
|
|
43
|
+
if (!(upper in LEVEL_IDS)) {
|
|
44
|
+
throw new Error(`Invalid log level: ${level}. Valid levels: ${Object.keys(LEVEL_IDS).join(", ")}`);
|
|
45
|
+
}
|
|
46
|
+
minLevelId = LEVEL_IDS[upper];
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Shift a Date into local time so toISOString() renders local clock digits. */
|
|
50
|
+
function toLocalISO(now: Date): string {
|
|
51
|
+
const offsetMs = -now.getTimezoneOffset() * 60_000;
|
|
52
|
+
const sign = offsetMs >= 0 ? "+" : "-";
|
|
53
|
+
const abs = Math.abs(now.getTimezoneOffset());
|
|
54
|
+
const offStr = `${sign}${String(Math.floor(abs / 60)).padStart(2, "0")}:${String(abs % 60).padStart(2, "0")}`;
|
|
55
|
+
return new Date(now.getTime() + offsetMs).toISOString().replace("Z", offStr);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function localDateKey(now: Date): string {
|
|
59
|
+
return toLocalISO(now).slice(0, 10);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function resolveMainLogPath(): string {
|
|
63
|
+
const dateKey = localDateKey(new Date());
|
|
64
|
+
return path.join(MAIN_LOG_DIR, `openclaw-${dateKey}.log`);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
let logDirEnsured = false;
|
|
68
|
+
|
|
69
|
+
export type Logger = {
|
|
70
|
+
info(message: string): void;
|
|
71
|
+
debug(message: string): void;
|
|
72
|
+
warn(message: string): void;
|
|
73
|
+
error(message: string): void;
|
|
74
|
+
/** Returns a child logger whose messages are prefixed with `[accountId]`. */
|
|
75
|
+
withAccount(accountId: string): Logger;
|
|
76
|
+
/** Returns the current main log file path. */
|
|
77
|
+
getLogFilePath(): string;
|
|
78
|
+
close(): void;
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
function buildLoggerName(accountId?: string): string {
|
|
82
|
+
return accountId ? `${SUBSYSTEM}/${accountId}` : SUBSYSTEM;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function writeLog(level: string, message: string, accountId?: string): void {
|
|
86
|
+
const levelId = LEVEL_IDS[level] ?? LEVEL_IDS.INFO;
|
|
87
|
+
if (levelId < minLevelId) return;
|
|
88
|
+
|
|
89
|
+
const now = new Date();
|
|
90
|
+
const loggerName = buildLoggerName(accountId);
|
|
91
|
+
const prefixedMessage = accountId ? `[${accountId}] ${message}` : message;
|
|
92
|
+
const entry = JSON.stringify({
|
|
93
|
+
"0": loggerName,
|
|
94
|
+
"1": prefixedMessage,
|
|
95
|
+
_meta: {
|
|
96
|
+
runtime: RUNTIME,
|
|
97
|
+
runtimeVersion: RUNTIME_VERSION,
|
|
98
|
+
hostname: HOSTNAME,
|
|
99
|
+
name: loggerName,
|
|
100
|
+
parentNames: PARENT_NAMES,
|
|
101
|
+
date: now.toISOString(),
|
|
102
|
+
logLevelId: LEVEL_IDS[level] ?? LEVEL_IDS.INFO,
|
|
103
|
+
logLevelName: level,
|
|
104
|
+
},
|
|
105
|
+
time: toLocalISO(now),
|
|
106
|
+
});
|
|
107
|
+
try {
|
|
108
|
+
if (!logDirEnsured) {
|
|
109
|
+
fs.mkdirSync(MAIN_LOG_DIR, { recursive: true });
|
|
110
|
+
logDirEnsured = true;
|
|
111
|
+
}
|
|
112
|
+
fs.appendFileSync(resolveMainLogPath(), `${entry}\n`, "utf-8");
|
|
113
|
+
} catch {
|
|
114
|
+
// Best-effort; never block on logging failures.
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** Creates a logger instance, optionally bound to a specific account. */
|
|
119
|
+
function createLogger(accountId?: string): Logger {
|
|
120
|
+
return {
|
|
121
|
+
info(message: string): void {
|
|
122
|
+
writeLog("INFO", message, accountId);
|
|
123
|
+
},
|
|
124
|
+
debug(message: string): void {
|
|
125
|
+
writeLog("DEBUG", message, accountId);
|
|
126
|
+
},
|
|
127
|
+
warn(message: string): void {
|
|
128
|
+
writeLog("WARN", message, accountId);
|
|
129
|
+
},
|
|
130
|
+
error(message: string): void {
|
|
131
|
+
writeLog("ERROR", message, accountId);
|
|
132
|
+
},
|
|
133
|
+
withAccount(id: string): Logger {
|
|
134
|
+
return createLogger(id);
|
|
135
|
+
},
|
|
136
|
+
getLogFilePath(): string {
|
|
137
|
+
return resolveMainLogPath();
|
|
138
|
+
},
|
|
139
|
+
close(): void {
|
|
140
|
+
// No-op: appendFileSync has no persistent handle to close.
|
|
141
|
+
},
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
export const logger: Logger = createLogger();
|
|
File without changes
|