@openbrt/audioctl 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 ADDED
@@ -0,0 +1,70 @@
1
+ # @openbrt/audioctl
2
+
3
+ Generic WeClawBot audio-output control CLI and AI-agent plugin.
4
+
5
+ It is meant for user-owned agents such as Codex, Claude Code, OpenClaw, Hermes,
6
+ WorkBuddy, Gemini CLI, or any shell-capable agent runtime. It is not tied to one
7
+ speaker brand. Any ESP32/Linux audio device that implements the WeClawBot MQTT
8
+ audio command envelope can use the same package.
9
+
10
+ ## Tell your agent
11
+
12
+ ```text
13
+ Install npm package @openbrt/audioctl, bind my audio device with pairing code 123456,
14
+ run doctor, then use it as my default BGM/music output device.
15
+ ```
16
+
17
+ ## Install and bind
18
+
19
+ ```bash
20
+ npm install -g @openbrt/audioctl
21
+ audioctl bind 123456 --name codex
22
+ audioctl doctor --online
23
+ ```
24
+
25
+ One-shot usage:
26
+
27
+ ```bash
28
+ npm exec --package @openbrt/audioctl -- audioctl bind 123456 --name codex
29
+ ```
30
+
31
+ Credentials are saved to `~/.config/weclawbot/audio-mqtt.json` by default.
32
+
33
+ ## Commands
34
+
35
+ ```bash
36
+ audioctl play
37
+ audioctl pause
38
+ audioctl toggle
39
+ audioctl next
40
+ audioctl previous
41
+ audioctl volume 48
42
+ audioctl volumeup
43
+ audioctl volumedown
44
+ audioctl status
45
+ audioctl rule ./my-playlist-rule.json
46
+ audioctl deepnight
47
+ audioctl prompt 123456 --agent codex --alias 视听房音箱
48
+ ```
49
+
50
+ `deepnight` pushes the bundled “深夜工作” playlist rule and asks the device to
51
+ refresh/play according to its local runtime. The package never contains music
52
+ platform private keys.
53
+
54
+ ## Control envelope
55
+
56
+ The MQTT control message is:
57
+
58
+ ```json
59
+ {
60
+ "schema": "weclawbot.control.v1",
61
+ "id": "audio_<uuid>",
62
+ "kind": "music_command",
63
+ "music": {
64
+ "command": "play"
65
+ }
66
+ }
67
+ ```
68
+
69
+ Supported commands include `play`, `pause`, `toggle`, `next`, `previous`,
70
+ `seek`, `set_volume`, `volume_up`, `volume_down`, `status`, and `set_rule`.
@@ -0,0 +1,61 @@
1
+ {
2
+ "schema": "weclawbot.agent_plugin.v1",
3
+ "id": "audioctl",
4
+ "name": "audioctl",
5
+ "package": "@openbrt/audioctl",
6
+ "bin": "audioctl",
7
+ "description": "Bind and control WeClawBot-compatible audio output devices from user-owned AI agents.",
8
+ "transport": "weclawbot.link/mqtt",
9
+ "device_classes": [
10
+ "audio_output",
11
+ "music_player"
12
+ ],
13
+ "agents": [
14
+ "Codex",
15
+ "Claude Code",
16
+ "OpenClaw",
17
+ "Hermes",
18
+ "WorkBuddy",
19
+ "Gemini CLI",
20
+ "OpenCode"
21
+ ],
22
+ "capabilities": [
23
+ "audio.play",
24
+ "audio.pause",
25
+ "audio.toggle",
26
+ "audio.next",
27
+ "audio.previous",
28
+ "audio.seek",
29
+ "audio.set_volume",
30
+ "music.set_rule",
31
+ "music.status"
32
+ ],
33
+ "pairing": {
34
+ "endpoint": "https://weclawbot.link/byoa",
35
+ "endpoint_env": "WEC_BYOA_ENDPOINT",
36
+ "credentials_env": "WEC_AUDIO_CREDENTIALS",
37
+ "code_pattern": "^[A-Za-z0-9_-]{6,64}$"
38
+ },
39
+ "commands": [
40
+ {
41
+ "name": "bind",
42
+ "usage": "audioctl bind CODE --name AGENT"
43
+ },
44
+ {
45
+ "name": "doctor",
46
+ "usage": "audioctl doctor --online"
47
+ },
48
+ {
49
+ "name": "control",
50
+ "usage": "audioctl play|pause|toggle|next|previous|volumeup|volumedown|status"
51
+ },
52
+ {
53
+ "name": "volume",
54
+ "usage": "audioctl volume 0..100"
55
+ },
56
+ {
57
+ "name": "rule",
58
+ "usage": "audioctl rule RULE.json"
59
+ }
60
+ ]
61
+ }
@@ -0,0 +1,259 @@
1
+ #!/usr/bin/env node
2
+
3
+ import fs from "node:fs/promises";
4
+ import process from "node:process";
5
+
6
+ import {
7
+ COMMAND_NAME,
8
+ DEFAULT_CREDENTIALS_PATH,
9
+ DEFAULT_ENDPOINT,
10
+ DEEPNIGHT_RULE,
11
+ PACKAGE_NAME,
12
+ bindAgent,
13
+ buildAgentPrompt,
14
+ buildAudioControl,
15
+ expandPath,
16
+ normalizeCredentials,
17
+ publishAudioControl,
18
+ readCredentials,
19
+ summarizePlayback,
20
+ testConnection,
21
+ validateRule,
22
+ } from "../lib/index.mjs";
23
+
24
+ const [command, ...rawArguments] = process.argv.slice(2);
25
+
26
+ if (!command || new Set(["help", "-h", "--help"]).has(command)) {
27
+ usage(command ? 0 : 64);
28
+ } else {
29
+ try {
30
+ await run(command, rawArguments);
31
+ } catch (error) {
32
+ process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`);
33
+ process.exit(1);
34
+ }
35
+ }
36
+
37
+ async function run(name, values) {
38
+ if (name === "bind") return commandBind(values);
39
+ if (name === "doctor") return commandDoctor(values);
40
+ if (name === "prompt") return commandPrompt(values);
41
+ if (name === "manifest") return commandManifest();
42
+ if (name === "rule") return commandRule(values);
43
+ if (name === "deepnight") return commandDeepnight(values);
44
+ if (name === "volume") return commandVolume(values);
45
+ if (name === "seek") return commandSeek(values);
46
+ return commandControl(name, values);
47
+ }
48
+
49
+ function usage(exitCode = 0) {
50
+ process.stdout.write(`Usage:
51
+ ${COMMAND_NAME} bind CODE [--name AGENT]
52
+ ${COMMAND_NAME} doctor [--online] [--json]
53
+ ${COMMAND_NAME} prompt CODE [--agent AGENT] [--alias NAME]
54
+ ${COMMAND_NAME} play|pause|toggle|next|previous|volumeup|volumedown|status
55
+ ${COMMAND_NAME} volume 0..100
56
+ ${COMMAND_NAME} seek MILLISECONDS
57
+ ${COMMAND_NAME} rule RULE.json
58
+ ${COMMAND_NAME} deepnight
59
+ ${COMMAND_NAME} manifest
60
+
61
+ Options:
62
+ --credentials FILE default: ${DEFAULT_CREDENTIALS_PATH}
63
+ --endpoint URL default: ${DEFAULT_ENDPOINT}
64
+ --timeout SECONDS default: 12
65
+ --json machine-readable output
66
+ --nowait publish without waiting for device status
67
+ `);
68
+ process.exit(exitCode);
69
+ }
70
+
71
+ async function commandBind(values) {
72
+ const options = parseOptions(values, {
73
+ credentials: credentialsPath(),
74
+ endpoint: process.env.WEC_BYOA_ENDPOINT || DEFAULT_ENDPOINT,
75
+ name: process.env.WEC_AGENT_NAME || "user-agent",
76
+ timeout: 15,
77
+ });
78
+ const code = String(options._[0] || "");
79
+ const result = await bindAgent({
80
+ code,
81
+ name: options.name,
82
+ endpoint: options.endpoint,
83
+ credentialsPath: options.credentials,
84
+ timeoutMs: Number(options.timeout) * 1000,
85
+ });
86
+ process.stdout.write(
87
+ `paired ${result.credentials.binding?.device_id || "audio-device"} -> ${result.credentialsPath}\n`,
88
+ );
89
+ }
90
+
91
+ async function commandDoctor(values) {
92
+ const options = parseOptions(values, {
93
+ credentials: credentialsPath(),
94
+ online: false,
95
+ json: false,
96
+ timeout: 12,
97
+ });
98
+ const file = expandPath(options.credentials);
99
+ const credentials = await readCredentials(file);
100
+ const profile = normalizeCredentials(credentials);
101
+ const status = {
102
+ ok: true,
103
+ paired: true,
104
+ credentials: file,
105
+ device_id: credentials.binding?.device_id || "",
106
+ control_topic: profile.controlTopic,
107
+ status_topic: profile.statusTopic,
108
+ online: null,
109
+ };
110
+ if (options.online) {
111
+ await testConnection(credentials, { timeoutMs: Number(options.timeout) * 1000 });
112
+ status.online = true;
113
+ }
114
+ print(status, options.json);
115
+ }
116
+
117
+ async function commandPrompt(values) {
118
+ const options = parseOptions(values, {
119
+ agent: process.env.WEC_AGENT_NAME || "agent",
120
+ alias: "音频设备",
121
+ profile: "深夜工作",
122
+ });
123
+ process.stdout.write(`${buildAgentPrompt({
124
+ code: options._[0],
125
+ agentName: options.agent,
126
+ alias: options.alias,
127
+ profile: options.profile,
128
+ })}\n`);
129
+ }
130
+
131
+ async function commandManifest() {
132
+ const manifest = JSON.parse(
133
+ await fs.readFile(new URL("../agent-plugin.json", import.meta.url), "utf8"),
134
+ );
135
+ process.stdout.write(`${JSON.stringify(manifest, null, 2)}\n`);
136
+ }
137
+
138
+ async function commandRule(values) {
139
+ const options = parseOptions(values, {
140
+ credentials: credentialsPath(),
141
+ timeout: 12,
142
+ json: false,
143
+ nowait: false,
144
+ });
145
+ const file = String(options._[0] || "");
146
+ if (!file) throw new Error("rule_requires_file");
147
+ const rule = validateRule(JSON.parse(await fs.readFile(file, "utf8")));
148
+ await send("set_rule", options, { rule });
149
+ }
150
+
151
+ async function commandDeepnight(values) {
152
+ const options = parseOptions(values, {
153
+ credentials: credentialsPath(),
154
+ timeout: 12,
155
+ json: false,
156
+ nowait: false,
157
+ });
158
+ await send("set_rule", options, { rule: DEEPNIGHT_RULE });
159
+ }
160
+
161
+ async function commandVolume(values) {
162
+ const options = parseOptions(values, {
163
+ credentials: credentialsPath(),
164
+ timeout: 12,
165
+ json: false,
166
+ nowait: false,
167
+ });
168
+ await send("set_volume", options, { volume: options._[0] });
169
+ }
170
+
171
+ async function commandSeek(values) {
172
+ const options = parseOptions(values, {
173
+ credentials: credentialsPath(),
174
+ timeout: 12,
175
+ json: false,
176
+ nowait: false,
177
+ });
178
+ await send("seek", options, { positionMs: options._[0] });
179
+ }
180
+
181
+ async function commandControl(name, values) {
182
+ const options = parseOptions(values, {
183
+ credentials: credentialsPath(),
184
+ timeout: 12,
185
+ json: false,
186
+ nowait: false,
187
+ });
188
+ await send(name, options);
189
+ }
190
+
191
+ async function send(name, options, payload = {}) {
192
+ const control = buildAudioControl(name, payload);
193
+ const credentials = await readCredentials(options.credentials);
194
+ const delivery = await publishAudioControl(credentials, control, {
195
+ timeoutMs: Number(options.timeout) * 1000,
196
+ wait: !options.nowait,
197
+ });
198
+ print(delivery.status || delivery, options.json);
199
+ if (delivery.status?.kind === "rejected") process.exitCode = 1;
200
+ }
201
+
202
+ function parseOptions(values, defaults = {}) {
203
+ const options = {
204
+ _: [],
205
+ ...defaults,
206
+ };
207
+ for (let index = 0; index < values.length; index += 1) {
208
+ const value = values[index];
209
+ if (!value.startsWith("--")) {
210
+ options._.push(value);
211
+ continue;
212
+ }
213
+ const key = value.slice(2);
214
+ if (key === "json" || key === "online" || key === "nowait") {
215
+ options[key] = true;
216
+ continue;
217
+ }
218
+ if (!new Set([
219
+ "agent",
220
+ "alias",
221
+ "credentials",
222
+ "endpoint",
223
+ "name",
224
+ "profile",
225
+ "timeout",
226
+ ]).has(key)) {
227
+ throw new Error(`option_unknown: --${key}`);
228
+ }
229
+ options[key] = values[++index];
230
+ if (options[key] === undefined) throw new Error(`option_value_missing: --${key}`);
231
+ }
232
+ return options;
233
+ }
234
+
235
+ function credentialsPath() {
236
+ return (
237
+ process.env.WEC_AUDIO_CREDENTIALS ||
238
+ process.env.WEC_MQTT_CREDENTIALS ||
239
+ DEFAULT_CREDENTIALS_PATH
240
+ );
241
+ }
242
+
243
+ function print(value, json) {
244
+ if (json) {
245
+ process.stdout.write(`${JSON.stringify(value)}\n`);
246
+ return;
247
+ }
248
+ if (value?.kind === "applied" || value?.kind === "rejected") {
249
+ const playback = summarizePlayback(value);
250
+ const track = playback.track || "no track";
251
+ process.stdout.write(
252
+ `${value.kind}: ${value.detail || ""}\n` +
253
+ `playback: ${playback.desired}, volume ${playback.volume ?? "-"}, ${track}\n`,
254
+ );
255
+ return;
256
+ }
257
+ process.stdout.write(`${JSON.stringify(value, null, 2)}\n`);
258
+ }
259
+
package/lib/index.mjs ADDED
@@ -0,0 +1,388 @@
1
+ import crypto from "node:crypto";
2
+ import fs from "node:fs/promises";
3
+ import os from "node:os";
4
+ import path from "node:path";
5
+
6
+ import mqtt from "mqtt";
7
+
8
+ export const PACKAGE_NAME = "@openbrt/audioctl";
9
+ export const COMMAND_NAME = "audioctl";
10
+ export const DEFAULT_ENDPOINT = "https://weclawbot.link/byoa";
11
+ export const DEFAULT_CREDENTIALS_PATH = path.join(
12
+ os.homedir(),
13
+ ".config",
14
+ "weclawbot",
15
+ "audio-mqtt.json",
16
+ );
17
+
18
+ export const DEEPNIGHT_RULE = Object.freeze({
19
+ schemaVersion: 1,
20
+ title: "深夜工作",
21
+ source: "网易云音乐官方歌单:深夜刷题党|轻缓律动伴你专注学习",
22
+ sourcePlaylist: Object.freeze({
23
+ originalId: "8383869866",
24
+ encryptedId: "661561242F7B3CE80B5AB760DB171EB4",
25
+ }),
26
+ fetchLimit: 60,
27
+ targetTrackCount: 20,
28
+ minimumTrackCount: 8,
29
+ qualityPolicy: "highest-available",
30
+ refreshHintSeconds: 21600,
31
+ });
32
+
33
+ export function validateBindingCode(code) {
34
+ const value = string(code).replace(/\s/gu, "");
35
+ if (!/^[A-Za-z0-9_-]{6,64}$/u.test(value)) {
36
+ throw new Error("binding_code_invalid");
37
+ }
38
+ return value;
39
+ }
40
+
41
+ export function expandPath(file) {
42
+ const value = string(file);
43
+ if (!value) return DEFAULT_CREDENTIALS_PATH;
44
+ if (value === "~") return os.homedir();
45
+ if (value.startsWith("~/")) return path.join(os.homedir(), value.slice(2));
46
+ return path.resolve(value);
47
+ }
48
+
49
+ export async function bindAgent(options = {}) {
50
+ const code = validateBindingCode(options.code);
51
+ const endpoint = string(options.endpoint) || DEFAULT_ENDPOINT;
52
+ const agentName = string(options.name || options.agentName || "user-agent")
53
+ .slice(0, 80);
54
+ const response = await fetch(endpoint, {
55
+ method: "POST",
56
+ headers: { "content-type": "application/json", accept: "application/json" },
57
+ body: JSON.stringify({
58
+ schema: "weclawbot.byoa.v1",
59
+ operation: "claim",
60
+ code,
61
+ agent_name: agentName,
62
+ }),
63
+ signal: AbortSignal.timeout(Math.max(1000, Number(options.timeoutMs) || 15_000)),
64
+ });
65
+ const payload = await response.json().catch(() => null);
66
+ if (
67
+ !response.ok ||
68
+ !payload?.ok ||
69
+ payload?.schema !== "weclawbot.byoa.agent_credentials.v1"
70
+ ) {
71
+ throw new Error(`pairing_failed: ${payload?.error || `HTTP ${response.status}`}`);
72
+ }
73
+
74
+ const credentials = {
75
+ schema: "weclawbot.agent_credentials.v1",
76
+ binding: payload.binding,
77
+ mqtt: payload.mqtt,
78
+ delivery: payload.delivery,
79
+ created_at: new Date().toISOString(),
80
+ };
81
+ normalizeCredentials(credentials);
82
+ const credentialsPath = expandPath(options.credentialsPath || options.credentials);
83
+ await writeCredentials(credentialsPath, credentials);
84
+ return { credentials, credentialsPath };
85
+ }
86
+
87
+ export async function writeCredentials(file, credentials) {
88
+ await fs.mkdir(path.dirname(file), { recursive: true, mode: 0o700 });
89
+ await fs.writeFile(file, `${JSON.stringify(credentials, null, 2)}\n`, {
90
+ mode: 0o600,
91
+ });
92
+ await fs.chmod(file, 0o600);
93
+ }
94
+
95
+ export async function readCredentials(file = DEFAULT_CREDENTIALS_PATH) {
96
+ const credentialsPath = expandPath(file);
97
+ let value;
98
+ try {
99
+ value = JSON.parse(await fs.readFile(credentialsPath, "utf8"));
100
+ } catch {
101
+ throw new Error(`credentials_unavailable: ${credentialsPath}`);
102
+ }
103
+ return value;
104
+ }
105
+
106
+ export function normalizeCredentials(value) {
107
+ const profile = value?.mqtt && typeof value.mqtt === "object" ? value.mqtt : value;
108
+ const topics = profile?.topics || {};
109
+ if (
110
+ typeof profile?.url !== "string" ||
111
+ !profile.url.startsWith("wss://") ||
112
+ typeof profile?.username !== "string" ||
113
+ typeof profile?.password !== "string" ||
114
+ typeof profile?.client_id !== "string" ||
115
+ typeof topics.control !== "string" ||
116
+ !topics.control
117
+ ) {
118
+ throw new Error("credentials_invalid");
119
+ }
120
+ return {
121
+ url: profile.url,
122
+ username: profile.username,
123
+ password: profile.password,
124
+ clientId: profile.client_id,
125
+ controlTopic: topics.control,
126
+ statusTopic: typeof topics.status === "string" ? topics.status : "",
127
+ eventTopic: typeof topics.events === "string" ? topics.events : "",
128
+ };
129
+ }
130
+
131
+ export async function testConnection(credentials, options = {}) {
132
+ const profile = normalizeCredentials(credentials);
133
+ const client = connectMqtt(profile, options);
134
+ try {
135
+ await onceConnected(client, options.timeoutMs);
136
+ } finally {
137
+ client.end(true);
138
+ }
139
+ return {
140
+ ok: true,
141
+ url: profile.url,
142
+ username: profile.username,
143
+ client_id: profile.clientId,
144
+ control_topic: profile.controlTopic,
145
+ status_topic: profile.statusTopic,
146
+ };
147
+ }
148
+
149
+ export function buildAudioControl(command, options = {}) {
150
+ const music = normalizeAudioCommand(command, options);
151
+ return {
152
+ schema: "weclawbot.control.v1",
153
+ id: string(options.id) || `audio_${crypto.randomUUID()}`,
154
+ kind: "music_command",
155
+ music,
156
+ };
157
+ }
158
+
159
+ export function normalizeAudioCommand(command, options = {}) {
160
+ const aliases = new Map([
161
+ ["resume", "play"],
162
+ ["prev", "previous"],
163
+ ["volume-up", "volume_up"],
164
+ ["volumeup", "volume_up"],
165
+ ["volup", "volume_up"],
166
+ ["volume-down", "volume_down"],
167
+ ["volumedown", "volume_down"],
168
+ ["voldown", "volume_down"],
169
+ ["reload", "reload"],
170
+ ["refresh", "reload"],
171
+ ]);
172
+ const raw = string(command).replace(/_/gu, "-").toLowerCase();
173
+ const normalized = aliases.get(raw) || raw.replace(/-/gu, "_");
174
+ const supported = new Set([
175
+ "play",
176
+ "pause",
177
+ "toggle",
178
+ "next",
179
+ "previous",
180
+ "seek",
181
+ "set_volume",
182
+ "volume_up",
183
+ "volume_down",
184
+ "reload",
185
+ "status",
186
+ "set_rule",
187
+ ]);
188
+ if (!supported.has(normalized)) throw new Error(`command_unsupported: ${command}`);
189
+
190
+ const music = { command: normalized };
191
+ if (normalized === "set_volume") {
192
+ const volume = Number(options.volume ?? options.value);
193
+ if (!Number.isFinite(volume) || volume < 0 || volume > 100) {
194
+ throw new Error("volume_invalid");
195
+ }
196
+ music.volume = Math.round(volume);
197
+ }
198
+ if (normalized === "seek") {
199
+ const position = Number(options.positionMs ?? options.position_ms ?? options.value);
200
+ if (!Number.isFinite(position) || position < 0) {
201
+ throw new Error("position_invalid");
202
+ }
203
+ music.position_ms = Math.round(position);
204
+ }
205
+ if (normalized === "set_rule") {
206
+ music.rule = validateRule(options.rule);
207
+ }
208
+ return music;
209
+ }
210
+
211
+ export function validateRule(rule) {
212
+ if (
213
+ !rule ||
214
+ rule.schemaVersion !== 1 ||
215
+ typeof rule.title !== "string" ||
216
+ !rule.title.trim() ||
217
+ !/^[0-9A-F]+$/u.test(rule.sourcePlaylist?.encryptedId || "")
218
+ ) {
219
+ throw new Error("rule_invalid");
220
+ }
221
+ return {
222
+ schemaVersion: 1,
223
+ title: rule.title.slice(0, 120),
224
+ source: string(rule.source || "NetEase Cloud Music").slice(0, 240),
225
+ sourcePlaylist: {
226
+ originalId: string(rule.sourcePlaylist.originalId),
227
+ encryptedId: string(rule.sourcePlaylist.encryptedId),
228
+ },
229
+ fetchLimit: clamp(Number(rule.fetchLimit) || 60, 10, 100),
230
+ targetTrackCount: clamp(Number(rule.targetTrackCount) || 20, 1, 40),
231
+ minimumTrackCount: clamp(Number(rule.minimumTrackCount) || 8, 1, 40),
232
+ qualityPolicy:
233
+ rule.qualityPolicy === "highest-available" ? "highest-available" : "bounded",
234
+ bitrateKbps: clamp(Number(rule.bitrateKbps) || 320, 64, 999_999),
235
+ refreshHintSeconds: Math.max(900, Number(rule.refreshHintSeconds) || 2700),
236
+ };
237
+ }
238
+
239
+ export async function publishAudioControl(credentials, control, options = {}) {
240
+ const profile = normalizeCredentials(credentials);
241
+ if (options.wait !== false && !profile.statusTopic) {
242
+ throw new Error("status_topic_missing");
243
+ }
244
+ const timeoutMs = Math.max(1000, Number(options.timeoutMs) || 12_000);
245
+ const client = connectMqtt(profile, { timeoutMs });
246
+ try {
247
+ await onceConnected(client, timeoutMs);
248
+ if (options.wait === false) {
249
+ await publishJson(client, profile.controlTopic, control);
250
+ return { ok: true, published: true, applied: null };
251
+ }
252
+ await subscribe(client, profile.statusTopic);
253
+ const statusPromise = waitForStatus(client, {
254
+ correlationId: control.id,
255
+ timeoutMs,
256
+ });
257
+ await publishJson(client, profile.controlTopic, control);
258
+ const status = await statusPromise;
259
+ return { ok: status.kind === "applied", published: true, applied: true, status };
260
+ } finally {
261
+ client.end(true);
262
+ }
263
+ }
264
+
265
+ export function buildAgentPrompt(options = {}) {
266
+ const code = validateBindingCode(options.code);
267
+ const agentName = string(options.agentName || options.name || "agent");
268
+ const alias = string(options.alias || "音频设备");
269
+ const profile = string(options.profile || "深夜工作");
270
+ const expires = Number(options.expiresAt || options.expires_at || 0);
271
+ const expiresLine = expires > 0 ? `绑定码过期时间:${new Date(expires).toISOString()}` : "";
272
+ return [
273
+ `请作为我的 AI agent 绑定并控制一台 WeClawBot 音频输出设备。`,
274
+ `设备别名:${alias}`,
275
+ `绑定码:${code}`,
276
+ expiresLine,
277
+ `请在你的运行环境安装 npm 包 ${PACKAGE_NAME},然后执行:`,
278
+ `npm exec --package ${PACKAGE_NAME} -- ${COMMAND_NAME} bind ${code} --name ${shellToken(agentName)}`,
279
+ `npm exec --package ${PACKAGE_NAME} -- ${COMMAND_NAME} doctor --online`,
280
+ `绑定成功后,把它作为默认 BGM/音乐输出设备。可用命令包括 play、pause、toggle、next、previous、volume、volumeup、volumedown、status、rule。`,
281
+ `如果我要播放「${profile}」,请优先执行:npm exec --package ${PACKAGE_NAME} -- ${COMMAND_NAME} deepnight`,
282
+ `不要向设备写入音乐平台私钥;音乐平台授权、歌单刷新和长期规则由设备侧已配置的安全运行时处理。`,
283
+ ].filter(Boolean).join("\n");
284
+ }
285
+
286
+ export function summarizePlayback(status) {
287
+ const playback = status?.playback || {};
288
+ const track = playback.track
289
+ ? `${playback.track.artist || ""} - ${playback.track.title || ""}`
290
+ .replace(/^\s*-\s*|\s*-\s*$/gu, "")
291
+ : "";
292
+ return {
293
+ desired: string(playback.desired || "unknown"),
294
+ volume: playback.volume ?? null,
295
+ playlist: string(playback.playlist),
296
+ track_index: playback.track_index ?? null,
297
+ track_count: playback.track_count ?? null,
298
+ track,
299
+ };
300
+ }
301
+
302
+ function connectMqtt(profile, options = {}) {
303
+ const timeoutMs = Math.max(1000, Number(options.timeoutMs) || 12_000);
304
+ return mqtt.connect(profile.url, {
305
+ clientId: profile.clientId,
306
+ username: profile.username,
307
+ password: profile.password,
308
+ clean: true,
309
+ reconnectPeriod: 0,
310
+ connectTimeout: timeoutMs,
311
+ protocolVersion: 4,
312
+ });
313
+ }
314
+
315
+ function onceConnected(client, timeoutMs = 12_000) {
316
+ return new Promise((resolve, reject) => {
317
+ const timer = setTimeout(() => finish(new Error("mqtt_connect_timeout")), timeoutMs);
318
+ const finish = (error) => {
319
+ clearTimeout(timer);
320
+ client.removeListener("connect", onConnect);
321
+ client.removeListener("error", onError);
322
+ error ? reject(error) : resolve();
323
+ };
324
+ const onConnect = () => finish();
325
+ const onError = (error) => finish(error);
326
+ client.once("connect", onConnect);
327
+ client.once("error", onError);
328
+ });
329
+ }
330
+
331
+ function publishJson(client, topic, value) {
332
+ return new Promise((resolve, reject) => {
333
+ client.publish(topic, JSON.stringify(value), { qos: 1, retain: false }, (error) => {
334
+ error ? reject(error) : resolve();
335
+ });
336
+ });
337
+ }
338
+
339
+ function subscribe(client, topic) {
340
+ return new Promise((resolve, reject) => {
341
+ client.subscribe(topic, { qos: 1 }, (error) => {
342
+ error ? reject(error) : resolve();
343
+ });
344
+ });
345
+ }
346
+
347
+ function waitForStatus(client, options) {
348
+ return new Promise((resolve, reject) => {
349
+ const timer = setTimeout(
350
+ () => finish(new Error("device_status_timeout")),
351
+ options.timeoutMs,
352
+ );
353
+ const finish = (error, value) => {
354
+ clearTimeout(timer);
355
+ client.removeListener("message", onMessage);
356
+ error ? reject(error) : resolve(value);
357
+ };
358
+ const onMessage = (_topic, payload) => {
359
+ try {
360
+ const status = JSON.parse(payload.toString("utf8"));
361
+ if (
362
+ status?.schema === "weclawbot.device_status.v1" &&
363
+ status.correlation_id === options.correlationId &&
364
+ new Set(["applied", "rejected"]).has(status.kind)
365
+ ) {
366
+ finish(null, status);
367
+ }
368
+ } catch {
369
+ // Ignore unrelated or malformed status traffic.
370
+ }
371
+ };
372
+ client.on("message", onMessage);
373
+ });
374
+ }
375
+
376
+ function shellToken(value) {
377
+ const text = string(value) || "agent";
378
+ if (/^[A-Za-z0-9_./:@+-]+$/u.test(text)) return text;
379
+ return JSON.stringify(text);
380
+ }
381
+
382
+ function clamp(value, minimum, maximum) {
383
+ return Math.min(maximum, Math.max(minimum, Math.round(value)));
384
+ }
385
+
386
+ function string(value) {
387
+ return typeof value === "string" ? value.trim() : "";
388
+ }
package/package.json ADDED
@@ -0,0 +1,50 @@
1
+ {
2
+ "name": "@openbrt/audioctl",
3
+ "version": "0.1.0",
4
+ "description": "Generic WeClawBot audio-output control CLI and AI-agent plugin.",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "keywords": [
8
+ "weclawbot",
9
+ "audio",
10
+ "music",
11
+ "mqtt",
12
+ "esp32",
13
+ "agent",
14
+ "byoa",
15
+ "openclaw",
16
+ "codex",
17
+ "claude-code",
18
+ "hermes",
19
+ "workbuddy",
20
+ "netease-cloud-music"
21
+ ],
22
+ "engines": {
23
+ "node": ">=20"
24
+ },
25
+ "publishConfig": {
26
+ "access": "public"
27
+ },
28
+ "files": [
29
+ "agent-plugin.json",
30
+ "bin",
31
+ "lib",
32
+ "rules",
33
+ "README.md"
34
+ ],
35
+ "bin": {
36
+ "audioctl": "bin/audioctl.mjs"
37
+ },
38
+ "exports": {
39
+ ".": "./lib/index.mjs",
40
+ "./manifest": "./agent-plugin.json",
41
+ "./rules/deepnight": "./rules/deepnight.json",
42
+ "./package.json": "./package.json"
43
+ },
44
+ "scripts": {
45
+ "check": "node --check lib/index.mjs && node --check bin/audioctl.mjs && node --test test/*.test.mjs"
46
+ },
47
+ "dependencies": {
48
+ "mqtt": "^5.15.1"
49
+ }
50
+ }
@@ -0,0 +1,14 @@
1
+ {
2
+ "schemaVersion": 1,
3
+ "title": "深夜工作",
4
+ "source": "网易云音乐官方歌单:深夜刷题党|轻缓律动伴你专注学习",
5
+ "sourcePlaylist": {
6
+ "originalId": "8383869866",
7
+ "encryptedId": "661561242F7B3CE80B5AB760DB171EB4"
8
+ },
9
+ "fetchLimit": 60,
10
+ "targetTrackCount": 20,
11
+ "minimumTrackCount": 8,
12
+ "qualityPolicy": "highest-available",
13
+ "refreshHintSeconds": 21600
14
+ }