@openbrt/audioctl 0.1.3 → 0.1.4
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 +30 -13
- package/agent-plugin.json +26 -5
- package/bin/audioctl.mjs +27 -0
- package/lib/index.mjs +154 -11
- package/package.json +6 -2
package/README.md
CHANGED
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
# @openbrt/audioctl
|
|
2
2
|
|
|
3
|
-
Generic WeClawBot audio
|
|
3
|
+
Generic WeClawBot audio/voice endpoint control CLI and AI-agent plugin.
|
|
4
4
|
|
|
5
5
|
It is meant for user-owned agents such as Codex, Claude Code, OpenClaw, Hermes,
|
|
6
6
|
WorkBuddy, Gemini CLI, or any shell-capable agent runtime. It is not tied to one
|
|
7
|
-
speaker brand. Any ESP32/Linux audio
|
|
8
|
-
|
|
7
|
+
speaker brand. Any ESP32/Linux audio or voice endpoint that implements the
|
|
8
|
+
WeClawBot MQTT/app envelope can use the same package. Music playback is one app
|
|
9
|
+
on top of the endpoint; the same VM/capability model can later host TTS, voice
|
|
10
|
+
remote control, smart-home control, and voice interaction apps.
|
|
9
11
|
|
|
10
12
|
## Tell your agent
|
|
11
13
|
|
|
@@ -15,11 +17,17 @@ audio command envelope can use the same package.
|
|
|
15
17
|
|
|
16
18
|
Agent contract:
|
|
17
19
|
|
|
18
|
-
- If already bound, run the high-level task directly
|
|
20
|
+
- If already bound, inspect the device and run the high-level task directly.
|
|
19
21
|
- If not bound, ask the user only for the short binding code, then bind and continue.
|
|
20
|
-
-
|
|
21
|
-
|
|
22
|
-
|
|
22
|
+
- Preferred BYOA flow: use a legal music platform API in the agent environment
|
|
23
|
+
to build a playable queue, then push it with `audioctl queue QUEUE.json --play --json`.
|
|
24
|
+
NetEase Cloud Music Open Platform can be used this way to request the highest
|
|
25
|
+
available quality URLs. Spotify should be driven through official Spotify
|
|
26
|
+
Connect/Web Playback SDK or existing-user playback control; do not assume the
|
|
27
|
+
Spotify Web API gives full-track direct audio URLs.
|
|
28
|
+
- If music-platform authorization is missing, guide the user through the
|
|
29
|
+
platform authorization entry available to the agent/phone, then continue after
|
|
30
|
+
the user says “已授权”.
|
|
23
31
|
- Never ask the user to paste AppSecret, PrivateKey, accessToken, refreshToken,
|
|
24
32
|
Wi-Fi passwords, MQTT credentials, or other secrets into chat.
|
|
25
33
|
|
|
@@ -53,6 +61,7 @@ audioctl volumeup
|
|
|
53
61
|
audioctl volumedown
|
|
54
62
|
audioctl status
|
|
55
63
|
audioctl inspect --json
|
|
64
|
+
audioctl queue ./work-bgm.queue.json --play
|
|
56
65
|
audioctl workbgm --play
|
|
57
66
|
audioctl rule ./my-playlist-rule.json
|
|
58
67
|
audioctl deepnight
|
|
@@ -64,16 +73,24 @@ existing MQTT channel. Agent scripts can read Wi-Fi connection state, BLE
|
|
|
64
73
|
provisioning state, wake-word capability, playback, VM/app status, and health
|
|
65
74
|
without receiving Wi-Fi passwords, MQTT credentials, or music-platform secrets.
|
|
66
75
|
|
|
76
|
+
`queue --play` is the generic agent-built music path. The agent uses the user's
|
|
77
|
+
authorized music provider outside the speaker, writes a
|
|
78
|
+
`weclawbot.playable_queue.v1`/`schemaVersion: 1` queue containing only safe media
|
|
79
|
+
URLs and redacted metadata, then pushes it to the device. The queue must not
|
|
80
|
+
contain platform private keys, OAuth tokens, cookies, custom request headers, or
|
|
81
|
+
Wi-Fi/MQTT credentials. Expiring media URLs are allowed; refreshing them is the
|
|
82
|
+
agent/app's job.
|
|
83
|
+
|
|
67
84
|
`workbgm --play` installs a persistent `work-bgm-autopilot` VM app on the audio
|
|
68
85
|
device, writes the bundled 工作 BGM playlist rule through the device-side music
|
|
69
86
|
adapter, requests a refresh, and starts playback once. The app expresses a
|
|
70
87
|
continuous BGM intent and respects later user-initiated pause/resume controls.
|
|
71
|
-
If the device reports `netease_auth_refresh_failed`,
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
the user
|
|
76
|
-
|
|
88
|
+
If the device reports `netease_auth_refresh_failed`, that only means the legacy
|
|
89
|
+
device-side NetEase runtime cannot refresh itself. The better agent behavior is
|
|
90
|
+
to switch to the BYOA queue/app path: obtain the user's music-platform identity
|
|
91
|
+
in the agent environment, build a fresh queue, and push it with `audioctl queue`.
|
|
92
|
+
Only if the user explicitly wants the device-side NetEase runtime should the
|
|
93
|
+
agent route them to the phone authorization entry.
|
|
77
94
|
|
|
78
95
|
`deepnight` pushes the bundled “深夜工作” playlist rule and asks the device to
|
|
79
96
|
refresh/play according to its local runtime. The package never contains music
|
package/agent-plugin.json
CHANGED
|
@@ -4,11 +4,14 @@
|
|
|
4
4
|
"name": "audioctl",
|
|
5
5
|
"package": "@openbrt/audioctl",
|
|
6
6
|
"bin": "audioctl",
|
|
7
|
-
"description": "Bind and
|
|
7
|
+
"description": "Bind and drive WeClawBot-compatible audio, voice, and ambient I/O devices from user-owned AI agents.",
|
|
8
8
|
"transport": "weclawbot.link/mqtt",
|
|
9
|
-
"user_contract": "用户只需要给出设备身份和目标;agent
|
|
9
|
+
"user_contract": "用户只需要给出设备身份和目标;agent 负责选择合法平台 API、完成授权引导、构建最小权限脚本/队列、推送到设备 VM、诊断和报告结果。",
|
|
10
10
|
"device_classes": [
|
|
11
|
+
"audio_io",
|
|
11
12
|
"audio_output",
|
|
13
|
+
"voice_endpoint",
|
|
14
|
+
"smart_home_endpoint",
|
|
12
15
|
"music_player"
|
|
13
16
|
],
|
|
14
17
|
"agents": [
|
|
@@ -28,9 +31,15 @@
|
|
|
28
31
|
"audio.previous",
|
|
29
32
|
"audio.seek",
|
|
30
33
|
"audio.set_volume",
|
|
34
|
+
"audio.queue.write",
|
|
35
|
+
"audio.tts",
|
|
36
|
+
"voice.wakeword",
|
|
37
|
+
"voice.capture",
|
|
38
|
+
"iot.control",
|
|
31
39
|
"device.inspect",
|
|
32
40
|
"music.autopilot",
|
|
33
41
|
"music.set_rule",
|
|
42
|
+
"music.set_queue",
|
|
34
43
|
"music.work_bgm",
|
|
35
44
|
"music.status",
|
|
36
45
|
"app.install"
|
|
@@ -44,10 +53,18 @@
|
|
|
44
53
|
"natural_language_tasks": [
|
|
45
54
|
{
|
|
46
55
|
"intent": "帮我在音箱建立一个工作 BGM 歌单,持续播放",
|
|
47
|
-
"primary_command": "audioctl
|
|
56
|
+
"primary_command": "audioctl queue QUEUE.json --play --json",
|
|
57
|
+
"agent_strategy": "先运行 audioctl inspect --json。优先在 agent 侧使用用户授权的音乐平台 API 生成 QUEUE.json,再运行 audioctl queue QUEUE.json --play --json;网易云开放平台可生成最高可用音质播放 URL。Spotify 必须走官方 Spotify Connect/Web Playback SDK 或控制用户已有 Spotify 设备,不能假设 Web API 能提供完整歌曲直链。如果 agent 当前没有音乐平台身份,只要求用户在 agent/手机授权入口完成对应平台授权。",
|
|
58
|
+
"fallback_command": "audioctl workbgm --play --json",
|
|
48
59
|
"if_unbound": "只向用户索要绑定码,然后运行 audioctl bind CODE --name AGENT;不要要求用户理解 MQTT、topic 或凭据文件。",
|
|
49
|
-
"
|
|
50
|
-
"
|
|
60
|
+
"if_music_platform_auth_missing": "告诉用户:我需要你给这个 agent 一个音乐平台身份。请在 agent 支持的网易云开放平台或 Spotify 官方授权入口完成授权;完成后只需回复“已授权”。不要把 AppSecret、PrivateKey、accessToken、refreshToken、Wi-Fi 密码或 MQTT 凭据粘贴到聊天里。",
|
|
61
|
+
"if_device_side_netease_auth_failed": "这是设备内置网易运行时的旧授权失效。优先不要让用户折腾原厂 App;改由 agent 侧使用用户授权的音乐平台 API 生成队列并推送。只有当用户明确要继续使用设备侧网易运行时时,才引导手机端重新授权。",
|
|
62
|
+
"after_user_confirms_authorized": "agent 使用已授权的平台 API 生成 QUEUE.json,运行 audioctl queue QUEUE.json --play --json,然后报告是否 active/degraded。"
|
|
63
|
+
},
|
|
64
|
+
{
|
|
65
|
+
"intent": "让音箱作为语音遥控、家庭智能或语音互动端点",
|
|
66
|
+
"primary_command": "audioctl app install MANIFEST.json APP.wasm",
|
|
67
|
+
"agent_strategy": "不要把设备假定为音乐播放器。先运行 audioctl inspect --json 读取设备能力,再生成声明最小 capabilities 的 VM app。语音、TTS、家居控制等能力必须通过 manifest capabilities 显式声明;设备侧只执行授权范围内的 I/O。"
|
|
51
68
|
}
|
|
52
69
|
],
|
|
53
70
|
"commands": [
|
|
@@ -75,6 +92,10 @@
|
|
|
75
92
|
"name": "rule",
|
|
76
93
|
"usage": "audioctl rule RULE.json"
|
|
77
94
|
},
|
|
95
|
+
{
|
|
96
|
+
"name": "queue",
|
|
97
|
+
"usage": "audioctl queue QUEUE.json [--play]"
|
|
98
|
+
},
|
|
78
99
|
{
|
|
79
100
|
"name": "app install",
|
|
80
101
|
"usage": "audioctl app install MANIFEST.json APP.wasm"
|
package/bin/audioctl.mjs
CHANGED
|
@@ -13,6 +13,7 @@ import {
|
|
|
13
13
|
buildAppInstallControl,
|
|
14
14
|
buildAgentPrompt,
|
|
15
15
|
buildAudioControl,
|
|
16
|
+
buildQueueControl,
|
|
16
17
|
buildWorkBgmAppInstallControl,
|
|
17
18
|
expandPath,
|
|
18
19
|
normalizeCredentials,
|
|
@@ -45,6 +46,7 @@ async function run(name, values) {
|
|
|
45
46
|
if (name === "prompt") return commandPrompt(values);
|
|
46
47
|
if (name === "manifest") return commandManifest();
|
|
47
48
|
if (name === "app") return commandApp(values);
|
|
49
|
+
if (name === "queue") return commandQueue(values);
|
|
48
50
|
if (name === "rule") return commandRule(values);
|
|
49
51
|
if (name === "workbgm") return commandWorkBgm(values);
|
|
50
52
|
if (name === "deepnight") return commandDeepnight(values);
|
|
@@ -63,6 +65,7 @@ function usage(exitCode = 0) {
|
|
|
63
65
|
${COMMAND_NAME} volume 0..100
|
|
64
66
|
${COMMAND_NAME} seek MILLISECONDS
|
|
65
67
|
${COMMAND_NAME} rule RULE.json
|
|
68
|
+
${COMMAND_NAME} queue QUEUE.json [--play]
|
|
66
69
|
${COMMAND_NAME} app install MANIFEST.json APP.wasm
|
|
67
70
|
${COMMAND_NAME} workbgm [--play]
|
|
68
71
|
${COMMAND_NAME} deepnight
|
|
@@ -202,6 +205,30 @@ async function commandRule(values) {
|
|
|
202
205
|
await send("set_rule", options, { rule });
|
|
203
206
|
}
|
|
204
207
|
|
|
208
|
+
async function commandQueue(values) {
|
|
209
|
+
const options = parseOptions(values, {
|
|
210
|
+
credentials: credentialsPath(),
|
|
211
|
+
timeout: 12,
|
|
212
|
+
json: false,
|
|
213
|
+
nowait: false,
|
|
214
|
+
play: false,
|
|
215
|
+
});
|
|
216
|
+
const file = String(options._[0] || "");
|
|
217
|
+
if (!file) throw new Error("queue_requires_file");
|
|
218
|
+
const queue = JSON.parse(await fs.readFile(file, "utf8"));
|
|
219
|
+
const credentials = await readCredentials(options.credentials);
|
|
220
|
+
const delivery = await publishAudioControl(
|
|
221
|
+
credentials,
|
|
222
|
+
buildQueueControl({ queue, play: options.play }),
|
|
223
|
+
{
|
|
224
|
+
timeoutMs: Number(options.timeout) * 1000,
|
|
225
|
+
wait: !options.nowait,
|
|
226
|
+
},
|
|
227
|
+
);
|
|
228
|
+
print(delivery.status || delivery, options.json);
|
|
229
|
+
if (delivery.status?.kind === "rejected") process.exitCode = 1;
|
|
230
|
+
}
|
|
231
|
+
|
|
205
232
|
async function commandApp(values) {
|
|
206
233
|
const subcommand = String(values[0] || "");
|
|
207
234
|
if (subcommand !== "install") {
|
package/lib/index.mjs
CHANGED
|
@@ -45,6 +45,8 @@ export const WORKBGM_RULE = Object.freeze({
|
|
|
45
45
|
refreshHintSeconds: 21600,
|
|
46
46
|
});
|
|
47
47
|
|
|
48
|
+
export const PLAYABLE_QUEUE_SCHEMA = "weclawbot.playable_queue.v1";
|
|
49
|
+
|
|
48
50
|
const WORKBGM_APP_WASM_BASE64 =
|
|
49
51
|
"AGFzbQEAAAABBAFgAAADAgEABwgBBG1haW4AAAoEAQIACw==";
|
|
50
52
|
|
|
@@ -174,6 +176,15 @@ export function buildAudioControl(command, options = {}) {
|
|
|
174
176
|
};
|
|
175
177
|
}
|
|
176
178
|
|
|
179
|
+
export function buildQueueControl(options = {}) {
|
|
180
|
+
return buildAudioControl("set_queue", {
|
|
181
|
+
id: options.id,
|
|
182
|
+
queue: options.queue,
|
|
183
|
+
play: options.play,
|
|
184
|
+
autoplay: options.autoplay,
|
|
185
|
+
});
|
|
186
|
+
}
|
|
187
|
+
|
|
177
188
|
export function buildAppInstallControl(options = {}) {
|
|
178
189
|
const wasm =
|
|
179
190
|
Buffer.isBuffer(options.wasm) || options.wasm instanceof Uint8Array
|
|
@@ -270,6 +281,7 @@ export function normalizeAudioCommand(command, options = {}) {
|
|
|
270
281
|
"reload",
|
|
271
282
|
"status",
|
|
272
283
|
"set_rule",
|
|
284
|
+
"set_queue",
|
|
273
285
|
]);
|
|
274
286
|
if (!supported.has(normalized)) throw new Error(`command_unsupported: ${command}`);
|
|
275
287
|
|
|
@@ -291,9 +303,79 @@ export function normalizeAudioCommand(command, options = {}) {
|
|
|
291
303
|
if (normalized === "set_rule") {
|
|
292
304
|
music.rule = validateRule(options.rule);
|
|
293
305
|
}
|
|
306
|
+
if (normalized === "set_queue") {
|
|
307
|
+
music.queue = validatePlayableQueue(options.queue);
|
|
308
|
+
music.play = options.play === true || options.autoplay === true;
|
|
309
|
+
}
|
|
294
310
|
return music;
|
|
295
311
|
}
|
|
296
312
|
|
|
313
|
+
export function validatePlayableQueue(queue) {
|
|
314
|
+
if (!queue || typeof queue !== "object") {
|
|
315
|
+
throw new Error("queue_invalid");
|
|
316
|
+
}
|
|
317
|
+
const schemaOk =
|
|
318
|
+
queue.schema === PLAYABLE_QUEUE_SCHEMA ||
|
|
319
|
+
queue.schemaVersion === 1;
|
|
320
|
+
if (!schemaOk || !Array.isArray(queue.tracks) || queue.tracks.length < 1) {
|
|
321
|
+
throw new Error("queue_invalid");
|
|
322
|
+
}
|
|
323
|
+
if (queue.tracks.length > 80) {
|
|
324
|
+
throw new Error("queue_too_large");
|
|
325
|
+
}
|
|
326
|
+
const normalizedTracks = queue.tracks.map((track, index) => {
|
|
327
|
+
if (!track || typeof track !== "object") {
|
|
328
|
+
throw new Error(`queue_track_invalid:${index + 1}`);
|
|
329
|
+
}
|
|
330
|
+
const forbiddenKeys = [
|
|
331
|
+
"headers",
|
|
332
|
+
"cookies",
|
|
333
|
+
"cookie",
|
|
334
|
+
"authorization",
|
|
335
|
+
"accessToken",
|
|
336
|
+
"access_token",
|
|
337
|
+
"refreshToken",
|
|
338
|
+
"refresh_token",
|
|
339
|
+
"privateKey",
|
|
340
|
+
"private_key",
|
|
341
|
+
"appSecret",
|
|
342
|
+
"app_secret",
|
|
343
|
+
];
|
|
344
|
+
for (const key of forbiddenKeys) {
|
|
345
|
+
if (Object.hasOwn(track, key)) {
|
|
346
|
+
throw new Error(`queue_track_secret_field_blocked:${key}`);
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
const url = normalizeMediaUrl(track.url, index + 1);
|
|
350
|
+
return {
|
|
351
|
+
title: string(track.title || `Track ${index + 1}`).slice(0, 160),
|
|
352
|
+
artist: string(track.artist).slice(0, 160),
|
|
353
|
+
originalId: string(track.originalId || track.original_id || track.id || index + 1).slice(0, 120),
|
|
354
|
+
encryptedId: string(track.encryptedId || track.encrypted_id).slice(0, 160),
|
|
355
|
+
provider: string(track.provider || queue.provider || "agent").slice(0, 64),
|
|
356
|
+
durationMs: clamp(Number(track.durationMs || track.duration_ms) || 0, 0, 24 * 60 * 60 * 1000),
|
|
357
|
+
bitrate: clamp(Number(track.bitrate) || 0, 0, 999_999),
|
|
358
|
+
format: string(track.format).slice(0, 32),
|
|
359
|
+
url,
|
|
360
|
+
};
|
|
361
|
+
});
|
|
362
|
+
return {
|
|
363
|
+
schema: PLAYABLE_QUEUE_SCHEMA,
|
|
364
|
+
schemaVersion: 1,
|
|
365
|
+
title: string(queue.title || "Agent playlist").slice(0, 120),
|
|
366
|
+
source: string(queue.source || "agent-built playable queue").slice(0, 240),
|
|
367
|
+
provider: string(queue.provider || "agent").slice(0, 64),
|
|
368
|
+
generatedBy: string(queue.generatedBy || queue.generated_by || "audioctl-agent").slice(0, 120),
|
|
369
|
+
generatedAt: string(queue.generatedAt || queue.generated_at) || new Date().toISOString(),
|
|
370
|
+
expiresAt: string(queue.expiresAt || queue.expires_at).slice(0, 80),
|
|
371
|
+
expiresAtEpoch: Math.max(0, Math.floor(Number(queue.expiresAtEpoch || queue.expires_at_epoch) || 0)),
|
|
372
|
+
refreshHintSeconds: Math.max(0, Math.floor(Number(queue.refreshHintSeconds || queue.refresh_hint_seconds) || 0)),
|
|
373
|
+
loop: queue.loop !== false,
|
|
374
|
+
respectUserPause: queue.respectUserPause !== false && queue.respect_user_pause !== false,
|
|
375
|
+
tracks: normalizedTracks,
|
|
376
|
+
};
|
|
377
|
+
}
|
|
378
|
+
|
|
297
379
|
export function validateRule(rule) {
|
|
298
380
|
if (
|
|
299
381
|
!rule ||
|
|
@@ -339,7 +421,15 @@ export function normalizeAppManifest(manifest, artifact = {}) {
|
|
|
339
421
|
"log.emit",
|
|
340
422
|
"music.state.read",
|
|
341
423
|
"music.rule.write",
|
|
424
|
+
"music.queue.write",
|
|
342
425
|
"music.playback.write",
|
|
426
|
+
"audio.queue.write",
|
|
427
|
+
"audio.playback.write",
|
|
428
|
+
"audio.output.write",
|
|
429
|
+
"audio.tts",
|
|
430
|
+
"voice.wakeword",
|
|
431
|
+
"voice.capture",
|
|
432
|
+
"iot.control",
|
|
343
433
|
]);
|
|
344
434
|
const capabilities = Array.isArray(manifest.capabilities)
|
|
345
435
|
? manifest.capabilities.map((capability) => string(capability)).filter(Boolean)
|
|
@@ -463,15 +553,17 @@ export function remediationForStatus(status) {
|
|
|
463
553
|
user_action_required: true,
|
|
464
554
|
safe_to_retry_without_user: false,
|
|
465
555
|
summary:
|
|
466
|
-
"
|
|
556
|
+
"设备内置网易运行时的用户授权已过期。优先改走 BYOA:由 agent 使用用户授权的音乐平台 API 生成播放队列并推送到设备。",
|
|
467
557
|
user_message:
|
|
468
|
-
"
|
|
469
|
-
agent_after_user_confirms:
|
|
558
|
+
"我需要一个可用的音乐平台身份来生成 BGM 队列。请在 agent 支持的网易云开放平台或 Spotify 官方授权入口完成授权;完成后回到 agent 说“已授权”。不要发送任何密钥、token 或密码。",
|
|
559
|
+
agent_after_user_confirms:
|
|
560
|
+
"generate QUEUE.json with the authorized music platform API, then run audioctl queue QUEUE.json --play --json",
|
|
470
561
|
steps: [
|
|
471
|
-
"
|
|
472
|
-
"
|
|
562
|
+
"优先使用 agent 侧 BYOA 音乐驱动:让用户在 agent 支持的网易云开放平台或 Spotify 官方授权入口完成授权。",
|
|
563
|
+
"网易云开放平台可由 agent 请求最高可用音质 URL,生成 weclawbot.playable_queue.v1 队列,然后运行 audioctl queue QUEUE.json --play --json。",
|
|
564
|
+
"Spotify 必须走官方 Spotify Connect/Web Playback SDK 或控制用户已有 Spotify 设备;不要声称 Spotify Web API 能提供完整歌曲直链。",
|
|
473
565
|
"不要要求用户理解内部授权包,也不要让用户粘贴 AppSecret、PrivateKey、accessToken、refreshToken、Wi-Fi 密码或 MQTT 凭据。",
|
|
474
|
-
"
|
|
566
|
+
"只有用户明确要求继续使用设备侧网易运行时时,才引导用户去手机端配网/授权入口重新授权,然后重试 audioctl workbgm --play --json。",
|
|
475
567
|
],
|
|
476
568
|
});
|
|
477
569
|
}
|
|
@@ -503,18 +595,20 @@ export function buildAgentPrompt(options = {}) {
|
|
|
503
595
|
const expires = Number(options.expiresAt || options.expires_at || 0);
|
|
504
596
|
const expiresLine = expires > 0 ? `绑定码过期时间:${new Date(expires).toISOString()}` : "";
|
|
505
597
|
return [
|
|
506
|
-
`请作为我的 AI agent 绑定并控制一台 WeClawBot
|
|
598
|
+
`请作为我的 AI agent 绑定并控制一台 WeClawBot 音频/语音/环境 I/O 设备。`,
|
|
507
599
|
`设备别名:${alias}`,
|
|
508
600
|
`绑定码:${code}`,
|
|
509
601
|
expiresLine,
|
|
510
602
|
`请在你的运行环境安装 npm 包 ${PACKAGE_NAME},然后执行:`,
|
|
511
603
|
`npm exec --package ${PACKAGE_NAME} -- ${COMMAND_NAME} bind ${code} --name ${shellToken(agentName)}`,
|
|
512
604
|
`npm exec --package ${PACKAGE_NAME} -- ${COMMAND_NAME} doctor --online`,
|
|
513
|
-
|
|
514
|
-
`如果我要建立“工作 BGM
|
|
515
|
-
|
|
605
|
+
`绑定成功后,把它作为默认音频/语音/环境 I/O 端点;不要把它假定成只会播放音乐。可用命令包括 play、pause、toggle、next、previous、volume、volumeup、volumedown、status、inspect、queue、rule、workbgm、app install。`,
|
|
606
|
+
`如果我要建立“工作 BGM 歌单,持续播放”,优先由你在 agent 侧使用用户授权的音乐平台 API 生成 QUEUE.json,再执行:npm exec --package ${PACKAGE_NAME} -- ${COMMAND_NAME} queue QUEUE.json --play --json。网易云开放平台可请求最高可用音质 URL。`,
|
|
607
|
+
`如果用户选择 Spotify,必须走 Spotify 官方 Spotify Connect/Web Playback SDK 或控制用户已有 Spotify 播放设备;不要假设 Spotify Web API 能提供完整歌曲直链。`,
|
|
608
|
+
`如果你当前没有音乐平台身份,请只告诉我:我需要你在 agent 支持的网易云开放平台或 Spotify 官方授权入口完成授权;完成后回复“已授权”。不要反复重试,不要让我理解内部授权包,也不要要求我把 AppSecret、PrivateKey、accessToken 或 refreshToken 发到聊天里。`,
|
|
609
|
+
`如果返回 health.problems 包含 netease_auth_refresh_failed,说明设备侧内置网易运行时授权失效;优先改走 agent 侧 queue/app 驱动。只有我明确要求继续使用设备侧网易运行时时,才引导手机端重新授权并重试 workbgm。`,
|
|
516
610
|
`如果我要播放「${profile}」,也可以执行:npm exec --package ${PACKAGE_NAME} -- ${COMMAND_NAME} deepnight`,
|
|
517
|
-
|
|
611
|
+
`不要向设备写入全局音乐平台私钥;平台授权优先保存在 agent 运行环境。只有在我明确授权时,才把最小范围、可轮换、可撤销的 app-scoped 凭据随 VM app 写入设备。`,
|
|
518
612
|
].filter(Boolean).join("\n");
|
|
519
613
|
}
|
|
520
614
|
|
|
@@ -566,6 +660,55 @@ export function summarizeInspect(inspect) {
|
|
|
566
660
|
};
|
|
567
661
|
}
|
|
568
662
|
|
|
663
|
+
function normalizeMediaUrl(value, trackNumber) {
|
|
664
|
+
const raw = string(value);
|
|
665
|
+
let parsed;
|
|
666
|
+
try {
|
|
667
|
+
parsed = new URL(raw);
|
|
668
|
+
} catch {
|
|
669
|
+
throw new Error(`queue_track_url_invalid:${trackNumber}`);
|
|
670
|
+
}
|
|
671
|
+
const protocol = parsed.protocol.toLowerCase();
|
|
672
|
+
const host = parsed.hostname.toLowerCase();
|
|
673
|
+
if (!new Set(["http:", "https:"]).has(protocol) || !host) {
|
|
674
|
+
throw new Error(`queue_track_url_invalid:${trackNumber}`);
|
|
675
|
+
}
|
|
676
|
+
if (!isPublicMediaHost(host)) {
|
|
677
|
+
throw new Error(`queue_track_url_host_blocked:${trackNumber}`);
|
|
678
|
+
}
|
|
679
|
+
if (protocol === "http:" && !isNeteaseMediaHost(host)) {
|
|
680
|
+
throw new Error(`queue_track_url_requires_https:${trackNumber}`);
|
|
681
|
+
}
|
|
682
|
+
return parsed.toString();
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
function isNeteaseMediaHost(host) {
|
|
686
|
+
return host === "music.126.net" || host.endsWith(".music.126.net");
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
function isPublicMediaHost(host) {
|
|
690
|
+
if (
|
|
691
|
+
host === "localhost" ||
|
|
692
|
+
host.endsWith(".localhost") ||
|
|
693
|
+
host.endsWith(".local") ||
|
|
694
|
+
host.endsWith(".lan")
|
|
695
|
+
) {
|
|
696
|
+
return false;
|
|
697
|
+
}
|
|
698
|
+
const match = host.match(/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/u);
|
|
699
|
+
if (!match) return true;
|
|
700
|
+
const parts = match.slice(1).map((part) => Number(part));
|
|
701
|
+
if (parts.some((part) => !Number.isInteger(part) || part < 0 || part > 255)) {
|
|
702
|
+
return false;
|
|
703
|
+
}
|
|
704
|
+
const [a, b] = parts;
|
|
705
|
+
if (a === 0 || a === 10 || a === 127 || (a === 169 && b === 254)) return false;
|
|
706
|
+
if (a === 172 && b >= 16 && b <= 31) return false;
|
|
707
|
+
if (a === 192 && b === 168) return false;
|
|
708
|
+
if (a >= 224) return false;
|
|
709
|
+
return true;
|
|
710
|
+
}
|
|
711
|
+
|
|
569
712
|
function connectMqtt(profile, options = {}) {
|
|
570
713
|
const timeoutMs = Math.max(1000, Number(options.timeoutMs) || 12_000);
|
|
571
714
|
return mqtt.connect(profile.url, {
|
package/package.json
CHANGED
|
@@ -1,15 +1,19 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@openbrt/audioctl",
|
|
3
|
-
"version": "0.1.
|
|
4
|
-
"description": "Generic WeClawBot audio
|
|
3
|
+
"version": "0.1.4",
|
|
4
|
+
"description": "Generic WeClawBot audio/voice endpoint control CLI and AI-agent plugin.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
7
7
|
"keywords": [
|
|
8
8
|
"weclawbot",
|
|
9
9
|
"audio",
|
|
10
|
+
"voice",
|
|
10
11
|
"music",
|
|
12
|
+
"tts",
|
|
13
|
+
"smart-home",
|
|
11
14
|
"mqtt",
|
|
12
15
|
"esp32",
|
|
16
|
+
"audio-vm",
|
|
13
17
|
"agent",
|
|
14
18
|
"byoa",
|
|
15
19
|
"openclaw",
|