@vauxr/openclaw 2026.4.1-2.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/.github/workflows/publish.yml +89 -0
- package/LICENSE +21 -0
- package/README.md +149 -0
- package/index.ts +68 -0
- package/openclaw.plugin.json +57 -0
- package/package.json +29 -0
- package/setup-entry.ts +4 -0
- package/src/api-client.ts +76 -0
- package/src/bridge.ts +237 -0
- package/src/channel.ts +107 -0
- package/src/defaults.ts +2 -0
- package/src/tools.ts +102 -0
- package/tsconfig.json +14 -0
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
name: Publish to npm + ClawHub
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
branches: [main]
|
|
6
|
+
release:
|
|
7
|
+
types: [published]
|
|
8
|
+
|
|
9
|
+
jobs:
|
|
10
|
+
# On push to main: compute version and create a draft release
|
|
11
|
+
draft-release:
|
|
12
|
+
if: github.event_name == 'push'
|
|
13
|
+
runs-on: ubuntu-latest
|
|
14
|
+
permissions:
|
|
15
|
+
contents: write
|
|
16
|
+
|
|
17
|
+
steps:
|
|
18
|
+
- uses: actions/checkout@v4
|
|
19
|
+
|
|
20
|
+
- name: Compute date-based version
|
|
21
|
+
id: version
|
|
22
|
+
env:
|
|
23
|
+
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
24
|
+
run: |
|
|
25
|
+
TODAY=$(date -u +"%Y.%m.%d")
|
|
26
|
+
|
|
27
|
+
EXISTING=$(gh api repos/${{ github.repository }}/git/refs/tags \
|
|
28
|
+
--paginate --jq '.[].ref' 2>/dev/null \
|
|
29
|
+
| grep "^refs/tags/v${TODAY}\." || true)
|
|
30
|
+
|
|
31
|
+
if [ -z "$EXISTING" ]; then
|
|
32
|
+
N=0
|
|
33
|
+
else
|
|
34
|
+
MAX_N=$(echo "$EXISTING" | sed "s|refs/tags/v${TODAY}\.||" | sort -n | tail -1)
|
|
35
|
+
N=$((MAX_N + 1))
|
|
36
|
+
fi
|
|
37
|
+
|
|
38
|
+
VERSION="${TODAY}.${N}"
|
|
39
|
+
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
|
|
40
|
+
echo "Computed version: $VERSION"
|
|
41
|
+
|
|
42
|
+
- name: Create draft release
|
|
43
|
+
env:
|
|
44
|
+
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
45
|
+
run: |
|
|
46
|
+
gh release create "v${{ steps.version.outputs.version }}" \
|
|
47
|
+
--title "v${{ steps.version.outputs.version }}" \
|
|
48
|
+
--draft \
|
|
49
|
+
--notes "## What's Changed\n\n<!-- Add release notes here before publishing -->"
|
|
50
|
+
|
|
51
|
+
# On release published: publish to npm
|
|
52
|
+
publish:
|
|
53
|
+
if: github.event_name == 'release'
|
|
54
|
+
runs-on: ubuntu-latest
|
|
55
|
+
environment: Production
|
|
56
|
+
permissions:
|
|
57
|
+
contents: write
|
|
58
|
+
|
|
59
|
+
steps:
|
|
60
|
+
- uses: actions/checkout@v4
|
|
61
|
+
|
|
62
|
+
- uses: actions/setup-node@v4
|
|
63
|
+
with:
|
|
64
|
+
node-version: "20"
|
|
65
|
+
registry-url: "https://registry.npmjs.org"
|
|
66
|
+
|
|
67
|
+
- name: Extract version from tag
|
|
68
|
+
id: version
|
|
69
|
+
run: |
|
|
70
|
+
VERSION="${{ github.event.release.tag_name }}"
|
|
71
|
+
VERSION="${VERSION#v}"
|
|
72
|
+
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
|
|
73
|
+
|
|
74
|
+
- name: Update package.json version
|
|
75
|
+
run: npm version ${{ steps.version.outputs.version }} --no-git-tag-version
|
|
76
|
+
|
|
77
|
+
- name: Install dependencies
|
|
78
|
+
run: npm ci
|
|
79
|
+
|
|
80
|
+
- name: Publish to npm
|
|
81
|
+
run: npm publish --access public
|
|
82
|
+
env:
|
|
83
|
+
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
|
|
84
|
+
|
|
85
|
+
- name: Publish to ClawHub
|
|
86
|
+
run: |
|
|
87
|
+
npm i -g clawhub
|
|
88
|
+
clawhub login --token ${{ secrets.CLAWHUB_TOKEN }} --no-browser
|
|
89
|
+
clawhub package publish ${{ github.repository }}@${{ github.event.release.tag_name }} --json
|
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Lillian Mikus
|
|
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,149 @@
|
|
|
1
|
+
# vauxr-openclaw
|
|
2
|
+
|
|
3
|
+
An OpenClaw channel plugin that bridges Vauxr voice devices into the OpenClaw agent loop. It connects to [Vauxr](https://github.com/vauxr-ai/vauxr) over WebSocket, dispatches inbound transcripts to the agent, and streams response deltas back for TTS playback.
|
|
4
|
+
|
|
5
|
+
It also registers three agent tools for direct device control from any session.
|
|
6
|
+
|
|
7
|
+
[](https://opensource.org/licenses/MIT)
|
|
8
|
+
|
|
9
|
+
---
|
|
10
|
+
|
|
11
|
+
## How it works
|
|
12
|
+
|
|
13
|
+
### Channel Plugin Bridge (recommended)
|
|
14
|
+
|
|
15
|
+
```
|
|
16
|
+
Vauxr <──WS (Vauxr protocol)──> vauxr-openclaw plugin <──> OpenClaw agent loop
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
- The plugin opens an outbound WS connection to Vauxr on startup
|
|
20
|
+
- Inbound transcripts from devices are dispatched into the agent loop as `vauxr:{device_id}` sessions
|
|
21
|
+
- Agent response deltas stream back to Vauxr in real time for TTS playback
|
|
22
|
+
- A `before_prompt_build` hook injects a voice-optimized system prompt for all vauxr sessions
|
|
23
|
+
|
|
24
|
+
### Fallback: Direct Operator WS
|
|
25
|
+
|
|
26
|
+
```
|
|
27
|
+
Vauxr <──WS (OpenClaw protocol)──> OpenClaw gateway
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
If installing the plugin is undesirable, Vauxr can connect directly to the OpenClaw gateway as an operator. This still works but is limited:
|
|
31
|
+
|
|
32
|
+
- No voice system prompt injection
|
|
33
|
+
- No session detection for vauxr-specific behavior
|
|
34
|
+
- No plugin-side control over prompt or session routing
|
|
35
|
+
|
|
36
|
+
To use fallback mode, configure Vauxr with `OPENCLAW_URL` and `OPENCLAW_TOKEN` environment variables and do not install this plugin.
|
|
37
|
+
|
|
38
|
+
---
|
|
39
|
+
|
|
40
|
+
## Tools
|
|
41
|
+
|
|
42
|
+
| Tool | What it does |
|
|
43
|
+
|---|---|
|
|
44
|
+
| `vauxr_devices` | Lists all Vauxr devices connected to Vauxr, with their IDs, names, and connection state |
|
|
45
|
+
| `vauxr_announce` | Synthesizes text via Piper TTS and plays it through a device's speaker |
|
|
46
|
+
| `vauxr_control` | Sends a control command to a device (`set_volume`, `mute`, `unmute`, `reboot`) |
|
|
47
|
+
|
|
48
|
+
These tools use the Vauxr REST API and work in any session, not just vauxr voice sessions.
|
|
49
|
+
|
|
50
|
+
---
|
|
51
|
+
|
|
52
|
+
## Requirements
|
|
53
|
+
|
|
54
|
+
- OpenClaw gateway
|
|
55
|
+
- [Vauxr](https://github.com/vauxr-ai/vauxr) running and reachable
|
|
56
|
+
- At least one paired Vauxr device connected to Vauxr
|
|
57
|
+
|
|
58
|
+
---
|
|
59
|
+
|
|
60
|
+
## Installation
|
|
61
|
+
|
|
62
|
+
Install from the repo directly:
|
|
63
|
+
|
|
64
|
+
```bash
|
|
65
|
+
openclaw plugins install path:/path/to/vauxr-openclaw
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
Then configure in your OpenClaw config:
|
|
69
|
+
|
|
70
|
+
```json
|
|
71
|
+
{
|
|
72
|
+
"channels": {
|
|
73
|
+
"vauxr": {
|
|
74
|
+
"url": "http://vauxr:8765",
|
|
75
|
+
"token": "your-channel-token",
|
|
76
|
+
"voiceSystemPrompt": "You are responding to a voice device. Use plain speech only — no emojis, no markdown, no code blocks. Keep replies concise."
|
|
77
|
+
}
|
|
78
|
+
},
|
|
79
|
+
"plugins": {
|
|
80
|
+
"entries": {
|
|
81
|
+
"vauxr": {
|
|
82
|
+
"enabled": true,
|
|
83
|
+
"hooks": {
|
|
84
|
+
"allowPromptInjection": true
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
- `url` — Vauxr base URL (HTTP)
|
|
93
|
+
- `token` — channel token generated in the Vauxr portal
|
|
94
|
+
- `voiceSystemPrompt` — optional, appended to the system prompt for all vauxr sessions
|
|
95
|
+
|
|
96
|
+
The `allowPromptInjection` hook permission is required for the voice system prompt to take effect.
|
|
97
|
+
|
|
98
|
+
---
|
|
99
|
+
|
|
100
|
+
## Usage
|
|
101
|
+
|
|
102
|
+
Once installed, the plugin connects to Vauxr automatically. Voice turns from any device are routed through the plugin into the agent loop, and responses stream back for TTS playback.
|
|
103
|
+
|
|
104
|
+
The agent tools are available in all sessions:
|
|
105
|
+
|
|
106
|
+
**Announce something:**
|
|
107
|
+
> "Announce through the living room speaker that dinner is ready."
|
|
108
|
+
|
|
109
|
+
**Device control:**
|
|
110
|
+
> "Mute the bedroom speaker."
|
|
111
|
+
> "Turn the volume up on the kitchen device."
|
|
112
|
+
|
|
113
|
+
---
|
|
114
|
+
|
|
115
|
+
## Architecture
|
|
116
|
+
|
|
117
|
+
```
|
|
118
|
+
Vauxr device (mic)
|
|
119
|
+
│
|
|
120
|
+
│ voice.start / audio / voice.end
|
|
121
|
+
▼
|
|
122
|
+
Vauxr (STT: Whisper)
|
|
123
|
+
│
|
|
124
|
+
│ channel.transcript (WS)
|
|
125
|
+
▼
|
|
126
|
+
vauxr-openclaw plugin
|
|
127
|
+
│
|
|
128
|
+
│ subagent.run(sessionKey: "vauxr:{device_id}")
|
|
129
|
+
▼
|
|
130
|
+
OpenClaw agent loop
|
|
131
|
+
│
|
|
132
|
+
│ agent event deltas
|
|
133
|
+
▼
|
|
134
|
+
vauxr-openclaw plugin
|
|
135
|
+
│
|
|
136
|
+
│ channel.response.delta (WS)
|
|
137
|
+
▼
|
|
138
|
+
Vauxr (TTS: Piper)
|
|
139
|
+
│
|
|
140
|
+
│ 0x02 audio frames
|
|
141
|
+
▼
|
|
142
|
+
Vauxr device (speaker)
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
---
|
|
146
|
+
|
|
147
|
+
## License
|
|
148
|
+
|
|
149
|
+
Vauxr OpenClaw is licensed under the [MIT License](LICENSE).
|
package/index.ts
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { defineChannelPluginEntry } from "openclaw/plugin-sdk/core";
|
|
2
|
+
import type { OpenClawPluginApi } from "openclaw/plugin-sdk/core";
|
|
3
|
+
import { vauxrPlugin } from "./src/channel.js";
|
|
4
|
+
import { VauxrAPIClient } from "./src/api-client.js";
|
|
5
|
+
import { registerTools } from "./src/tools.js";
|
|
6
|
+
import { VauxrBridge } from "./src/bridge.js";
|
|
7
|
+
import { DEFAULT_VOICE_SYSTEM_PROMPT } from "./src/defaults.js";
|
|
8
|
+
|
|
9
|
+
interface VauxrConfig {
|
|
10
|
+
url: string;
|
|
11
|
+
httpUrl?: string;
|
|
12
|
+
token?: string;
|
|
13
|
+
voiceSystemPrompt?: string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function resolveConfig(api: OpenClawPluginApi): VauxrConfig {
|
|
17
|
+
if (api.pluginConfig && typeof api.pluginConfig === "object" && "url" in api.pluginConfig) {
|
|
18
|
+
return api.pluginConfig as unknown as VauxrConfig;
|
|
19
|
+
}
|
|
20
|
+
const cfg = api.config as Record<string, unknown>;
|
|
21
|
+
const channels = cfg.channels as Record<string, unknown> | undefined;
|
|
22
|
+
return (channels?.vauxr ?? {}) as VauxrConfig;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const entry = defineChannelPluginEntry({
|
|
26
|
+
id: "vauxr",
|
|
27
|
+
name: "Vauxr",
|
|
28
|
+
description: "Vauxr voice device channel plugin for OpenClaw",
|
|
29
|
+
plugin: vauxrPlugin,
|
|
30
|
+
registerFull(api) {
|
|
31
|
+
const config = resolveConfig(api);
|
|
32
|
+
|
|
33
|
+
// REST tools — use explicit httpUrl if set, otherwise derive from ws url
|
|
34
|
+
// (vauxr WS is on :8765, HTTP API is on :8080)
|
|
35
|
+
const httpBase = config.httpUrl ?? (config.url ? config.url.replace(/:8765(\/?$)/, ":8080") : "");
|
|
36
|
+
|
|
37
|
+
if (!httpBase) {
|
|
38
|
+
// config.url not available yet (early registration) — skip bridge/tools
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const client = new VauxrAPIClient(httpBase, config.token ?? "");
|
|
43
|
+
registerTools(api, client);
|
|
44
|
+
|
|
45
|
+
// WS bridge to vauxr — guard against double-registration.
|
|
46
|
+
// OpenClaw invokes registerFull from multiple subsystems in the same
|
|
47
|
+
// process; without this flag, both bridges would contend for the
|
|
48
|
+
// single active channel slot in vauxr and flap continuously.
|
|
49
|
+
const g = globalThis as { __vauxrBridgeStarted?: boolean };
|
|
50
|
+
if (!g.__vauxrBridgeStarted) {
|
|
51
|
+
g.__vauxrBridgeStarted = true;
|
|
52
|
+
const bridge = new VauxrBridge(api, config);
|
|
53
|
+
bridge.start();
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// Voice system prompt injection for vauxr sessions
|
|
57
|
+
api.on("before_prompt_build", (_event, ctx) => {
|
|
58
|
+
if (ctx.sessionKey?.startsWith("vauxr:")) {
|
|
59
|
+
return {
|
|
60
|
+
appendSystemContext: config.voiceSystemPrompt ?? DEFAULT_VOICE_SYSTEM_PROMPT,
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
return undefined;
|
|
64
|
+
});
|
|
65
|
+
},
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
export default entry;
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
{
|
|
2
|
+
"id": "vauxr",
|
|
3
|
+
"kind": "channel",
|
|
4
|
+
"channels": ["vauxr"],
|
|
5
|
+
"name": "Vauxr",
|
|
6
|
+
"description": "Vauxr voice device channel plugin for OpenClaw",
|
|
7
|
+
"channelConfigs": {
|
|
8
|
+
"vauxr": {
|
|
9
|
+
"label": "Vauxr",
|
|
10
|
+
"description": "Vauxr voice device channel",
|
|
11
|
+
"schema": {
|
|
12
|
+
"type": "object",
|
|
13
|
+
"additionalProperties": false,
|
|
14
|
+
"required": ["url", "token"],
|
|
15
|
+
"properties": {
|
|
16
|
+
"url": {
|
|
17
|
+
"type": "string",
|
|
18
|
+
"description": "Vauxr base URL (e.g. http://192.168.1.100:8765)"
|
|
19
|
+
},
|
|
20
|
+
"token": {
|
|
21
|
+
"type": "string",
|
|
22
|
+
"description": "Channel token issued by Vauxr"
|
|
23
|
+
},
|
|
24
|
+
"voiceSystemPrompt": {
|
|
25
|
+
"type": "string",
|
|
26
|
+
"description": "System prompt appended to vauxr voice sessions"
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
},
|
|
32
|
+
"configSchema": {
|
|
33
|
+
"type": "object",
|
|
34
|
+
"additionalProperties": false,
|
|
35
|
+
"properties": {
|
|
36
|
+
"vauxr": {
|
|
37
|
+
"type": "object",
|
|
38
|
+
"required": ["url"],
|
|
39
|
+
"additionalProperties": false,
|
|
40
|
+
"properties": {
|
|
41
|
+
"url": {
|
|
42
|
+
"type": "string",
|
|
43
|
+
"description": "Vauxr base URL (e.g. http://vauxr:8765)"
|
|
44
|
+
},
|
|
45
|
+
"token": {
|
|
46
|
+
"type": "string",
|
|
47
|
+
"description": "Operator token for Vauxr API auth"
|
|
48
|
+
},
|
|
49
|
+
"voiceSystemPrompt": {
|
|
50
|
+
"type": "string",
|
|
51
|
+
"description": "Text appended to the system prompt for all vauxr sessions. Defaults to a voice-optimized instruction set."
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@vauxr/openclaw",
|
|
3
|
+
"version": "2026.4.1-2.3",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"main": "./index.ts",
|
|
6
|
+
"openclaw": {
|
|
7
|
+
"extensions": [
|
|
8
|
+
"./index.ts",
|
|
9
|
+
"./setup-entry.ts"
|
|
10
|
+
],
|
|
11
|
+
"compat": {
|
|
12
|
+
"pluginApi": ">=2026.4.2",
|
|
13
|
+
"minGatewayVersion": "2026.4.2"
|
|
14
|
+
},
|
|
15
|
+
"build": {
|
|
16
|
+
"openclawVersion": "2026.4.2",
|
|
17
|
+
"pluginSdkVersion": "2026.4.2"
|
|
18
|
+
}
|
|
19
|
+
},
|
|
20
|
+
"dependencies": {
|
|
21
|
+
"@sinclair/typebox": "^0.32.0",
|
|
22
|
+
"ws": "^8.18.0"
|
|
23
|
+
},
|
|
24
|
+
"devDependencies": {
|
|
25
|
+
"@types/ws": "^8.5.0",
|
|
26
|
+
"openclaw": "^2026.4.2",
|
|
27
|
+
"typescript": "^5.0.0"
|
|
28
|
+
}
|
|
29
|
+
}
|
package/setup-entry.ts
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
export interface Device {
|
|
2
|
+
id: string;
|
|
3
|
+
name: string;
|
|
4
|
+
state: "idle" | "listening" | "processing" | "speaking" | "offline";
|
|
5
|
+
lastSeen: string;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export class VauxrAPIClient {
|
|
9
|
+
constructor(
|
|
10
|
+
private baseUrl: string,
|
|
11
|
+
private token: string,
|
|
12
|
+
) {}
|
|
13
|
+
|
|
14
|
+
private async request<T>(method: string, path: string, body?: unknown): Promise<T> {
|
|
15
|
+
const url = `${this.baseUrl}${path}`;
|
|
16
|
+
const headers: Record<string, string> = {
|
|
17
|
+
Authorization: `Bearer ${this.token}`,
|
|
18
|
+
"Content-Type": "application/json",
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
const res = await fetch(url, {
|
|
22
|
+
method,
|
|
23
|
+
headers,
|
|
24
|
+
body: body !== undefined ? JSON.stringify(body) : undefined,
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
if (!res.ok) {
|
|
28
|
+
let message = `HTTP ${res.status}`;
|
|
29
|
+
try {
|
|
30
|
+
const errorBody = (await res.json()) as Record<string, unknown>;
|
|
31
|
+
const detail = errorBody.error ?? errorBody.message;
|
|
32
|
+
if (typeof detail === "string") {
|
|
33
|
+
message = detail;
|
|
34
|
+
}
|
|
35
|
+
} catch {
|
|
36
|
+
// response body wasn't JSON — keep generic message
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
if (res.status === 401) {
|
|
40
|
+
throw new Error(`Unauthorized: ${message}`);
|
|
41
|
+
}
|
|
42
|
+
if (res.status === 404) {
|
|
43
|
+
throw new Error(`Device not found: ${message}`);
|
|
44
|
+
}
|
|
45
|
+
if (res.status === 409) {
|
|
46
|
+
throw new Error(`Device busy: ${message}`);
|
|
47
|
+
}
|
|
48
|
+
throw new Error(message);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const text = await res.text();
|
|
52
|
+
if (!text) return undefined as T;
|
|
53
|
+
return JSON.parse(text) as T;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
async listDevices(): Promise<Device[]> {
|
|
57
|
+
return this.request<Device[]>("GET", "/api/devices");
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
async announce(deviceId: string, text: string): Promise<void> {
|
|
61
|
+
await this.request<void>("POST", `/api/devices/${encodeURIComponent(deviceId)}/announce`, {
|
|
62
|
+
text,
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
async command(
|
|
67
|
+
deviceId: string,
|
|
68
|
+
command: string,
|
|
69
|
+
params?: Record<string, unknown>,
|
|
70
|
+
): Promise<void> {
|
|
71
|
+
await this.request<void>("POST", `/api/devices/${encodeURIComponent(deviceId)}/command`, {
|
|
72
|
+
command,
|
|
73
|
+
params,
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
}
|
package/src/bridge.ts
ADDED
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
import WebSocket from "ws";
|
|
2
|
+
import type { OpenClawPluginApi } from "openclaw/plugin-sdk/core";
|
|
3
|
+
|
|
4
|
+
/** Vauxr protocol frames sent by vauxr to the channel plugin */
|
|
5
|
+
interface VauxrInboundFrame {
|
|
6
|
+
type: "channel.transcript" | "channel.device_state" | "channel.ready" | "error";
|
|
7
|
+
deviceId?: string;
|
|
8
|
+
text?: string;
|
|
9
|
+
state?: string;
|
|
10
|
+
name?: string;
|
|
11
|
+
code?: string;
|
|
12
|
+
message?: string;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** Vauxr protocol frames sent by the channel plugin to vauxr */
|
|
16
|
+
type VauxrOutboundFrame =
|
|
17
|
+
| { type: "channel.auth"; token: string }
|
|
18
|
+
| { type: "channel.response.delta"; deviceId: string; runId: string; text: string }
|
|
19
|
+
| { type: "channel.response.end"; deviceId: string; runId: string }
|
|
20
|
+
| { type: "channel.response.error"; deviceId: string; runId: string; message: string };
|
|
21
|
+
|
|
22
|
+
interface VauxrBridgeConfig {
|
|
23
|
+
url: string;
|
|
24
|
+
token?: string;
|
|
25
|
+
voiceSystemPrompt?: string;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const INITIAL_RECONNECT_MS = 1000;
|
|
29
|
+
const MAX_RECONNECT_MS = 30000;
|
|
30
|
+
|
|
31
|
+
export class VauxrBridge {
|
|
32
|
+
private ws: WebSocket | null = null;
|
|
33
|
+
private reconnectMs = INITIAL_RECONNECT_MS;
|
|
34
|
+
private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
|
|
35
|
+
private unsubscribeEvents: (() => void) | null = null;
|
|
36
|
+
private activeRuns = new Map<string, string>(); // SDK runId → deviceId
|
|
37
|
+
private runIdMap = new Map<string, string>(); // SDK runId → protocol runId
|
|
38
|
+
private wsUrl: string;
|
|
39
|
+
|
|
40
|
+
constructor(
|
|
41
|
+
private api: OpenClawPluginApi,
|
|
42
|
+
private config: VauxrBridgeConfig,
|
|
43
|
+
) {
|
|
44
|
+
// Derive WS URL from HTTP base URL
|
|
45
|
+
const base = config.url.replace(/\/$/, "");
|
|
46
|
+
this.wsUrl = base.replace(/^http/, "ws") + "/channel";
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
start(): void {
|
|
50
|
+
this.connect();
|
|
51
|
+
this.subscribeAgentEvents();
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
stop(): void {
|
|
55
|
+
if (this.reconnectTimer) {
|
|
56
|
+
clearTimeout(this.reconnectTimer);
|
|
57
|
+
this.reconnectTimer = null;
|
|
58
|
+
}
|
|
59
|
+
if (this.unsubscribeEvents) {
|
|
60
|
+
this.unsubscribeEvents();
|
|
61
|
+
this.unsubscribeEvents = null;
|
|
62
|
+
}
|
|
63
|
+
if (this.ws) {
|
|
64
|
+
this.ws.close();
|
|
65
|
+
this.ws = null;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
private connect(): void {
|
|
70
|
+
this.api.logger.info(`[vauxr-bridge] Connecting to vauxr: ${this.wsUrl}`);
|
|
71
|
+
|
|
72
|
+
const ws = new WebSocket(this.wsUrl);
|
|
73
|
+
this.ws = ws;
|
|
74
|
+
|
|
75
|
+
ws.on("open", () => {
|
|
76
|
+
this.api.logger.info("[vauxr-bridge] Connected to vauxr");
|
|
77
|
+
this.reconnectMs = INITIAL_RECONNECT_MS;
|
|
78
|
+
|
|
79
|
+
// Authenticate with channel token
|
|
80
|
+
if (this.config.token) {
|
|
81
|
+
this.send({ type: "channel.auth", token: this.config.token });
|
|
82
|
+
}
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
ws.on("message", (data) => {
|
|
86
|
+
try {
|
|
87
|
+
const frame = JSON.parse(String(data)) as VauxrInboundFrame;
|
|
88
|
+
this.handleFrame(frame);
|
|
89
|
+
} catch (err) {
|
|
90
|
+
this.api.logger.warn(`[vauxr-bridge] Failed to parse inbound frame: ${String(err)}`);
|
|
91
|
+
}
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
ws.on("close", () => {
|
|
95
|
+
this.api.logger.info("[vauxr-bridge] Disconnected from vauxr");
|
|
96
|
+
this.ws = null;
|
|
97
|
+
this.scheduleReconnect();
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
ws.on("error", (err) => {
|
|
101
|
+
this.api.logger.warn(`[vauxr-bridge] WS error: ${String(err)}`);
|
|
102
|
+
// 'close' event will fire after this — reconnect handled there
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
private scheduleReconnect(): void {
|
|
107
|
+
if (this.reconnectTimer) return;
|
|
108
|
+
this.api.logger.info(
|
|
109
|
+
`[vauxr-bridge] Reconnecting in ${this.reconnectMs}ms`,
|
|
110
|
+
);
|
|
111
|
+
this.reconnectTimer = setTimeout(() => {
|
|
112
|
+
this.reconnectTimer = null;
|
|
113
|
+
this.connect();
|
|
114
|
+
}, this.reconnectMs);
|
|
115
|
+
this.reconnectMs = Math.min(this.reconnectMs * 2, MAX_RECONNECT_MS);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
private handleFrame(frame: VauxrInboundFrame): void {
|
|
119
|
+
switch (frame.type) {
|
|
120
|
+
case "channel.transcript":
|
|
121
|
+
if (frame.deviceId && frame.text) {
|
|
122
|
+
void this.dispatchTranscript(frame.deviceId, frame.text);
|
|
123
|
+
}
|
|
124
|
+
break;
|
|
125
|
+
case "channel.device_state":
|
|
126
|
+
this.api.logger.info(
|
|
127
|
+
`[vauxr-bridge] Device ${frame.deviceId ?? "unknown"}: ${frame.state ?? "unknown"}`,
|
|
128
|
+
);
|
|
129
|
+
break;
|
|
130
|
+
case "channel.ready":
|
|
131
|
+
this.api.logger.info("[vauxr-bridge] Channel authenticated");
|
|
132
|
+
break;
|
|
133
|
+
case "error":
|
|
134
|
+
this.api.logger.warn(
|
|
135
|
+
`[vauxr-bridge] Error from vauxr: ${frame.code ?? "UNKNOWN"} — ${frame.message ?? "no details"}`,
|
|
136
|
+
);
|
|
137
|
+
break;
|
|
138
|
+
default:
|
|
139
|
+
this.api.logger.warn(
|
|
140
|
+
`[vauxr-bridge] Unknown frame type: ${String((frame as unknown as Record<string, unknown>).type)}`,
|
|
141
|
+
);
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
private async dispatchTranscript(deviceId: string, text: string): Promise<void> {
|
|
146
|
+
const sessionKey = `vauxr:${deviceId}`;
|
|
147
|
+
// Generate a protocol-level runId (sent to vauxr in response frames)
|
|
148
|
+
const protocolRunId = crypto.randomUUID();
|
|
149
|
+
this.api.logger.info(
|
|
150
|
+
`[vauxr-bridge] Dispatching transcript for ${sessionKey} (runId=${protocolRunId}): "${text}"`,
|
|
151
|
+
);
|
|
152
|
+
|
|
153
|
+
try {
|
|
154
|
+
const result = await this.api.runtime.subagent.run({
|
|
155
|
+
sessionKey,
|
|
156
|
+
message: text,
|
|
157
|
+
idempotencyKey: protocolRunId,
|
|
158
|
+
// Inject voice-formatting instructions so the model doesn't emit
|
|
159
|
+
// markdown, emojis, or lists — responses are spoken aloud by TTS.
|
|
160
|
+
// Uses the SDK's extraSystemPrompt field; omitted when not configured.
|
|
161
|
+
...(this.config.voiceSystemPrompt
|
|
162
|
+
? { extraSystemPrompt: this.config.voiceSystemPrompt }
|
|
163
|
+
: {}),
|
|
164
|
+
});
|
|
165
|
+
this.activeRuns.set(result.runId, deviceId);
|
|
166
|
+
this.runIdMap.set(result.runId, protocolRunId);
|
|
167
|
+
} catch (err) {
|
|
168
|
+
this.api.logger.warn(
|
|
169
|
+
`[vauxr-bridge] Failed to dispatch transcript for ${sessionKey}: ${String(err)}`,
|
|
170
|
+
);
|
|
171
|
+
this.send({
|
|
172
|
+
type: "channel.response.error",
|
|
173
|
+
deviceId,
|
|
174
|
+
runId: protocolRunId,
|
|
175
|
+
message: String(err),
|
|
176
|
+
});
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
private subscribeAgentEvents(): void {
|
|
181
|
+
this.unsubscribeEvents = this.api.runtime.events.onAgentEvent((event) => {
|
|
182
|
+
const deviceId = this.activeRuns.get(event.runId);
|
|
183
|
+
if (!deviceId) return; // Not a vauxr run
|
|
184
|
+
|
|
185
|
+
const runId = this.runIdMap.get(event.runId) ?? event.runId;
|
|
186
|
+
|
|
187
|
+
if (event.stream === "assistant") {
|
|
188
|
+
// data.delta is the incremental chunk for streaming sessions;
|
|
189
|
+
// data.text is the accumulated text (or full text for single-shot).
|
|
190
|
+
// Prefer delta when present, fall back to text.
|
|
191
|
+
const chunk =
|
|
192
|
+
(typeof event.data["delta"] === "string" && event.data["delta"]) ||
|
|
193
|
+
(typeof event.data["text"] === "string" && event.data["text"]) ||
|
|
194
|
+
null;
|
|
195
|
+
if (chunk) {
|
|
196
|
+
this.send({
|
|
197
|
+
type: "channel.response.delta",
|
|
198
|
+
deviceId,
|
|
199
|
+
runId,
|
|
200
|
+
text: chunk,
|
|
201
|
+
});
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// Clean up when run ends
|
|
206
|
+
if (event.stream === "lifecycle" && event.data["phase"] === "end") {
|
|
207
|
+
this.send({
|
|
208
|
+
type: "channel.response.end",
|
|
209
|
+
deviceId,
|
|
210
|
+
runId,
|
|
211
|
+
});
|
|
212
|
+
this.activeRuns.delete(event.runId);
|
|
213
|
+
this.runIdMap.delete(event.runId);
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
if (event.stream === "error") {
|
|
217
|
+
this.api.logger.warn(
|
|
218
|
+
`[vauxr-bridge] Agent error for device ${deviceId}: ${JSON.stringify(event.data)}`,
|
|
219
|
+
);
|
|
220
|
+
this.send({
|
|
221
|
+
type: "channel.response.error",
|
|
222
|
+
deviceId,
|
|
223
|
+
runId,
|
|
224
|
+
message: String(event.data["message"] ?? "Agent error"),
|
|
225
|
+
});
|
|
226
|
+
this.activeRuns.delete(event.runId);
|
|
227
|
+
this.runIdMap.delete(event.runId);
|
|
228
|
+
}
|
|
229
|
+
});
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
private send(frame: VauxrOutboundFrame): void {
|
|
233
|
+
if (this.ws?.readyState === WebSocket.OPEN) {
|
|
234
|
+
this.ws.send(JSON.stringify(frame));
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
}
|
package/src/channel.ts
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import { createChatChannelPlugin, createChannelPluginBase } from "openclaw/plugin-sdk/core";
|
|
2
|
+
import { DEFAULT_VOICE_SYSTEM_PROMPT } from "./defaults.js";
|
|
3
|
+
import { createTopLevelChannelConfigBase } from "openclaw/plugin-sdk/channel-config-helpers";
|
|
4
|
+
import type { OpenClawConfig } from "openclaw/plugin-sdk/core";
|
|
5
|
+
|
|
6
|
+
export interface VauxrAccount {
|
|
7
|
+
accountId?: string | null;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
// Type assertion needed: createChannelPluginBase marks capabilities as Partial
|
|
11
|
+
// in its return type, but createChatChannelPlugin requires it non-optional.
|
|
12
|
+
// We always provide capabilities, so the assertion is safe.
|
|
13
|
+
export const vauxrPlugin = createChatChannelPlugin<VauxrAccount>({
|
|
14
|
+
base: createChannelPluginBase<VauxrAccount>({
|
|
15
|
+
id: "vauxr",
|
|
16
|
+
meta: { label: "Vauxr" },
|
|
17
|
+
capabilities: {
|
|
18
|
+
chatTypes: ["direct"],
|
|
19
|
+
},
|
|
20
|
+
config: createTopLevelChannelConfigBase<VauxrAccount>({
|
|
21
|
+
sectionKey: "vauxr",
|
|
22
|
+
resolveAccount: (cfg) => {
|
|
23
|
+
const section = resolveSection(cfg);
|
|
24
|
+
const url = section?.url;
|
|
25
|
+
return {
|
|
26
|
+
accountId: url ?? "default",
|
|
27
|
+
// running/connected drive UI status indicators
|
|
28
|
+
...(url ? { running: true, connected: true } : {}),
|
|
29
|
+
};
|
|
30
|
+
},
|
|
31
|
+
// Single-account channel — listAccountIds returns either the single
|
|
32
|
+
// resolved id or an empty array if the channel isn't configured.
|
|
33
|
+
listAccountIds: (cfg) => {
|
|
34
|
+
const section = resolveSection(cfg);
|
|
35
|
+
return section?.url ? [section.url] : [];
|
|
36
|
+
},
|
|
37
|
+
defaultAccountId: (cfg) => resolveSection(cfg)?.url ?? "default",
|
|
38
|
+
}),
|
|
39
|
+
setup: {
|
|
40
|
+
resolveAccountId({ cfg }) {
|
|
41
|
+
const section = resolveSection(cfg);
|
|
42
|
+
return section?.url ?? "default";
|
|
43
|
+
},
|
|
44
|
+
applyAccountConfig({ cfg, input }) {
|
|
45
|
+
const updated = structuredClone(cfg) as Record<string, unknown>;
|
|
46
|
+
const channels = (updated.channels ?? {}) as Record<string, unknown>;
|
|
47
|
+
channels.vauxr = {
|
|
48
|
+
// Seed default voice system prompt so it's populated on first install.
|
|
49
|
+
// Existing value (if any) takes precedence via spread order.
|
|
50
|
+
voiceSystemPrompt: DEFAULT_VOICE_SYSTEM_PROMPT,
|
|
51
|
+
...((channels.vauxr ?? {}) as Record<string, unknown>),
|
|
52
|
+
...(input as Record<string, unknown>),
|
|
53
|
+
};
|
|
54
|
+
updated.channels = channels;
|
|
55
|
+
return updated as OpenClawConfig;
|
|
56
|
+
},
|
|
57
|
+
},
|
|
58
|
+
}) as Parameters<typeof createChatChannelPlugin<VauxrAccount>>[0]["base"],
|
|
59
|
+
// No security/pairing — vauxr devices are trusted local hardware
|
|
60
|
+
outbound: {
|
|
61
|
+
// Outbound responses are delivered via the WS bridge, not the outbound adapter
|
|
62
|
+
// This stub satisfies the ChannelPlugin interface
|
|
63
|
+
base: {
|
|
64
|
+
deliveryMode: "direct",
|
|
65
|
+
},
|
|
66
|
+
attachedResults: {
|
|
67
|
+
channel: "vauxr",
|
|
68
|
+
sendText: async () => ({ messageId: "bridge" }),
|
|
69
|
+
},
|
|
70
|
+
},
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
// gateway.startAccount is required for OpenClaw to mark this channel as
|
|
74
|
+
// "running" and "configured" in the UI. The actual bridge lifecycle is
|
|
75
|
+
// managed by registerFull in index.ts (which has access to the full plugin
|
|
76
|
+
// API). This stub holds the channel in running state until the gateway stops.
|
|
77
|
+
// isConfigured: tells OpenClaw the channel is configured when url is set.
|
|
78
|
+
vauxrPlugin.config.isConfigured = (_account: unknown, cfg: OpenClawConfig) => {
|
|
79
|
+
return Boolean(resolveSection(cfg)?.url);
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
vauxrPlugin.gateway = {
|
|
83
|
+
startAccount: async (ctx: { abortSignal: AbortSignal }) => {
|
|
84
|
+
await new Promise<void>((resolve) => {
|
|
85
|
+
ctx.abortSignal.addEventListener("abort", () => resolve(), { once: true });
|
|
86
|
+
});
|
|
87
|
+
},
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
interface VauxrSection {
|
|
91
|
+
url?: string;
|
|
92
|
+
token?: string;
|
|
93
|
+
voiceSystemPrompt?: string;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function resolveSection(cfg: OpenClawConfig): VauxrSection | undefined {
|
|
97
|
+
const raw = cfg as Record<string, unknown>;
|
|
98
|
+
const channelsCfg = (raw.channels as Record<string, unknown> | undefined)?.vauxr as
|
|
99
|
+
| VauxrSection
|
|
100
|
+
| undefined;
|
|
101
|
+
const pluginsCfg = (
|
|
102
|
+
(raw.plugins as Record<string, unknown> | undefined)?.entries as
|
|
103
|
+
| Record<string, unknown>
|
|
104
|
+
| undefined
|
|
105
|
+
)?.vauxr as { config?: VauxrSection } | undefined;
|
|
106
|
+
return channelsCfg ?? pluginsCfg?.config;
|
|
107
|
+
}
|
package/src/defaults.ts
ADDED
package/src/tools.ts
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import { Type } from "@sinclair/typebox";
|
|
2
|
+
import type { OpenClawPluginApi } from "openclaw/plugin-sdk/plugin-entry";
|
|
3
|
+
import type { VauxrAPIClient, Device } from "./api-client.js";
|
|
4
|
+
|
|
5
|
+
function formatDeviceList(devices: Device[]): string {
|
|
6
|
+
if (devices.length === 0) return "No devices connected.";
|
|
7
|
+
return devices
|
|
8
|
+
.map((d) => `• ${d.name} (id: ${d.id}) — ${d.state}, last seen ${d.lastSeen}`)
|
|
9
|
+
.join("\n");
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function registerTools(api: OpenClawPluginApi, client: VauxrAPIClient): void {
|
|
13
|
+
api.registerTool(
|
|
14
|
+
{
|
|
15
|
+
name: "vauxr_devices",
|
|
16
|
+
label: "Vauxr Devices",
|
|
17
|
+
description:
|
|
18
|
+
"List Vauxr voice devices currently connected to Vauxr, with their IDs, names, and connection state. Call this first if you don't know which device to target.",
|
|
19
|
+
parameters: Type.Object({}),
|
|
20
|
+
async execute() {
|
|
21
|
+
const devices = await client.listDevices();
|
|
22
|
+
return {
|
|
23
|
+
content: [{ type: "text" as const, text: formatDeviceList(devices) }],
|
|
24
|
+
details: { devices },
|
|
25
|
+
};
|
|
26
|
+
},
|
|
27
|
+
},
|
|
28
|
+
{ optional: false },
|
|
29
|
+
);
|
|
30
|
+
|
|
31
|
+
api.registerTool(
|
|
32
|
+
{
|
|
33
|
+
name: "vauxr_announce",
|
|
34
|
+
label: "Vauxr Announce",
|
|
35
|
+
description:
|
|
36
|
+
"Announce a spoken message through a Vauxr voice device. The text will be synthesized to speech and played through the device's speaker. Use `vauxr_devices` first if you don't know the device ID.",
|
|
37
|
+
parameters: Type.Object({
|
|
38
|
+
device_id: Type.String({ description: "ID of the device to speak through" }),
|
|
39
|
+
text: Type.String({
|
|
40
|
+
description:
|
|
41
|
+
"Text to speak aloud — keep it concise, plain sentences only, no markdown or emojis",
|
|
42
|
+
}),
|
|
43
|
+
}),
|
|
44
|
+
async execute(_id, params) {
|
|
45
|
+
await client.announce(params.device_id, params.text);
|
|
46
|
+
return {
|
|
47
|
+
content: [
|
|
48
|
+
{
|
|
49
|
+
type: "text" as const,
|
|
50
|
+
text: `Announced on device ${params.device_id}: "${params.text}"`,
|
|
51
|
+
},
|
|
52
|
+
],
|
|
53
|
+
details: {},
|
|
54
|
+
};
|
|
55
|
+
},
|
|
56
|
+
},
|
|
57
|
+
{ optional: false },
|
|
58
|
+
);
|
|
59
|
+
|
|
60
|
+
api.registerTool(
|
|
61
|
+
{
|
|
62
|
+
name: "vauxr_control",
|
|
63
|
+
label: "Vauxr Control",
|
|
64
|
+
description:
|
|
65
|
+
"Send a control command to a Vauxr voice device (set volume, mute, unmute, or reboot).",
|
|
66
|
+
parameters: Type.Object({
|
|
67
|
+
device_id: Type.String({ description: "ID of the device to control" }),
|
|
68
|
+
command: Type.Union(
|
|
69
|
+
[
|
|
70
|
+
Type.Literal("set_volume"),
|
|
71
|
+
Type.Literal("mute"),
|
|
72
|
+
Type.Literal("unmute"),
|
|
73
|
+
Type.Literal("reboot"),
|
|
74
|
+
],
|
|
75
|
+
{ description: "The control command to send" },
|
|
76
|
+
),
|
|
77
|
+
volume: Type.Optional(
|
|
78
|
+
Type.Number({
|
|
79
|
+
description: "Volume level 0–100, required when command is set_volume",
|
|
80
|
+
minimum: 0,
|
|
81
|
+
maximum: 100,
|
|
82
|
+
}),
|
|
83
|
+
),
|
|
84
|
+
}),
|
|
85
|
+
async execute(_id, params) {
|
|
86
|
+
const cmdParams: Record<string, unknown> | undefined =
|
|
87
|
+
params.command === "set_volume" ? { volume: params.volume } : undefined;
|
|
88
|
+
await client.command(params.device_id, params.command, cmdParams);
|
|
89
|
+
return {
|
|
90
|
+
content: [
|
|
91
|
+
{
|
|
92
|
+
type: "text" as const,
|
|
93
|
+
text: `Sent ${params.command} to device ${params.device_id}${params.command === "set_volume" ? ` (volume: ${params.volume})` : ""}`,
|
|
94
|
+
},
|
|
95
|
+
],
|
|
96
|
+
details: {},
|
|
97
|
+
};
|
|
98
|
+
},
|
|
99
|
+
},
|
|
100
|
+
{ optional: false },
|
|
101
|
+
);
|
|
102
|
+
}
|
package/tsconfig.json
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "ES2022",
|
|
4
|
+
"module": "Node16",
|
|
5
|
+
"moduleResolution": "Node16",
|
|
6
|
+
"strict": true,
|
|
7
|
+
"esModuleInterop": true,
|
|
8
|
+
"skipLibCheck": true,
|
|
9
|
+
"outDir": "dist",
|
|
10
|
+
"rootDir": ".",
|
|
11
|
+
"declaration": false
|
|
12
|
+
},
|
|
13
|
+
"include": ["index.ts", "setup-entry.ts", "src/**/*.ts"]
|
|
14
|
+
}
|