@thclouds/openclaw-channel-taidesk 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +49 -0
- package/index.ts +14 -0
- package/openclaw.plugin.json +6 -0
- package/package.json +58 -0
- package/setup-entry.ts +5 -0
- package/src/api/taidesk-outbound.ts +42 -0
- package/src/channel.ts +160 -0
- package/src/config-schema.ts +45 -0
- package/src/config.ts +63 -0
- package/src/dedup.ts +17 -0
- package/src/inbound-handler.ts +88 -0
- package/src/messaging/process-inbound.ts +103 -0
- package/src/monitor/taidesk-monitor.ts +78 -0
- package/src/mqtt/taidesk-mqtt-client.ts +77 -0
- package/src/persistence-store.ts +5 -0
- package/src/runtime.ts +41 -0
- package/src/types.ts +29 -0
package/README.md
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
# openclaw-channel-taidesk
|
|
2
|
+
|
|
3
|
+
OpenClaw 渠道插件:**Taidesk MQTT 入站**(订阅 `/open/{agentId}`)+ **HTTP 出站**。
|
|
4
|
+
|
|
5
|
+
## 文档
|
|
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)
|
|
10
|
+
|
|
11
|
+
## 依赖
|
|
12
|
+
|
|
13
|
+
- Node.js >= 22
|
|
14
|
+
- `openclaw` peer `>=2026.3.22`
|
|
15
|
+
|
|
16
|
+
## 配置示例(`openclaw.json` 片段)
|
|
17
|
+
|
|
18
|
+
```json
|
|
19
|
+
{
|
|
20
|
+
"channels": {
|
|
21
|
+
"taidesk": {
|
|
22
|
+
"accounts": {
|
|
23
|
+
"default": {
|
|
24
|
+
"webhook": "https://console.taidesk.com/api/app-ai/ai/v1/agent/{id}/resp",
|
|
25
|
+
"mqttUrl": "https://console.taidesk.com/ws",
|
|
26
|
+
"agentId": "your-agent-id",
|
|
27
|
+
"apiKey": "your-agent-key",
|
|
28
|
+
"name": "your-agent-name",
|
|
29
|
+
"mqttUsername": "optional",
|
|
30
|
+
"mqttPassword": "optional"
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
订阅 topic:**`/open/{agentId}`**。入站 JSON 见契约文档中的 `agent_msg` 示例。
|
|
39
|
+
|
|
40
|
+
## 脚本
|
|
41
|
+
|
|
42
|
+
| 命令 | 说明 |
|
|
43
|
+
|------|------|
|
|
44
|
+
| `npm run type-check` | TypeScript 检查 |
|
|
45
|
+
| `npm test` | Vitest 单元测试 |
|
|
46
|
+
|
|
47
|
+
## 许可
|
|
48
|
+
|
|
49
|
+
MIT
|
package/index.ts
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { defineChannelPluginEntry } from "openclaw/plugin-sdk/core";
|
|
2
|
+
|
|
3
|
+
import { taideskPlugin } from "./src/channel.js";
|
|
4
|
+
import { setTaideskRuntime } from "./src/runtime.js";
|
|
5
|
+
|
|
6
|
+
export { taideskPlugin, setTaideskRuntime };
|
|
7
|
+
|
|
8
|
+
export default defineChannelPluginEntry({
|
|
9
|
+
id: "taidesk",
|
|
10
|
+
name: "Taidesk Channel",
|
|
11
|
+
description: "Taidesk WebSocket inbound + HTTP outbound",
|
|
12
|
+
plugin: taideskPlugin,
|
|
13
|
+
setRuntime: setTaideskRuntime,
|
|
14
|
+
});
|
package/package.json
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@thclouds/openclaw-channel-taidesk",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "OpenClaw Taidesk channel plugin (MQTT inbound + HTTP outbound)",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"main": "index.ts",
|
|
8
|
+
"files": [
|
|
9
|
+
"index.ts",
|
|
10
|
+
"setup-entry.ts",
|
|
11
|
+
"src/**/*.ts",
|
|
12
|
+
"openclaw.plugin.json"
|
|
13
|
+
],
|
|
14
|
+
"scripts": {
|
|
15
|
+
"type-check": "tsc -p tsconfig.json --noEmit",
|
|
16
|
+
"test": "vitest run",
|
|
17
|
+
"prepublishOnly": "npm run type-check && npm test"
|
|
18
|
+
},
|
|
19
|
+
"publishConfig": {
|
|
20
|
+
"access": "public",
|
|
21
|
+
"registry": "https://registry.npmjs.org/"
|
|
22
|
+
},
|
|
23
|
+
"engines": {
|
|
24
|
+
"node": ">=22"
|
|
25
|
+
},
|
|
26
|
+
"dependencies": {
|
|
27
|
+
"mqtt": "^5.10.0",
|
|
28
|
+
"zod": "^4.3.6"
|
|
29
|
+
},
|
|
30
|
+
"peerDependencies": {
|
|
31
|
+
"openclaw": ">=2026.3.22"
|
|
32
|
+
},
|
|
33
|
+
"devDependencies": {
|
|
34
|
+
"@types/node": "^22.10.0",
|
|
35
|
+
"openclaw": "2026.3.23",
|
|
36
|
+
"typescript": "^5.8.0",
|
|
37
|
+
"vitest": "^3.1.0"
|
|
38
|
+
},
|
|
39
|
+
"openclaw": {
|
|
40
|
+
"extensions": [
|
|
41
|
+
"./index.ts"
|
|
42
|
+
],
|
|
43
|
+
"channel": {
|
|
44
|
+
"id": "taidesk",
|
|
45
|
+
"label": "Taidesk",
|
|
46
|
+
"selectionLabel": "Taidesk (MQTT)",
|
|
47
|
+
"docsPath": "/channels/taidesk",
|
|
48
|
+
"docsLabel": "Taidesk",
|
|
49
|
+
"blurb": "Taidesk channel via MQTT inbound",
|
|
50
|
+
"order": 76
|
|
51
|
+
},
|
|
52
|
+
"install": {
|
|
53
|
+
"npmSpec": "@thclouds/openclaw-channel-taidesk",
|
|
54
|
+
"defaultChoice": "npm",
|
|
55
|
+
"minHostVersion": ">=2026.3.22"
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
}
|
package/setup-entry.ts
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import type { OpenClawConfig } from "openclaw/plugin-sdk/core";
|
|
2
|
+
|
|
3
|
+
import { resolveTaideskAccount } from "../config.js";
|
|
4
|
+
import type { TaideskAccountConfig } from "../types.js";
|
|
5
|
+
|
|
6
|
+
function resolveTaideskWebhookUrl(config: TaideskAccountConfig): string {
|
|
7
|
+
return config.webhook.replaceAll("{id}", config.agentId);
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export async function sendTaideskText(params: {
|
|
11
|
+
cfg: OpenClawConfig;
|
|
12
|
+
accountId: string;
|
|
13
|
+
conversationId: string;
|
|
14
|
+
text: string;
|
|
15
|
+
log?: (m: string) => void;
|
|
16
|
+
errLog?: (m: string) => void;
|
|
17
|
+
}): Promise<{ ok: boolean; status: number; body?: string }> {
|
|
18
|
+
const { cfg, accountId, conversationId, text } = params;
|
|
19
|
+
const log = params.log ?? (() => {});
|
|
20
|
+
const errLog = params.errLog ?? log;
|
|
21
|
+
const { config } = resolveTaideskAccount(cfg, accountId);
|
|
22
|
+
const url = resolveTaideskWebhookUrl(config);
|
|
23
|
+
const headers: Record<string, string> = {
|
|
24
|
+
"Content-Type": "application/json",
|
|
25
|
+
};
|
|
26
|
+
const bearer = config.apiKey ?? config.token;
|
|
27
|
+
if (bearer) {
|
|
28
|
+
headers.Authorization = `Bearer ${bearer}`;
|
|
29
|
+
}
|
|
30
|
+
const body = JSON.stringify({
|
|
31
|
+
conversationId,
|
|
32
|
+
text,
|
|
33
|
+
});
|
|
34
|
+
log(`taidesk outbound POST ${url.toString()} len=${body.length}`);
|
|
35
|
+
const res = await fetch(url, { method: "POST", headers, body });
|
|
36
|
+
const raw = await res.text();
|
|
37
|
+
if (!res.ok) {
|
|
38
|
+
errLog(`taidesk outbound HTTP ${res.status}: ${raw.slice(0, 500)}`);
|
|
39
|
+
return { ok: false, status: res.status, body: raw };
|
|
40
|
+
}
|
|
41
|
+
return { ok: true, status: res.status, body: raw };
|
|
42
|
+
}
|
package/src/channel.ts
ADDED
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
|
|
3
|
+
import type { ChannelGatewayContext, ChannelPlugin } from "openclaw/plugin-sdk";
|
|
4
|
+
import type { OpenClawConfig } from "openclaw/plugin-sdk/core";
|
|
5
|
+
import { buildChannelConfigSchema } from "openclaw/plugin-sdk/core";
|
|
6
|
+
|
|
7
|
+
import { TaideskChannelConfigSchema } from "./config-schema.js";
|
|
8
|
+
import {
|
|
9
|
+
applyTaideskAccountDefaults,
|
|
10
|
+
getTaideskConfig,
|
|
11
|
+
isTaideskAccountConfigured,
|
|
12
|
+
} from "./config.js";
|
|
13
|
+
import { startTaideskMonitor } from "./monitor/taidesk-monitor.js";
|
|
14
|
+
import { sendTaideskText } from "./api/taidesk-outbound.js";
|
|
15
|
+
import type { TaideskAccountConfig } from "./types.js";
|
|
16
|
+
|
|
17
|
+
export type ResolvedTaideskAccount = {
|
|
18
|
+
accountId: string;
|
|
19
|
+
config: TaideskAccountConfig;
|
|
20
|
+
enabled: boolean;
|
|
21
|
+
configured: boolean;
|
|
22
|
+
name?: string | null;
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
function mergeDefaults(account: TaideskAccountConfig | undefined): TaideskAccountConfig {
|
|
26
|
+
if (!account) {
|
|
27
|
+
throw new Error("taidesk: account config missing");
|
|
28
|
+
}
|
|
29
|
+
const merged = applyTaideskAccountDefaults(account);
|
|
30
|
+
return {
|
|
31
|
+
...merged,
|
|
32
|
+
enabled: merged.enabled !== false,
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export const taideskPlugin: ChannelPlugin<ResolvedTaideskAccount> = {
|
|
37
|
+
id: "taidesk",
|
|
38
|
+
meta: {
|
|
39
|
+
id: "taidesk",
|
|
40
|
+
label: "Taidesk",
|
|
41
|
+
selectionLabel: "Taidesk (MQTT)",
|
|
42
|
+
docsPath: "/channels/taidesk",
|
|
43
|
+
blurb: "Taidesk:MQTT 入站 `/open/{agentId}` + HTTP 出站",
|
|
44
|
+
},
|
|
45
|
+
configSchema: buildChannelConfigSchema(TaideskChannelConfigSchema),
|
|
46
|
+
reload: { configPrefixes: ["channels.taidesk"] },
|
|
47
|
+
capabilities: {
|
|
48
|
+
chatTypes: ["direct"],
|
|
49
|
+
reactions: false,
|
|
50
|
+
threads: false,
|
|
51
|
+
media: false,
|
|
52
|
+
nativeCommands: false,
|
|
53
|
+
blockStreaming: false,
|
|
54
|
+
},
|
|
55
|
+
config: {
|
|
56
|
+
listAccountIds: (cfg: OpenClawConfig): string[] => {
|
|
57
|
+
const { accounts } = getTaideskConfig(cfg);
|
|
58
|
+
const ids = Object.keys(accounts);
|
|
59
|
+
return ids.length > 0 ? ids : [];
|
|
60
|
+
},
|
|
61
|
+
resolveAccount: (cfg: OpenClawConfig, accountId?: string | null) => {
|
|
62
|
+
const { accounts } = getTaideskConfig(cfg);
|
|
63
|
+
const id = accountId?.trim() || "default";
|
|
64
|
+
const raw = accounts[id];
|
|
65
|
+
if (!raw) {
|
|
66
|
+
const empty: TaideskAccountConfig = {
|
|
67
|
+
mqttUrl: "",
|
|
68
|
+
agentId: "",
|
|
69
|
+
webhook: "",
|
|
70
|
+
apiKey: "",
|
|
71
|
+
name: "",
|
|
72
|
+
};
|
|
73
|
+
return {
|
|
74
|
+
accountId: id,
|
|
75
|
+
config: empty,
|
|
76
|
+
enabled: false,
|
|
77
|
+
configured: false,
|
|
78
|
+
name: null,
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
const config = mergeDefaults(raw);
|
|
82
|
+
const configured = isTaideskAccountConfigured(config);
|
|
83
|
+
return {
|
|
84
|
+
accountId: id,
|
|
85
|
+
config,
|
|
86
|
+
enabled: config.enabled !== false,
|
|
87
|
+
configured,
|
|
88
|
+
name: config.name ?? null,
|
|
89
|
+
};
|
|
90
|
+
},
|
|
91
|
+
defaultAccountId: () => "default",
|
|
92
|
+
isConfigured: (account: ResolvedTaideskAccount) => account.configured,
|
|
93
|
+
describeAccount: (account: ResolvedTaideskAccount) => ({
|
|
94
|
+
accountId: account.accountId,
|
|
95
|
+
name: account.config?.name ?? "Taidesk",
|
|
96
|
+
enabled: account.enabled,
|
|
97
|
+
configured: account.configured,
|
|
98
|
+
}),
|
|
99
|
+
},
|
|
100
|
+
security: {
|
|
101
|
+
resolveDmPolicy: () => ({
|
|
102
|
+
policy: "open",
|
|
103
|
+
allowFrom: [],
|
|
104
|
+
policyPath: "channels.taidesk.dmPolicy",
|
|
105
|
+
allowFromPath: "channels.taidesk.allowFrom",
|
|
106
|
+
approveHint: "Taidesk: 默认开放会话",
|
|
107
|
+
}),
|
|
108
|
+
},
|
|
109
|
+
outbound: {
|
|
110
|
+
deliveryMode: "direct",
|
|
111
|
+
resolveTarget: ({ to }: { to?: string }) => {
|
|
112
|
+
const trimmed = to?.trim();
|
|
113
|
+
if (!trimmed) {
|
|
114
|
+
return { ok: false as const, error: new Error("taidesk: --to <conversationId> required") };
|
|
115
|
+
}
|
|
116
|
+
return { ok: true as const, to: trimmed };
|
|
117
|
+
},
|
|
118
|
+
sendText: async (ctx) => {
|
|
119
|
+
const { cfg, to, text, accountId } = ctx;
|
|
120
|
+
const id = accountId ?? "default";
|
|
121
|
+
const result = await sendTaideskText({
|
|
122
|
+
cfg,
|
|
123
|
+
accountId: id,
|
|
124
|
+
conversationId: to,
|
|
125
|
+
text,
|
|
126
|
+
log: (m) => {
|
|
127
|
+
console.info(`[taidesk] ${m}`);
|
|
128
|
+
},
|
|
129
|
+
errLog: (m) => {
|
|
130
|
+
console.error(`[taidesk] ${m}`);
|
|
131
|
+
},
|
|
132
|
+
});
|
|
133
|
+
if (!result.ok) {
|
|
134
|
+
throw new Error(result.body ?? `HTTP ${result.status}`);
|
|
135
|
+
}
|
|
136
|
+
return {
|
|
137
|
+
channel: "taidesk",
|
|
138
|
+
messageId: randomUUID(),
|
|
139
|
+
};
|
|
140
|
+
},
|
|
141
|
+
},
|
|
142
|
+
gateway: {
|
|
143
|
+
startAccount: async (ctx: ChannelGatewayContext<ResolvedTaideskAccount>) => {
|
|
144
|
+
const { account, cfg, abortSignal, channelRuntime, log } = ctx;
|
|
145
|
+
if (!isTaideskAccountConfigured(account.config)) {
|
|
146
|
+
throw new Error("taidesk: mqttUrl、agentId、webhook、apiKey 为必填(且需替换默认占位 agentId/apiKey)");
|
|
147
|
+
}
|
|
148
|
+
const mon = startTaideskMonitor({
|
|
149
|
+
cfg,
|
|
150
|
+
accountId: account.accountId,
|
|
151
|
+
accountConfig: account.config,
|
|
152
|
+
channelRuntime,
|
|
153
|
+
abortSignal,
|
|
154
|
+
log: (m) => log?.info?.(m),
|
|
155
|
+
errLog: (m) => log?.error?.(m),
|
|
156
|
+
});
|
|
157
|
+
return { stop: () => mon.close() };
|
|
158
|
+
},
|
|
159
|
+
},
|
|
160
|
+
};
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
|
|
3
|
+
/** 单账号默认(OpenClaw 配置 schema 与运行时合并共用) */
|
|
4
|
+
export const TAIDESK_ACCOUNT_DEFAULTS = {
|
|
5
|
+
webhook: "https://console.taidesk.com/api/app-ai/ai/v1/agent/{id}/resp",
|
|
6
|
+
mqttUrl: "https://console.taidesk.com/ws",
|
|
7
|
+
agentId: "your-agent-id",
|
|
8
|
+
apiKey: "your-agent-key",
|
|
9
|
+
name: "your-agent-name",
|
|
10
|
+
} as const;
|
|
11
|
+
|
|
12
|
+
const MqttUrlSchema = z
|
|
13
|
+
.string()
|
|
14
|
+
.min(1)
|
|
15
|
+
.refine(
|
|
16
|
+
(s) =>
|
|
17
|
+
s.startsWith("mqtt://") ||
|
|
18
|
+
s.startsWith("mqtts://") ||
|
|
19
|
+
s.startsWith("ws://") ||
|
|
20
|
+
s.startsWith("wss://") ||
|
|
21
|
+
s.startsWith("http://") ||
|
|
22
|
+
s.startsWith("https://"),
|
|
23
|
+
"mqttUrl must start with mqtt://, mqtts://, ws://, wss://, http:// or https://",
|
|
24
|
+
);
|
|
25
|
+
|
|
26
|
+
const TaideskAccountSchema = z.object({
|
|
27
|
+
enabled: z.boolean().optional(),
|
|
28
|
+
name: z.string().default(TAIDESK_ACCOUNT_DEFAULTS.name),
|
|
29
|
+
mqttUrl: MqttUrlSchema.default(TAIDESK_ACCOUNT_DEFAULTS.mqttUrl),
|
|
30
|
+
agentId: z.string().min(1).default(TAIDESK_ACCOUNT_DEFAULTS.agentId),
|
|
31
|
+
webhook: z.string().min(1).default(TAIDESK_ACCOUNT_DEFAULTS.webhook),
|
|
32
|
+
apiKey: z.string().default(TAIDESK_ACCOUNT_DEFAULTS.apiKey),
|
|
33
|
+
mqttUsername: z.string().optional(),
|
|
34
|
+
mqttPassword: z.string().optional(),
|
|
35
|
+
mqttClientId: z.string().optional(),
|
|
36
|
+
token: z.string().optional(),
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
export const TaideskChannelConfigSchema = z.object({
|
|
40
|
+
enabled: z.boolean().optional(),
|
|
41
|
+
dmPolicy: z.string().optional(),
|
|
42
|
+
accounts: z.record(z.string(), TaideskAccountSchema).optional(),
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
export type TaideskChannelConfig = z.infer<typeof TaideskChannelConfigSchema>;
|
package/src/config.ts
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import type { OpenClawConfig } from "openclaw/plugin-sdk/core";
|
|
2
|
+
|
|
3
|
+
import { TAIDESK_ACCOUNT_DEFAULTS } from "./config-schema.js";
|
|
4
|
+
import type { TaideskAccountConfig } from "./types.js";
|
|
5
|
+
|
|
6
|
+
export function getTaideskSection(cfg: OpenClawConfig): Record<string, unknown> {
|
|
7
|
+
const ch = cfg.channels as Record<string, unknown> | undefined;
|
|
8
|
+
return (ch?.taidesk as Record<string, unknown>) ?? {};
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function getTaideskConfig(cfg: OpenClawConfig): {
|
|
12
|
+
accounts: Record<string, TaideskAccountConfig>;
|
|
13
|
+
} {
|
|
14
|
+
const raw = getTaideskSection(cfg);
|
|
15
|
+
const accounts = (raw.accounts as Record<string, TaideskAccountConfig>) ?? {};
|
|
16
|
+
return { accounts };
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function resolveTaideskAccount(
|
|
20
|
+
cfg: OpenClawConfig,
|
|
21
|
+
accountId?: string | null,
|
|
22
|
+
): { accountId: string; config: TaideskAccountConfig } {
|
|
23
|
+
const { accounts } = getTaideskConfig(cfg);
|
|
24
|
+
const id = accountId?.trim() || "default";
|
|
25
|
+
const acct = accounts[id];
|
|
26
|
+
if (!acct) {
|
|
27
|
+
throw new Error(`taidesk: unknown accountId=${id}`);
|
|
28
|
+
}
|
|
29
|
+
return { accountId: id, config: applyTaideskAccountDefaults(acct) };
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function applyTaideskAccountDefaults(
|
|
33
|
+
raw: Partial<TaideskAccountConfig> | Record<string, unknown>,
|
|
34
|
+
): TaideskAccountConfig {
|
|
35
|
+
const r = raw as Partial<TaideskAccountConfig>;
|
|
36
|
+
return {
|
|
37
|
+
enabled: r.enabled,
|
|
38
|
+
name: r.name ?? TAIDESK_ACCOUNT_DEFAULTS.name,
|
|
39
|
+
mqttUrl: r.mqttUrl ?? TAIDESK_ACCOUNT_DEFAULTS.mqttUrl,
|
|
40
|
+
agentId: r.agentId ?? TAIDESK_ACCOUNT_DEFAULTS.agentId,
|
|
41
|
+
webhook: r.webhook ?? TAIDESK_ACCOUNT_DEFAULTS.webhook,
|
|
42
|
+
apiKey: r.apiKey ?? TAIDESK_ACCOUNT_DEFAULTS.apiKey,
|
|
43
|
+
mqttUsername: r.mqttUsername,
|
|
44
|
+
mqttPassword: r.mqttPassword,
|
|
45
|
+
mqttClientId: r.mqttClientId,
|
|
46
|
+
token: r.token,
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function isTaideskAccountConfigured(account: TaideskAccountConfig): boolean {
|
|
51
|
+
if (
|
|
52
|
+
!account.webhook?.trim() ||
|
|
53
|
+
!account.mqttUrl?.trim() ||
|
|
54
|
+
!account.agentId?.trim() ||
|
|
55
|
+
!account.apiKey?.trim()
|
|
56
|
+
) {
|
|
57
|
+
return false;
|
|
58
|
+
}
|
|
59
|
+
const stillPlaceholderCreds =
|
|
60
|
+
account.agentId === TAIDESK_ACCOUNT_DEFAULTS.agentId ||
|
|
61
|
+
account.apiKey === TAIDESK_ACCOUNT_DEFAULTS.apiKey;
|
|
62
|
+
return !stillPlaceholderCreds;
|
|
63
|
+
}
|
package/src/dedup.ts
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/** 单进程内存去重,TTL 毫秒 */
|
|
2
|
+
const seen = new Map<string, number>();
|
|
3
|
+
|
|
4
|
+
export function shouldSkipDedup(key: string, ttlMs: number): boolean {
|
|
5
|
+
const now = Date.now();
|
|
6
|
+
const exp = seen.get(key);
|
|
7
|
+
if (exp !== undefined && exp > now) {
|
|
8
|
+
return true;
|
|
9
|
+
}
|
|
10
|
+
seen.set(key, now + ttlMs);
|
|
11
|
+
for (const [k, t] of seen) {
|
|
12
|
+
if (t < now) {
|
|
13
|
+
seen.delete(k);
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
return false;
|
|
17
|
+
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import type { TaideskInboundEvent } from "./types.js";
|
|
2
|
+
|
|
3
|
+
function parseTaideskCreateTimeMs(createTime: string): number {
|
|
4
|
+
if (!createTime.trim()) {
|
|
5
|
+
return Date.now();
|
|
6
|
+
}
|
|
7
|
+
const normalized = createTime.includes("T") ? createTime : createTime.replace(" ", "T");
|
|
8
|
+
const t = Date.parse(normalized);
|
|
9
|
+
return Number.isNaN(t) ? Date.now() : t;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Taidesk MQTT 载荷:`event: agent_msg`,`data` 为业务体。
|
|
14
|
+
* @see 用户约定示例
|
|
15
|
+
*/
|
|
16
|
+
function parseAgentMsgEnvelope(v: Record<string, unknown>): TaideskInboundEvent | null {
|
|
17
|
+
if (v.event !== "agent_msg") {
|
|
18
|
+
return null;
|
|
19
|
+
}
|
|
20
|
+
const data = v.data;
|
|
21
|
+
if (!data || typeof data !== "object") {
|
|
22
|
+
return null;
|
|
23
|
+
}
|
|
24
|
+
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) : "";
|
|
28
|
+
const content = d.content;
|
|
29
|
+
let text = "";
|
|
30
|
+
if (content && typeof content === "object") {
|
|
31
|
+
const c = content as Record<string, unknown>;
|
|
32
|
+
if (typeof c.text === "string") {
|
|
33
|
+
text = c.text;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
const tenantId = typeof d.tenantId === "string" ? d.tenantId : undefined;
|
|
37
|
+
const createTime = typeof d.createTime === "string" ? d.createTime : "";
|
|
38
|
+
const timestamp = parseTaideskCreateTimeMs(createTime);
|
|
39
|
+
if (!eventId || !sessionId || !userId) {
|
|
40
|
+
return null;
|
|
41
|
+
}
|
|
42
|
+
return {
|
|
43
|
+
type: "agent_msg",
|
|
44
|
+
eventId,
|
|
45
|
+
conversationId: sessionId,
|
|
46
|
+
tenantId,
|
|
47
|
+
userId,
|
|
48
|
+
text,
|
|
49
|
+
timestamp,
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** 兼容旧版扁平 JSON(测试/迁移) */
|
|
54
|
+
function parseLegacyFlat(v: Record<string, unknown>): TaideskInboundEvent | null {
|
|
55
|
+
if (v.type === "ping" || v.type === "pong") {
|
|
56
|
+
return null;
|
|
57
|
+
}
|
|
58
|
+
const eventId = typeof v.eventId === "string" ? v.eventId : "";
|
|
59
|
+
const conversationId = typeof v.conversationId === "string" ? v.conversationId : "";
|
|
60
|
+
const userId = typeof v.userId === "string" ? v.userId : "";
|
|
61
|
+
const text = typeof v.text === "string" ? v.text : "";
|
|
62
|
+
const timestamp = typeof v.timestamp === "number" ? v.timestamp : Date.now();
|
|
63
|
+
if (!eventId || !conversationId || !userId) {
|
|
64
|
+
return null;
|
|
65
|
+
}
|
|
66
|
+
return {
|
|
67
|
+
type: typeof v.type === "string" ? v.type : "message",
|
|
68
|
+
eventId,
|
|
69
|
+
conversationId,
|
|
70
|
+
tenantId: typeof v.tenantId === "string" ? v.tenantId : undefined,
|
|
71
|
+
userId,
|
|
72
|
+
text,
|
|
73
|
+
timestamp,
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export function parseTaideskInboundJson(raw: string): TaideskInboundEvent | null {
|
|
78
|
+
try {
|
|
79
|
+
const v = JSON.parse(raw) as Record<string, unknown>;
|
|
80
|
+
const agent = parseAgentMsgEnvelope(v);
|
|
81
|
+
if (agent) {
|
|
82
|
+
return agent;
|
|
83
|
+
}
|
|
84
|
+
return parseLegacyFlat(v);
|
|
85
|
+
} catch {
|
|
86
|
+
return null;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import type { OpenClawConfig } from "openclaw/plugin-sdk/core";
|
|
2
|
+
import type { PluginRuntime } from "openclaw/plugin-sdk/core";
|
|
3
|
+
import { createTypingCallbacks } from "openclaw/plugin-sdk/channel-runtime";
|
|
4
|
+
import type { MsgContext } from "openclaw/plugin-sdk/reply-runtime";
|
|
5
|
+
|
|
6
|
+
import { sendTaideskText } from "../api/taidesk-outbound.js";
|
|
7
|
+
import type { TaideskInboundEvent } from "../types.js";
|
|
8
|
+
|
|
9
|
+
export async function processTaideskInbound(params: {
|
|
10
|
+
event: TaideskInboundEvent;
|
|
11
|
+
cfg: OpenClawConfig;
|
|
12
|
+
accountId: string;
|
|
13
|
+
channelRuntime: PluginRuntime["channel"];
|
|
14
|
+
log?: (m: string) => void;
|
|
15
|
+
errLog?: (m: string) => void;
|
|
16
|
+
}): Promise<void> {
|
|
17
|
+
const { event, cfg, accountId, channelRuntime } = params;
|
|
18
|
+
const log = params.log ?? (() => {});
|
|
19
|
+
const errLog = params.errLog ?? log;
|
|
20
|
+
|
|
21
|
+
const ctx: MsgContext = {
|
|
22
|
+
Body: event.text ?? "",
|
|
23
|
+
From: event.userId,
|
|
24
|
+
To: event.conversationId,
|
|
25
|
+
MessageSid: event.eventId,
|
|
26
|
+
Timestamp: event.timestamp,
|
|
27
|
+
OriginatingChannel: "taidesk",
|
|
28
|
+
OriginatingTo: event.conversationId,
|
|
29
|
+
AccountId: accountId,
|
|
30
|
+
ChatType: "direct",
|
|
31
|
+
NativeChannelId: event.conversationId,
|
|
32
|
+
CommandAuthorized: true,
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
const route = channelRuntime.routing.resolveAgentRoute({
|
|
36
|
+
cfg,
|
|
37
|
+
channel: "taidesk",
|
|
38
|
+
accountId,
|
|
39
|
+
peer: { kind: "direct", id: event.conversationId },
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
ctx.SessionKey = route.sessionKey;
|
|
43
|
+
|
|
44
|
+
const finalized = channelRuntime.reply.finalizeInboundContext(ctx);
|
|
45
|
+
|
|
46
|
+
const storePath = channelRuntime.session.resolveStorePath(cfg.session?.store, {
|
|
47
|
+
agentId: route.agentId,
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
await channelRuntime.session.recordInboundSession({
|
|
51
|
+
storePath,
|
|
52
|
+
sessionKey: route.sessionKey,
|
|
53
|
+
ctx: finalized as Parameters<typeof channelRuntime.session.recordInboundSession>[0]["ctx"],
|
|
54
|
+
updateLastRoute: {
|
|
55
|
+
sessionKey: route.mainSessionKey,
|
|
56
|
+
channel: "taidesk",
|
|
57
|
+
to: event.conversationId,
|
|
58
|
+
accountId,
|
|
59
|
+
},
|
|
60
|
+
onRecordError: (err) => errLog(`recordInboundSession: ${String(err)}`),
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
const humanDelay = channelRuntime.reply.resolveHumanDelayConfig(cfg, route.agentId);
|
|
64
|
+
|
|
65
|
+
const typingCallbacks = createTypingCallbacks({
|
|
66
|
+
start: async () => {},
|
|
67
|
+
stop: async () => {},
|
|
68
|
+
onStartError: () => {},
|
|
69
|
+
onStopError: () => {},
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
const { dispatcher, replyOptions, markDispatchIdle } =
|
|
73
|
+
channelRuntime.reply.createReplyDispatcherWithTyping({
|
|
74
|
+
humanDelay,
|
|
75
|
+
typingCallbacks,
|
|
76
|
+
deliver: async (payload) => {
|
|
77
|
+
const text = payload.text ?? "";
|
|
78
|
+
await sendTaideskText({
|
|
79
|
+
cfg,
|
|
80
|
+
accountId,
|
|
81
|
+
conversationId: event.conversationId,
|
|
82
|
+
text,
|
|
83
|
+
log,
|
|
84
|
+
errLog,
|
|
85
|
+
});
|
|
86
|
+
},
|
|
87
|
+
onError: (err, info) => {
|
|
88
|
+
errLog(`taidesk reply ${info.kind}: ${String(err)}`);
|
|
89
|
+
},
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
await channelRuntime.reply.withReplyDispatcher({
|
|
93
|
+
dispatcher,
|
|
94
|
+
run: () =>
|
|
95
|
+
channelRuntime.reply.dispatchReplyFromConfig({
|
|
96
|
+
ctx: finalized,
|
|
97
|
+
cfg,
|
|
98
|
+
dispatcher,
|
|
99
|
+
replyOptions: { ...replyOptions, disableBlockStreaming: false },
|
|
100
|
+
}),
|
|
101
|
+
});
|
|
102
|
+
markDispatchIdle();
|
|
103
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import type { OpenClawConfig } from "openclaw/plugin-sdk/core";
|
|
2
|
+
import type { PluginRuntime } from "openclaw/plugin-sdk/core";
|
|
3
|
+
|
|
4
|
+
import { shouldSkipDedup } from "../dedup.js";
|
|
5
|
+
import { parseTaideskInboundJson } from "../inbound-handler.js";
|
|
6
|
+
import { createTaideskMqttConnection } from "../mqtt/taidesk-mqtt-client.js";
|
|
7
|
+
import { processTaideskInbound } from "../messaging/process-inbound.js";
|
|
8
|
+
import { resolveTaideskChannelRuntime } from "../runtime.js";
|
|
9
|
+
import type { TaideskAccountConfig } from "../types.js";
|
|
10
|
+
|
|
11
|
+
export type MonitorTaideskOpts = {
|
|
12
|
+
cfg: OpenClawConfig;
|
|
13
|
+
accountId: string;
|
|
14
|
+
accountConfig: TaideskAccountConfig;
|
|
15
|
+
channelRuntime?: PluginRuntime["channel"];
|
|
16
|
+
abortSignal: AbortSignal;
|
|
17
|
+
log?: (m: string) => void;
|
|
18
|
+
errLog?: (m: string) => void;
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* 替代微信 `monitorWeixinProvider`:MQTT 订阅 `/open/{agentId}` → 单条处理。
|
|
23
|
+
*/
|
|
24
|
+
export function startTaideskMonitor(opts: MonitorTaideskOpts): { close: () => void } {
|
|
25
|
+
const log = opts.log ?? (() => {});
|
|
26
|
+
const errLog = opts.errLog ?? log;
|
|
27
|
+
const pwd = opts.accountConfig.mqttPassword ?? opts.accountConfig.apiKey ?? opts.accountConfig.token;
|
|
28
|
+
|
|
29
|
+
const conn = createTaideskMqttConnection({
|
|
30
|
+
mqttUrl: opts.accountConfig.mqttUrl,
|
|
31
|
+
agentId: opts.accountConfig.agentId,
|
|
32
|
+
mqttUsername: opts.accountConfig.mqttUsername,
|
|
33
|
+
mqttPassword: pwd,
|
|
34
|
+
clientId: opts.accountConfig.mqttClientId,
|
|
35
|
+
abortSignal: opts.abortSignal,
|
|
36
|
+
onConnect: () => {
|
|
37
|
+
log(
|
|
38
|
+
`taidesk mqtt connected account=${opts.accountId} topic=/open/${opts.accountConfig.agentId}`,
|
|
39
|
+
);
|
|
40
|
+
},
|
|
41
|
+
onMessage: (_topic, raw) => {
|
|
42
|
+
void (async () => {
|
|
43
|
+
const ev = parseTaideskInboundJson(raw);
|
|
44
|
+
if (!ev) {
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
const dedupKey = `${opts.accountId}:${ev.eventId}`;
|
|
48
|
+
if (shouldSkipDedup(dedupKey, 120_000)) {
|
|
49
|
+
log(`taidesk dedup skip eventId=${ev.eventId}`);
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
try {
|
|
53
|
+
const channelRuntime = await resolveTaideskChannelRuntime({
|
|
54
|
+
channelRuntime: opts.channelRuntime,
|
|
55
|
+
});
|
|
56
|
+
await processTaideskInbound({
|
|
57
|
+
event: ev,
|
|
58
|
+
cfg: opts.cfg,
|
|
59
|
+
accountId: opts.accountId,
|
|
60
|
+
channelRuntime,
|
|
61
|
+
log,
|
|
62
|
+
errLog,
|
|
63
|
+
});
|
|
64
|
+
} catch (e) {
|
|
65
|
+
errLog(`taidesk process inbound: ${String(e)}`);
|
|
66
|
+
}
|
|
67
|
+
})();
|
|
68
|
+
},
|
|
69
|
+
onClose: () => {
|
|
70
|
+
log(`taidesk mqtt close account=${opts.accountId}`);
|
|
71
|
+
},
|
|
72
|
+
onError: (err) => {
|
|
73
|
+
errLog(`taidesk mqtt error: ${err.message}`);
|
|
74
|
+
},
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
return conn;
|
|
78
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import mqtt from "mqtt";
|
|
2
|
+
|
|
3
|
+
/** Taidesk 控制台常用 https://host/ws,mqtt.js WebSocket 需 wss:// */
|
|
4
|
+
export function normalizeMqttConnectUrl(mqttUrl: string): string {
|
|
5
|
+
if (mqttUrl.startsWith("https://")) {
|
|
6
|
+
return `wss://${mqttUrl.slice("https://".length)}`;
|
|
7
|
+
}
|
|
8
|
+
if (mqttUrl.startsWith("http://")) {
|
|
9
|
+
return `ws://${mqttUrl.slice("http://".length)}`;
|
|
10
|
+
}
|
|
11
|
+
return mqttUrl;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export type TaideskMqttClientOptions = {
|
|
15
|
+
/** 例如 mqtt://、mqtts://、ws://、wss://(MQTT over WebSocket);https/http 会规范为 wss/ws */
|
|
16
|
+
mqttUrl: string;
|
|
17
|
+
/** 订阅 topic:`/open/{agentId}` */
|
|
18
|
+
agentId: string;
|
|
19
|
+
mqttUsername?: string;
|
|
20
|
+
mqttPassword?: string;
|
|
21
|
+
clientId?: string;
|
|
22
|
+
onMessage: (topic: string, payloadUtf8: string) => void;
|
|
23
|
+
onConnect?: () => void;
|
|
24
|
+
onClose?: () => void;
|
|
25
|
+
onError?: (err: Error) => void;
|
|
26
|
+
abortSignal?: AbortSignal;
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Taidesk 入站:MQTT 订阅 `/open/{agentId}`。
|
|
31
|
+
* 重连由 mqtt.js 内置 `reconnectPeriod` 处理。
|
|
32
|
+
*/
|
|
33
|
+
export function createTaideskMqttConnection(opts: TaideskMqttClientOptions): { close: () => void } {
|
|
34
|
+
const topic = `/open/${opts.agentId}`;
|
|
35
|
+
const connectUrl = normalizeMqttConnectUrl(opts.mqttUrl);
|
|
36
|
+
const client = mqtt.connect(connectUrl, {
|
|
37
|
+
username: opts.mqttUsername,
|
|
38
|
+
password: opts.mqttPassword,
|
|
39
|
+
clientId: opts.clientId ?? `openclaw-taidesk-${Date.now()}`,
|
|
40
|
+
reconnectPeriod: 5_000,
|
|
41
|
+
connectTimeout: 30_000,
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
const onAbort = (): void => {
|
|
45
|
+
client.end(true);
|
|
46
|
+
};
|
|
47
|
+
opts.abortSignal?.addEventListener("abort", onAbort);
|
|
48
|
+
|
|
49
|
+
client.on("connect", () => {
|
|
50
|
+
client.subscribe(topic, { qos: 0 }, (err) => {
|
|
51
|
+
if (err) {
|
|
52
|
+
opts.onError?.(err);
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
opts.onConnect?.();
|
|
56
|
+
});
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
client.on("message", (t, payload) => {
|
|
60
|
+
opts.onMessage(t, payload.toString("utf8"));
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
client.on("error", (err) => {
|
|
64
|
+
opts.onError?.(err);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
client.on("close", () => {
|
|
68
|
+
opts.onClose?.();
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
return {
|
|
72
|
+
close: () => {
|
|
73
|
+
opts.abortSignal?.removeEventListener("abort", onAbort);
|
|
74
|
+
client.end(true);
|
|
75
|
+
},
|
|
76
|
+
};
|
|
77
|
+
}
|
package/src/runtime.ts
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import type { PluginRuntime } from "openclaw/plugin-sdk/core";
|
|
2
|
+
|
|
3
|
+
let pluginRuntime: PluginRuntime | null = null;
|
|
4
|
+
|
|
5
|
+
export function setTaideskRuntime(next: PluginRuntime): void {
|
|
6
|
+
pluginRuntime = next;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function getTaideskRuntime(): PluginRuntime {
|
|
10
|
+
if (!pluginRuntime) {
|
|
11
|
+
throw new Error("Taidesk runtime not initialized");
|
|
12
|
+
}
|
|
13
|
+
return pluginRuntime;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const WAIT_INTERVAL_MS = 100;
|
|
17
|
+
const DEFAULT_TIMEOUT_MS = 60_000;
|
|
18
|
+
|
|
19
|
+
export async function waitForTaideskRuntime(timeoutMs = DEFAULT_TIMEOUT_MS): Promise<PluginRuntime> {
|
|
20
|
+
const start = Date.now();
|
|
21
|
+
while (!pluginRuntime) {
|
|
22
|
+
if (Date.now() - start > timeoutMs) {
|
|
23
|
+
throw new Error("Taidesk runtime initialization timeout");
|
|
24
|
+
}
|
|
25
|
+
await new Promise((resolve) => setTimeout(resolve, WAIT_INTERVAL_MS));
|
|
26
|
+
}
|
|
27
|
+
return pluginRuntime;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export type TaideskChannelRuntime = PluginRuntime["channel"];
|
|
31
|
+
|
|
32
|
+
export async function resolveTaideskChannelRuntime(params: {
|
|
33
|
+
channelRuntime?: TaideskChannelRuntime;
|
|
34
|
+
waitTimeoutMs?: number;
|
|
35
|
+
}): Promise<TaideskChannelRuntime> {
|
|
36
|
+
if (params.channelRuntime) {
|
|
37
|
+
return params.channelRuntime;
|
|
38
|
+
}
|
|
39
|
+
const pr = await waitForTaideskRuntime(params.waitTimeoutMs ?? DEFAULT_TIMEOUT_MS);
|
|
40
|
+
return pr.channel;
|
|
41
|
+
}
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Taidesk ↔ 插件入站载荷(MQTT JSON,见 docs/TAIDESK-WS-CONTRACT.zh-CN.md)。
|
|
3
|
+
*/
|
|
4
|
+
export type TaideskInboundEvent = {
|
|
5
|
+
type: string;
|
|
6
|
+
eventId: string;
|
|
7
|
+
conversationId: string;
|
|
8
|
+
tenantId?: string;
|
|
9
|
+
userId: string;
|
|
10
|
+
text?: string;
|
|
11
|
+
timestamp: number;
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
export type TaideskAccountConfig = {
|
|
15
|
+
enabled?: boolean;
|
|
16
|
+
name?: string;
|
|
17
|
+
/** MQTT Broker,如 mqtts://host:8883、wss://host/mqtt;https:// 会在连接前规范为 wss:// */
|
|
18
|
+
mqttUrl: string;
|
|
19
|
+
/** 订阅 topic `/open/{agentId}` */
|
|
20
|
+
agentId: string;
|
|
21
|
+
/** 出站 Webhook 完整 URL;可用 `{id}` 占位,发送前替换为 agentId */
|
|
22
|
+
webhook: string;
|
|
23
|
+
apiKey: string;
|
|
24
|
+
mqttUsername?: string;
|
|
25
|
+
mqttPassword?: string;
|
|
26
|
+
mqttClientId?: string;
|
|
27
|
+
/** HTTP 出站 Bearer;若未设 mqttPassword 也可作 MQTT 密码 */
|
|
28
|
+
token?: string;
|
|
29
|
+
};
|