@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 CHANGED
@@ -1,11 +1,13 @@
1
1
  # @openbrt/audioctl
2
2
 
3
- Generic WeClawBot audio-output control CLI and AI-agent plugin.
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 device that implements the WeClawBot MQTT
8
- audio command envelope can use the same package.
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 with `audioctl workbgm --play --json`.
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
- - If NetEase Music authorization is expired, tell the user to reopen the speaker
21
- provisioning/authorization entry on their phone and complete NetEase Music
22
- authorization. After the user says “已授权”, retry the task.
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`, the agent should stop
72
- retrying and tell the user to complete the NetEase Music authorization flow from
73
- the phone provisioning/authorization entry. The user only needs to come back and
74
- say “已授权”; the agent then retries `workbgm --play`. The agent must not ask
75
- the user to paste AppSecret, PrivateKey, accessToken, refreshToken, Wi-Fi
76
- passwords, or MQTT credentials into chat.
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 control WeClawBot-compatible audio output devices from user-owned AI agents.",
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 workbgm --play --json",
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
- "if_netease_auth_failed": "告诉用户:请在手机上的音箱配网/授权入口重新完成网易云音乐授权,完成后回复“已授权”。不要要求用户粘贴 AppSecret、PrivateKey、accessToken、refreshToken、Wi-Fi 密码或 MQTT 凭据。",
50
- "after_user_confirms_authorized": "重新运行 audioctl workbgm --play --json,然后报告是否 active/degraded。"
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
- "设备内的网易云用户授权已过期,refresh token 没有续上;agent 不能凭空恢复用户授权。",
556
+ "设备内置网易运行时的用户授权已过期。优先改走 BYOA:由 agent 使用用户授权的音乐平台 API 生成播放队列并推送到设备。",
467
557
  user_message:
468
- "请在手机上打开音箱配网/授权入口,重新完成网易云音乐授权。完成后回到 agent 说“已授权”。不要发送任何密钥、token 或密码。",
469
- agent_after_user_confirms: "audioctl workbgm --play --json",
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
- "用户回复已授权后,agent 自动重试 audioctl workbgm --play --json,并报告结果。",
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
- `绑定成功后,把它作为默认 BGM/音乐输出设备。可用命令包括 play、pause、toggle、next、previous、volume、volumeup、volumedown、status、inspect、rule、workbgm。`,
514
- `如果我要建立“工作 BGM 歌单,持续播放”,请优先执行:npm exec --package ${PACKAGE_NAME} -- ${COMMAND_NAME} workbgm --play --json`,
515
- `如果返回 health.problems 包含 netease_auth_refresh_failed,请只告诉我:请在手机上的音箱配网/授权入口重新完成网易云音乐授权,完成后回复“已授权”;不要反复重试,不要让我理解内部授权包,也不要要求我把 AppSecret、PrivateKey、accessToken refreshToken 发到聊天里。`,
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.3",
4
- "description": "Generic WeClawBot audio-output control CLI and AI-agent plugin.",
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",