@thclouds/openclaw-channel-taidesk 0.1.2 → 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
@@ -2,11 +2,9 @@
2
2
 
3
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
 
36
+ **MQTT 鉴权**:未配置 `mqttUsername` 且未显式配置 `mqttPassword` 时为**匿名连接**(`apiKey` 仅用于 HTTP 出站,不会当作 MQTT 密码)。若 Broker 要求用户名/密码,请配置 `mqttUsername` 或 `mqttPassword`。
37
+
38
38
  订阅 topic:**`/open` + `agentId`**(例:`agentId` 为 `2014147198056767490` 时 topic 为 `/open2014147198056767490`)。入站 JSON 见契约文档中的 `agent_msg` 示例。
39
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。
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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@thclouds/openclaw-channel-taidesk",
3
- "version": "0.1.2",
3
+ "version": "0.1.3",
4
4
  "description": "OpenClaw Taidesk channel plugin (MQTT inbound + HTTP outbound)",
5
5
  "license": "MIT",
6
6
  "author": "thclouds",
@@ -17,6 +17,7 @@
17
17
  "type-check": "tsc -p tsconfig.json --noEmit",
18
18
  "typecheck": "tsc -p tsconfig.json --noEmit",
19
19
  "test": "vitest run",
20
+ "smoke:mqtt": "tsx tests/live/taidesk-mqtt-smoke.ts",
20
21
  "prepublishOnly": "npm run typecheck && npm test"
21
22
  },
22
23
  "publishConfig": {
@@ -36,6 +37,7 @@
36
37
  "devDependencies": {
37
38
  "@types/node": "^22.10.0",
38
39
  "openclaw": "2026.3.23",
40
+ "tsx": "^4.19.0",
39
41
  "typescript": "^5.8.0",
40
42
  "vitest": "^3.1.0"
41
43
  },
package/src/api/api.ts CHANGED
@@ -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
@@ -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}`);
@@ -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
  }
@@ -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,7 +3,10 @@ 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";
6
+ import {
7
+ buildMqttConnectAuth,
8
+ createTaideskMqttConnection,
9
+ } from "../mqtt/taidesk-mqtt-client.js";
7
10
  import { processTaideskInbound } from "../messaging/process-message.js";
8
11
  import { resolveTaideskChannelRuntime } from "../runtime.js";
9
12
  import type { TaideskAccountConfig } from "../types.js";
@@ -24,13 +27,16 @@ export type MonitorTaideskOpts = {
24
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: () => {
@@ -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 {
@@ -16,6 +58,7 @@ export type TaideskMqttClientOptions = {
16
58
  mqttUrl: string;
17
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,6 +67,11 @@ 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
  /**
@@ -33,13 +81,25 @@ export type TaideskMqttClientOptions = {
33
81
  export function createTaideskMqttConnection(opts: TaideskMqttClientOptions): { close: () => void } {
34
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;
@@ -21,9 +22,11 @@ export type TaideskAccountConfig = {
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
  };