@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 CHANGED
@@ -1,12 +1,10 @@
1
1
  # openclaw-channel-taidesk
2
2
 
3
- OpenClaw 渠道插件:**Taidesk MQTT 入站**(订阅 `/open/{agentId}`)+ **HTTP 出站**。
3
+ OpenClaw 渠道插件:**Taidesk MQTT 入站**(订阅 **`/open` + agentId**,中间无 `/`)+ **HTTP 出站**。
4
4
 
5
- ## 文档
5
+ ## 技术说明
6
6
 
7
- - [MQTT / HTTP 契约](docs/TAIDESK-WS-CONTRACT.zh-CN.md)
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
- 订阅 topic:**`/open/{agentId}`**。入站 JSON 见契约文档中的 `agent_msg` 示例。
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
  },
@@ -1,6 +1,5 @@
1
1
  {
2
2
  "id": "taidesk",
3
- "version": "0.1.1",
4
3
  "channels": ["taidesk"],
5
4
  "configSchema": {
6
5
  "type": "object",
package/package.json CHANGED
@@ -1,20 +1,24 @@
1
1
  {
2
2
  "name": "@thclouds/openclaw-channel-taidesk",
3
- "version": "0.1.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
- "prepublishOnly": "npm run type-check && npm test"
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
- conversationId: string;
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, conversationId, text } = params;
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
- conversationId,
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 { startTaideskMonitor } from "./monitor/taidesk-monitor.js";
12
- import { sendTaideskText } from "./api/taidesk-outbound.js";
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/{agentId}` + HTTP 出站",
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 <conversationId> required") };
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
- conversationId: to,
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 = startTaideskMonitor({
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
- * OpenClaw 宿主版本校验(日期版本 YYYY.M.DD,如 2026.3.22)。
3
- * 对齐微信插件 openclaw-weixin 的 compat 行为,避免在过低版本主机上误加载。
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
- export const PLUGIN_VERSION = "0.1.1";
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}. Please upgrade OpenClaw.`,
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 "./types.js";
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;
@@ -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
- * @see 用户约定示例
20
+ * 完整形态含 `data.id` / `sessionId` / `userId`;根级发送方回退为 **`agentId`**(兼容旧版根级 `userId`);精简形态仅根级 `agentId` 作会话路由,不作发送方。
15
21
  */
16
- function parseAgentMsgEnvelope(v: Record<string, unknown>): TaideskInboundEvent | null {
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 eventId = d.id != null ? String(d.id) : "";
26
- const sessionId = d.sessionId != null ? String(d.sessionId) : "";
27
- const userId = d.userId != null ? String(d.userId) : "";
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
- conversationId: sessionId,
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 conversationId = typeof v.conversationId === "string" ? v.conversationId : "";
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 || !conversationId || !userId) {
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
- conversationId,
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/taidesk-outbound.js";
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.conversationId,
24
+ To: event.sessionId,
25
25
  MessageSid: event.eventId,
26
26
  Timestamp: event.timestamp,
27
27
  OriginatingChannel: "taidesk",
28
- OriginatingTo: event.conversationId,
28
+ OriginatingTo: event.sessionId,
29
29
  AccountId: accountId,
30
30
  ChatType: "direct",
31
- NativeChannelId: event.conversationId,
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.conversationId },
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.conversationId,
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
- conversationId: event.conversationId,
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 { createTaideskMqttConnection } from "../mqtt/taidesk-mqtt-client.js";
7
- import { processTaideskInbound } from "../messaging/process-inbound.js";
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
- * 替代微信 `monitorWeixinProvider`:MQTT 订阅 `/open/{agentId}`单条处理。
25
+ * Taidesk 入站:MQTT 订阅 `/open`+agentId → 单条处理(对应微信 `monitorWeixinProvider`)。
23
26
  */
24
- export function startTaideskMonitor(opts: MonitorTaideskOpts): { close: () => void } {
27
+ export function monitorTaideskProvider(opts: MonitorTaideskOpts): { close: () => void } {
25
28
  const log = opts.log ?? (() => {});
26
29
  const errLog = opts.errLog ?? log;
27
- const pwd = opts.accountConfig.mqttPassword ?? opts.accountConfig.apiKey ?? opts.accountConfig.token;
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: opts.accountConfig.mqttUsername,
33
- mqttPassword: pwd,
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/${opts.accountConfig.agentId}`,
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/{agentId}` */
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/{agentId}`。
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/${opts.agentId}`;
82
+ const topic = `/open${opts.agentId.trim()}`;
35
83
  const connectUrl = normalizeMqttConnectUrl(opts.mqttUrl);
36
- const client = mqtt.connect(connectUrl, {
37
- username: opts.mqttUsername,
38
- password: opts.mqttPassword,
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
- conversationId: string;
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/{agentId}` */
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;若未设 mqttPassword 也可作 MQTT 密码 */
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();