@voicyclaw/voicyclaw 0.0.2
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/LICENSE +21 -0
- package/README.md +97 -0
- package/index.ts +28 -0
- package/openclaw.plugin.json +9 -0
- package/package.json +76 -0
- package/src/channel.ts +125 -0
- package/src/config.ts +297 -0
- package/src/dispatch.ts +175 -0
- package/src/gateway.ts +218 -0
- package/src/protocol.ts +205 -0
- package/src/runtime.ts +152 -0
- package/src/socket-client.ts +267 -0
- package/tsconfig.json +14 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Yaoshen Luo
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
# VoicyClaw OpenClaw Plugin
|
|
2
|
+
|
|
3
|
+
This package is the standalone OpenClaw plugin for VoicyClaw.
|
|
4
|
+
|
|
5
|
+
Current scope:
|
|
6
|
+
|
|
7
|
+
- registers a real `voicyclaw` channel plugin
|
|
8
|
+
- owns an outbound WebSocket gateway connector
|
|
9
|
+
- performs the VoicyClaw `HELLO` / `WELCOME` handshake
|
|
10
|
+
- forwards final `STT_RESULT` turns into the OpenClaw agent runtime
|
|
11
|
+
- maps OpenClaw block/final replies back into VoicyClaw preview/text events
|
|
12
|
+
- tracks connection status through `voicyclaw.status`
|
|
13
|
+
- keeps a guarded `devEchoReplies` mode for transport smoke tests
|
|
14
|
+
|
|
15
|
+
What is not wired yet:
|
|
16
|
+
|
|
17
|
+
- audio frame handling beyond the protocol skeleton
|
|
18
|
+
- richer non-text payload delivery beyond text plus attachment URLs
|
|
19
|
+
|
|
20
|
+
## Local Development
|
|
21
|
+
|
|
22
|
+
Install dependencies inside this directory. Because this package lives inside
|
|
23
|
+
the main VoicyClaw monorepo but stays intentionally outside the workspace, use
|
|
24
|
+
`--ignore-workspace` for the initial install so pnpm creates the standalone
|
|
25
|
+
lockfile here.
|
|
26
|
+
|
|
27
|
+
```bash
|
|
28
|
+
pnpm install --ignore-workspace
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
Run the local checks for the standalone package:
|
|
32
|
+
|
|
33
|
+
```bash
|
|
34
|
+
pnpm lint
|
|
35
|
+
pnpm test
|
|
36
|
+
pnpm typecheck
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
The test suite includes a wire-level smoke case that covers the minimal
|
|
40
|
+
VoicyClaw workflow:
|
|
41
|
+
|
|
42
|
+
- `HELLO`
|
|
43
|
+
- `WELCOME`
|
|
44
|
+
- `STT_RESULT`
|
|
45
|
+
- `BOT_PREVIEW`
|
|
46
|
+
- `TTS_TEXT`
|
|
47
|
+
|
|
48
|
+
## Link Into OpenClaw
|
|
49
|
+
|
|
50
|
+
```bash
|
|
51
|
+
openclaw plugins install --link /Users/lyshen/Desktop/project/VoicyClaw/extensions/voicyclaw
|
|
52
|
+
openclaw gateway restart
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
## Channel Config
|
|
56
|
+
|
|
57
|
+
Configure the connector under `channels.voicyclaw`.
|
|
58
|
+
|
|
59
|
+
Minimal local example:
|
|
60
|
+
|
|
61
|
+
```json
|
|
62
|
+
{
|
|
63
|
+
"channels": {
|
|
64
|
+
"voicyclaw": {
|
|
65
|
+
"url": "http://127.0.0.1:3001",
|
|
66
|
+
"token": "vc_xxx",
|
|
67
|
+
"channelId": "demo-room",
|
|
68
|
+
"botId": "openclaw-local",
|
|
69
|
+
"displayName": "OpenClaw Local"
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
Important:
|
|
76
|
+
|
|
77
|
+
- `channels.voicyclaw.token` is the VoicyClaw API key issued by the VoicyClaw
|
|
78
|
+
server
|
|
79
|
+
- it is not the same thing as `gateway.auth.token` from OpenClaw
|
|
80
|
+
- if the wrong token is configured, the OpenClaw logs will show
|
|
81
|
+
`AUTH_FAILED Invalid or expired API key.`
|
|
82
|
+
|
|
83
|
+
Optional development-only transport echo:
|
|
84
|
+
|
|
85
|
+
```json
|
|
86
|
+
{
|
|
87
|
+
"channels": {
|
|
88
|
+
"voicyclaw": {
|
|
89
|
+
"devEchoReplies": true
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
When `devEchoReplies` is enabled, the plugin sends a simple echoed `TTS_TEXT`
|
|
96
|
+
reply after a final transcript arrives, bypassing the real OpenClaw dispatch
|
|
97
|
+
path. That is only for isolating transport problems during local debugging.
|
package/index.ts
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
|
|
2
|
+
import { emptyPluginConfigSchema } from "openclaw/plugin-sdk";
|
|
3
|
+
|
|
4
|
+
import { createVoicyClawChannel } from "./src/channel.js";
|
|
5
|
+
import { createVoicyClawRuntime, setVoicyClawRuntime } from "./src/runtime.js";
|
|
6
|
+
|
|
7
|
+
const plugin = {
|
|
8
|
+
id: "voicyclaw",
|
|
9
|
+
name: "VoicyClaw",
|
|
10
|
+
description: "Outbound VoicyClaw channel connector for OpenClaw",
|
|
11
|
+
configSchema: emptyPluginConfigSchema(),
|
|
12
|
+
register(api: OpenClawPluginApi) {
|
|
13
|
+
const runtime = createVoicyClawRuntime();
|
|
14
|
+
|
|
15
|
+
setVoicyClawRuntime(runtime);
|
|
16
|
+
|
|
17
|
+
api.registerChannel({
|
|
18
|
+
plugin: createVoicyClawChannel(runtime, api.runtime.channel),
|
|
19
|
+
});
|
|
20
|
+
api.registerGatewayMethod("voicyclaw.status", ({ respond }) => {
|
|
21
|
+
respond(true, {
|
|
22
|
+
accounts: runtime.listSnapshots(),
|
|
23
|
+
});
|
|
24
|
+
});
|
|
25
|
+
},
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
export default plugin;
|
package/package.json
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@voicyclaw/voicyclaw",
|
|
3
|
+
"version": "0.0.2",
|
|
4
|
+
"private": false,
|
|
5
|
+
"description": "OpenClaw VoicyClaw channel plugin",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"license": "MIT",
|
|
8
|
+
"repository": {
|
|
9
|
+
"type": "git",
|
|
10
|
+
"url": "git+https://github.com/Lyshen/VoicyClaw.git",
|
|
11
|
+
"directory": "extensions/voicyclaw"
|
|
12
|
+
},
|
|
13
|
+
"homepage": "https://github.com/Lyshen/VoicyClaw/tree/main/extensions/voicyclaw",
|
|
14
|
+
"bugs": {
|
|
15
|
+
"url": "https://github.com/Lyshen/VoicyClaw/issues"
|
|
16
|
+
},
|
|
17
|
+
"keywords": [
|
|
18
|
+
"openclaw",
|
|
19
|
+
"plugin",
|
|
20
|
+
"voicyclaw",
|
|
21
|
+
"voice"
|
|
22
|
+
],
|
|
23
|
+
"publishConfig": {
|
|
24
|
+
"access": "public"
|
|
25
|
+
},
|
|
26
|
+
"files": [
|
|
27
|
+
"README.md",
|
|
28
|
+
"index.ts",
|
|
29
|
+
"openclaw.plugin.json",
|
|
30
|
+
"src/channel.ts",
|
|
31
|
+
"src/config.ts",
|
|
32
|
+
"src/dispatch.ts",
|
|
33
|
+
"src/gateway.ts",
|
|
34
|
+
"src/protocol.ts",
|
|
35
|
+
"src/runtime.ts",
|
|
36
|
+
"src/socket-client.ts",
|
|
37
|
+
"tsconfig.json"
|
|
38
|
+
],
|
|
39
|
+
"dependencies": {
|
|
40
|
+
"ws": "^8.19.0"
|
|
41
|
+
},
|
|
42
|
+
"devDependencies": {
|
|
43
|
+
"@biomejs/biome": "^2.4.8",
|
|
44
|
+
"@types/node": "^25.5.0",
|
|
45
|
+
"@types/ws": "^8.18.1",
|
|
46
|
+
"openclaw": "2026.3.2",
|
|
47
|
+
"typescript": "^5.9.3",
|
|
48
|
+
"vitest": "^3.2.4"
|
|
49
|
+
},
|
|
50
|
+
"peerDependencies": {
|
|
51
|
+
"openclaw": ">=2026.3.1"
|
|
52
|
+
},
|
|
53
|
+
"openclaw": {
|
|
54
|
+
"extensions": [
|
|
55
|
+
"./index.ts"
|
|
56
|
+
],
|
|
57
|
+
"channel": {
|
|
58
|
+
"id": "voicyclaw",
|
|
59
|
+
"label": "VoicyClaw",
|
|
60
|
+
"selectionLabel": "VoicyClaw (Outbound Connector)",
|
|
61
|
+
"detailLabel": "VoicyClaw",
|
|
62
|
+
"docsPath": "/channels/voicyclaw",
|
|
63
|
+
"docsLabel": "voicyclaw",
|
|
64
|
+
"blurb": "Connect OpenClaw outward to a VoicyClaw service over WebSocket.",
|
|
65
|
+
"aliases": [
|
|
66
|
+
"voiceclaw"
|
|
67
|
+
],
|
|
68
|
+
"order": 60
|
|
69
|
+
}
|
|
70
|
+
},
|
|
71
|
+
"scripts": {
|
|
72
|
+
"lint": "biome check .",
|
|
73
|
+
"test": "vitest run",
|
|
74
|
+
"typecheck": "tsc --noEmit -p tsconfig.json"
|
|
75
|
+
}
|
|
76
|
+
}
|
package/src/channel.ts
ADDED
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import type { ChannelPlugin, PluginRuntime } from "openclaw/plugin-sdk";
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
DEFAULT_VOICYCLAW_ACCOUNT_ID,
|
|
5
|
+
listVoicyClawAccountIds,
|
|
6
|
+
type ResolvedVoicyClawAccount,
|
|
7
|
+
resolveVoicyClawAccount,
|
|
8
|
+
voicyClawChannelConfigSchema,
|
|
9
|
+
} from "./config.js";
|
|
10
|
+
import { createVoicyClawGatewayAdapter } from "./gateway.js";
|
|
11
|
+
import type { VoicyClawRuntime } from "./runtime.js";
|
|
12
|
+
|
|
13
|
+
export function createVoicyClawChannel(
|
|
14
|
+
runtimeState: VoicyClawRuntime,
|
|
15
|
+
channelRuntime: PluginRuntime["channel"],
|
|
16
|
+
): ChannelPlugin<ResolvedVoicyClawAccount> {
|
|
17
|
+
return {
|
|
18
|
+
id: "voicyclaw",
|
|
19
|
+
meta: {
|
|
20
|
+
id: "voicyclaw",
|
|
21
|
+
label: "VoicyClaw",
|
|
22
|
+
selectionLabel: "VoicyClaw (Outbound Connector)",
|
|
23
|
+
docsPath: "/channels/voicyclaw",
|
|
24
|
+
blurb:
|
|
25
|
+
"Outbound VoicyClaw connector that lets OpenClaw attach to a hosted voice workspace.",
|
|
26
|
+
aliases: ["voiceclaw"],
|
|
27
|
+
},
|
|
28
|
+
capabilities: {
|
|
29
|
+
chatTypes: ["direct"],
|
|
30
|
+
},
|
|
31
|
+
reload: {
|
|
32
|
+
configPrefixes: ["channels.voicyclaw"],
|
|
33
|
+
},
|
|
34
|
+
configSchema: voicyClawChannelConfigSchema,
|
|
35
|
+
config: {
|
|
36
|
+
listAccountIds: (cfg) => listVoicyClawAccountIds(cfg),
|
|
37
|
+
resolveAccount: (cfg, accountId) =>
|
|
38
|
+
resolveVoicyClawAccount(cfg, accountId),
|
|
39
|
+
defaultAccountId: () => DEFAULT_VOICYCLAW_ACCOUNT_ID,
|
|
40
|
+
isEnabled: (account) => account.enabled,
|
|
41
|
+
isConfigured: (account) => account.configured,
|
|
42
|
+
describeAccount: (account) => ({
|
|
43
|
+
accountId: account.accountId,
|
|
44
|
+
name: account.displayName,
|
|
45
|
+
enabled: account.enabled,
|
|
46
|
+
configured: account.configured,
|
|
47
|
+
baseUrl: account.url,
|
|
48
|
+
audience: account.channelId,
|
|
49
|
+
tokenSource: account.token ? "config" : undefined,
|
|
50
|
+
}),
|
|
51
|
+
},
|
|
52
|
+
status: {
|
|
53
|
+
buildAccountSnapshot: ({ account, runtime }) => {
|
|
54
|
+
const tracked = runtimeState.getSnapshot(account.accountId);
|
|
55
|
+
|
|
56
|
+
return {
|
|
57
|
+
accountId: account.accountId,
|
|
58
|
+
name: account.displayName,
|
|
59
|
+
enabled: account.enabled,
|
|
60
|
+
configured: account.configured,
|
|
61
|
+
running: tracked?.running ?? runtime?.running ?? false,
|
|
62
|
+
connected: tracked?.connected ?? runtime?.connected ?? false,
|
|
63
|
+
reconnectAttempts:
|
|
64
|
+
tracked?.reconnectAttempts ?? runtime?.reconnectAttempts ?? 0,
|
|
65
|
+
lastConnectedAt:
|
|
66
|
+
tracked?.lastConnectedAt ?? runtime?.lastConnectedAt ?? null,
|
|
67
|
+
lastDisconnect:
|
|
68
|
+
tracked?.lastDisconnect ?? runtime?.lastDisconnect ?? null,
|
|
69
|
+
lastError: tracked?.lastError ?? runtime?.lastError ?? null,
|
|
70
|
+
lastStartAt: tracked?.lastStartAt ?? runtime?.lastStartAt ?? null,
|
|
71
|
+
lastStopAt: tracked?.lastStopAt ?? runtime?.lastStopAt ?? null,
|
|
72
|
+
lastMessageAt:
|
|
73
|
+
tracked?.lastMessageAt ?? runtime?.lastMessageAt ?? null,
|
|
74
|
+
lastInboundAt:
|
|
75
|
+
tracked?.lastInboundAt ?? runtime?.lastInboundAt ?? null,
|
|
76
|
+
lastOutboundAt:
|
|
77
|
+
tracked?.lastOutboundAt ?? runtime?.lastOutboundAt ?? null,
|
|
78
|
+
baseUrl: account.url,
|
|
79
|
+
audience: account.channelId,
|
|
80
|
+
tokenSource: account.token ? "config" : undefined,
|
|
81
|
+
};
|
|
82
|
+
},
|
|
83
|
+
resolveAccountState: ({ configured, enabled }) => {
|
|
84
|
+
if (!configured) {
|
|
85
|
+
return "not configured";
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
return enabled ? "enabled" : "disabled";
|
|
89
|
+
},
|
|
90
|
+
collectStatusIssues: (accounts) => {
|
|
91
|
+
return accounts.flatMap((account) => {
|
|
92
|
+
const issues = [];
|
|
93
|
+
|
|
94
|
+
if (!account.configured) {
|
|
95
|
+
issues.push({
|
|
96
|
+
channel: "voicyclaw",
|
|
97
|
+
accountId: account.accountId,
|
|
98
|
+
kind: "config" as const,
|
|
99
|
+
message: "VoicyClaw token is missing.",
|
|
100
|
+
fix: "Set channels.voicyclaw.token or channels.voicyclaw.accounts.<id>.token.",
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
if (
|
|
105
|
+
account.enabled &&
|
|
106
|
+
account.configured &&
|
|
107
|
+
!account.connected &&
|
|
108
|
+
account.lastError
|
|
109
|
+
) {
|
|
110
|
+
issues.push({
|
|
111
|
+
channel: "voicyclaw",
|
|
112
|
+
accountId: account.accountId,
|
|
113
|
+
kind: "runtime" as const,
|
|
114
|
+
message: `VoicyClaw connector is disconnected: ${account.lastError}`,
|
|
115
|
+
fix: "Check the VoicyClaw base URL, token, and room id, then restart the gateway.",
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
return issues;
|
|
120
|
+
});
|
|
121
|
+
},
|
|
122
|
+
},
|
|
123
|
+
gateway: createVoicyClawGatewayAdapter(runtimeState, channelRuntime),
|
|
124
|
+
};
|
|
125
|
+
}
|
package/src/config.ts
ADDED
|
@@ -0,0 +1,297 @@
|
|
|
1
|
+
import type { OpenClawConfig } from "openclaw/plugin-sdk";
|
|
2
|
+
|
|
3
|
+
export const DEFAULT_VOICYCLAW_ACCOUNT_ID = "default";
|
|
4
|
+
export const DEFAULT_VOICYCLAW_BASE_URL = "http://127.0.0.1:3001";
|
|
5
|
+
export const DEFAULT_VOICYCLAW_CHANNEL_ID = "default";
|
|
6
|
+
export const DEFAULT_VOICYCLAW_BOT_ID = "openclaw-voicyclaw";
|
|
7
|
+
export const DEFAULT_VOICYCLAW_DISPLAY_NAME = "VoicyClaw Connector";
|
|
8
|
+
export const DEFAULT_CONNECT_TIMEOUT_MS = 10_000;
|
|
9
|
+
export const DEFAULT_RECONNECT_BACKOFF_MS = 5_000;
|
|
10
|
+
export const DEFAULT_HEARTBEAT_INTERVAL_MS = 25_000;
|
|
11
|
+
|
|
12
|
+
export type VoicyClawAccountConfig = {
|
|
13
|
+
enabled?: boolean;
|
|
14
|
+
url?: string;
|
|
15
|
+
token?: string;
|
|
16
|
+
workspaceId?: string;
|
|
17
|
+
channelId?: string;
|
|
18
|
+
botId?: string;
|
|
19
|
+
displayName?: string;
|
|
20
|
+
connectTimeoutMs?: number;
|
|
21
|
+
reconnectBackoffMs?: number;
|
|
22
|
+
heartbeatIntervalMs?: number;
|
|
23
|
+
devEchoReplies?: boolean;
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
export type ResolvedVoicyClawAccount = {
|
|
27
|
+
accountId: string;
|
|
28
|
+
enabled: boolean;
|
|
29
|
+
configured: boolean;
|
|
30
|
+
url: string;
|
|
31
|
+
token?: string;
|
|
32
|
+
workspaceId?: string;
|
|
33
|
+
channelId: string;
|
|
34
|
+
botId: string;
|
|
35
|
+
displayName: string;
|
|
36
|
+
connectTimeoutMs: number;
|
|
37
|
+
reconnectBackoffMs: number;
|
|
38
|
+
heartbeatIntervalMs: number;
|
|
39
|
+
devEchoReplies: boolean;
|
|
40
|
+
config: VoicyClawAccountConfig;
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
const voicyClawAccountProperties = {
|
|
44
|
+
enabled: { type: "boolean" },
|
|
45
|
+
url: { type: "string" },
|
|
46
|
+
token: { type: "string" },
|
|
47
|
+
workspaceId: { type: "string" },
|
|
48
|
+
channelId: { type: "string" },
|
|
49
|
+
botId: { type: "string" },
|
|
50
|
+
displayName: { type: "string" },
|
|
51
|
+
connectTimeoutMs: { type: "integer", minimum: 1000 },
|
|
52
|
+
reconnectBackoffMs: { type: "integer", minimum: 500 },
|
|
53
|
+
heartbeatIntervalMs: { type: "integer", minimum: 1000 },
|
|
54
|
+
devEchoReplies: { type: "boolean" },
|
|
55
|
+
} as const;
|
|
56
|
+
|
|
57
|
+
const voicyClawAccountSchema = {
|
|
58
|
+
type: "object",
|
|
59
|
+
additionalProperties: false,
|
|
60
|
+
properties: voicyClawAccountProperties,
|
|
61
|
+
} as const;
|
|
62
|
+
|
|
63
|
+
export const voicyClawChannelConfigSchema = {
|
|
64
|
+
schema: {
|
|
65
|
+
type: "object",
|
|
66
|
+
additionalProperties: false,
|
|
67
|
+
properties: {
|
|
68
|
+
...voicyClawAccountProperties,
|
|
69
|
+
accounts: {
|
|
70
|
+
type: "object",
|
|
71
|
+
additionalProperties: voicyClawAccountSchema,
|
|
72
|
+
},
|
|
73
|
+
},
|
|
74
|
+
},
|
|
75
|
+
uiHints: {
|
|
76
|
+
enabled: {
|
|
77
|
+
label: "Enabled",
|
|
78
|
+
},
|
|
79
|
+
url: {
|
|
80
|
+
label: "VoicyClaw Base URL",
|
|
81
|
+
placeholder: "https://voice.example.com",
|
|
82
|
+
},
|
|
83
|
+
token: {
|
|
84
|
+
label: "VoicyClaw Token",
|
|
85
|
+
sensitive: true,
|
|
86
|
+
},
|
|
87
|
+
workspaceId: {
|
|
88
|
+
label: "Workspace ID",
|
|
89
|
+
advanced: true,
|
|
90
|
+
},
|
|
91
|
+
channelId: {
|
|
92
|
+
label: "Room / Channel ID",
|
|
93
|
+
},
|
|
94
|
+
botId: {
|
|
95
|
+
label: "Bot ID",
|
|
96
|
+
},
|
|
97
|
+
displayName: {
|
|
98
|
+
label: "Display Name",
|
|
99
|
+
},
|
|
100
|
+
connectTimeoutMs: {
|
|
101
|
+
label: "Connect Timeout (ms)",
|
|
102
|
+
advanced: true,
|
|
103
|
+
},
|
|
104
|
+
reconnectBackoffMs: {
|
|
105
|
+
label: "Reconnect Backoff (ms)",
|
|
106
|
+
advanced: true,
|
|
107
|
+
},
|
|
108
|
+
heartbeatIntervalMs: {
|
|
109
|
+
label: "Heartbeat Interval (ms)",
|
|
110
|
+
advanced: true,
|
|
111
|
+
},
|
|
112
|
+
devEchoReplies: {
|
|
113
|
+
label: "Dev Echo Replies",
|
|
114
|
+
help: "Only for transport smoke tests before real agent dispatch is wired.",
|
|
115
|
+
advanced: true,
|
|
116
|
+
},
|
|
117
|
+
accounts: {
|
|
118
|
+
label: "Accounts",
|
|
119
|
+
advanced: true,
|
|
120
|
+
},
|
|
121
|
+
},
|
|
122
|
+
} as const;
|
|
123
|
+
|
|
124
|
+
export function listVoicyClawAccountIds(cfg: OpenClawConfig) {
|
|
125
|
+
const section = getVoicyClawSection(cfg);
|
|
126
|
+
const accounts = asRecord(section.accounts);
|
|
127
|
+
const accountIds = Object.keys(accounts ?? {}).filter((entry) =>
|
|
128
|
+
entry.trim(),
|
|
129
|
+
);
|
|
130
|
+
|
|
131
|
+
if (accountIds.length > 0) {
|
|
132
|
+
return accountIds.sort();
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
return [DEFAULT_VOICYCLAW_ACCOUNT_ID];
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
export function resolveVoicyClawAccount(
|
|
139
|
+
cfg: OpenClawConfig,
|
|
140
|
+
accountId?: string | null,
|
|
141
|
+
): ResolvedVoicyClawAccount {
|
|
142
|
+
const normalizedAccountId = normalizeAccountId(accountId);
|
|
143
|
+
const section = getVoicyClawSection(cfg);
|
|
144
|
+
const baseConfig = omitAccounts(section);
|
|
145
|
+
const accountConfig = getVoicyClawAccountOverrides(
|
|
146
|
+
section,
|
|
147
|
+
normalizedAccountId,
|
|
148
|
+
);
|
|
149
|
+
const mergedConfig = {
|
|
150
|
+
...baseConfig,
|
|
151
|
+
...accountConfig,
|
|
152
|
+
};
|
|
153
|
+
|
|
154
|
+
const enabled = readBoolean(mergedConfig.enabled, true);
|
|
155
|
+
const url = readString(mergedConfig.url) ?? DEFAULT_VOICYCLAW_BASE_URL;
|
|
156
|
+
const token = readString(mergedConfig.token);
|
|
157
|
+
const workspaceId = readString(mergedConfig.workspaceId);
|
|
158
|
+
const channelId =
|
|
159
|
+
readString(mergedConfig.channelId) ?? DEFAULT_VOICYCLAW_CHANNEL_ID;
|
|
160
|
+
const botId =
|
|
161
|
+
readString(mergedConfig.botId) ?? buildDefaultBotId(normalizedAccountId);
|
|
162
|
+
const displayName =
|
|
163
|
+
readString(mergedConfig.displayName) ??
|
|
164
|
+
buildDefaultDisplayName(normalizedAccountId);
|
|
165
|
+
const connectTimeoutMs = readPositiveInteger(
|
|
166
|
+
mergedConfig.connectTimeoutMs,
|
|
167
|
+
DEFAULT_CONNECT_TIMEOUT_MS,
|
|
168
|
+
);
|
|
169
|
+
const reconnectBackoffMs = readPositiveInteger(
|
|
170
|
+
mergedConfig.reconnectBackoffMs,
|
|
171
|
+
DEFAULT_RECONNECT_BACKOFF_MS,
|
|
172
|
+
);
|
|
173
|
+
const heartbeatIntervalMs = readPositiveInteger(
|
|
174
|
+
mergedConfig.heartbeatIntervalMs,
|
|
175
|
+
DEFAULT_HEARTBEAT_INTERVAL_MS,
|
|
176
|
+
);
|
|
177
|
+
const devEchoReplies = readBoolean(mergedConfig.devEchoReplies, false);
|
|
178
|
+
|
|
179
|
+
return {
|
|
180
|
+
accountId: normalizedAccountId,
|
|
181
|
+
enabled,
|
|
182
|
+
configured: Boolean(token),
|
|
183
|
+
url,
|
|
184
|
+
token,
|
|
185
|
+
workspaceId,
|
|
186
|
+
channelId,
|
|
187
|
+
botId,
|
|
188
|
+
displayName,
|
|
189
|
+
connectTimeoutMs,
|
|
190
|
+
reconnectBackoffMs,
|
|
191
|
+
heartbeatIntervalMs,
|
|
192
|
+
devEchoReplies,
|
|
193
|
+
config: {
|
|
194
|
+
enabled,
|
|
195
|
+
url,
|
|
196
|
+
token,
|
|
197
|
+
workspaceId,
|
|
198
|
+
channelId,
|
|
199
|
+
botId,
|
|
200
|
+
displayName,
|
|
201
|
+
connectTimeoutMs,
|
|
202
|
+
reconnectBackoffMs,
|
|
203
|
+
heartbeatIntervalMs,
|
|
204
|
+
devEchoReplies,
|
|
205
|
+
},
|
|
206
|
+
};
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
export function buildVoicyClawSocketUrl(input: string | undefined) {
|
|
210
|
+
const raw = (input?.trim() || DEFAULT_VOICYCLAW_BASE_URL).trim();
|
|
211
|
+
const withScheme = /^(wss?|https?):\/\//i.test(raw) ? raw : `http://${raw}`;
|
|
212
|
+
const url = new URL(withScheme);
|
|
213
|
+
|
|
214
|
+
if (url.protocol === "http:") {
|
|
215
|
+
url.protocol = "ws:";
|
|
216
|
+
} else if (url.protocol === "https:") {
|
|
217
|
+
url.protocol = "wss:";
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
if (!url.pathname || url.pathname === "/") {
|
|
221
|
+
url.pathname = "/bot/connect";
|
|
222
|
+
} else if (!url.pathname.endsWith("/bot/connect")) {
|
|
223
|
+
url.pathname = `${url.pathname.replace(/\/$/, "")}/bot/connect`;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
url.hash = "";
|
|
227
|
+
return url.toString();
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
function getVoicyClawSection(cfg: OpenClawConfig) {
|
|
231
|
+
return asRecord(cfg.channels?.voicyclaw);
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
function getVoicyClawAccountOverrides(
|
|
235
|
+
section: Record<string, unknown>,
|
|
236
|
+
accountId: string,
|
|
237
|
+
) {
|
|
238
|
+
const accounts = asRecord(section.accounts);
|
|
239
|
+
if (!accounts) {
|
|
240
|
+
return {};
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
return asRecord(accounts[accountId]);
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
function omitAccounts(section: Record<string, unknown>) {
|
|
247
|
+
const { accounts: _accounts, ...rest } = section;
|
|
248
|
+
return rest;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
function buildDefaultBotId(accountId: string) {
|
|
252
|
+
if (accountId === DEFAULT_VOICYCLAW_ACCOUNT_ID) {
|
|
253
|
+
return DEFAULT_VOICYCLAW_BOT_ID;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
return `${DEFAULT_VOICYCLAW_BOT_ID}-${accountId}`;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
function buildDefaultDisplayName(accountId: string) {
|
|
260
|
+
if (accountId === DEFAULT_VOICYCLAW_ACCOUNT_ID) {
|
|
261
|
+
return DEFAULT_VOICYCLAW_DISPLAY_NAME;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
return `${DEFAULT_VOICYCLAW_DISPLAY_NAME} ${accountId}`;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
function normalizeAccountId(accountId?: string | null) {
|
|
268
|
+
const normalized = accountId?.trim();
|
|
269
|
+
return normalized || DEFAULT_VOICYCLAW_ACCOUNT_ID;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
function asRecord(value: unknown): Record<string, unknown> {
|
|
273
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
274
|
+
return {};
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
return value as Record<string, unknown>;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
function readString(value: unknown) {
|
|
281
|
+
if (typeof value !== "string") {
|
|
282
|
+
return undefined;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
const normalized = value.trim();
|
|
286
|
+
return normalized || undefined;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
function readBoolean(value: unknown, fallback: boolean) {
|
|
290
|
+
return typeof value === "boolean" ? value : fallback;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
function readPositiveInteger(value: unknown, fallback: number) {
|
|
294
|
+
return typeof value === "number" && Number.isInteger(value) && value > 0
|
|
295
|
+
? value
|
|
296
|
+
: fallback;
|
|
297
|
+
}
|