Plugins: add device pairing and phone controls

This commit is contained in:
Mariano Belinky 2026-02-07 22:59:12 +01:00
parent ea0ad9fbcb
commit 2ecc995617
7 changed files with 1002 additions and 1 deletions

View File

@ -0,0 +1,477 @@
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
import os from "node:os";
import { approveDevicePairing, listDevicePairing } from "openclaw/plugin-sdk";
const DEFAULT_GATEWAY_PORT = 18789;
type DevicePairPluginConfig = {
publicUrl?: string;
};
type SetupPayload = {
url: string;
token?: string;
password?: string;
};
type ResolveUrlResult = {
url?: string;
source?: string;
error?: string;
};
type ResolveAuthResult = {
token?: string;
password?: string;
label?: string;
error?: string;
};
function normalizeUrl(raw: string, schemeFallback: "ws" | "wss"): string | null {
const trimmed = raw.trim();
if (!trimmed) {
return null;
}
try {
const parsed = new URL(trimmed);
const scheme = parsed.protocol.replace(":", "");
if (!scheme) {
return null;
}
const resolvedScheme = scheme === "http" ? "ws" : scheme === "https" ? "wss" : scheme;
if (resolvedScheme !== "ws" && resolvedScheme !== "wss") {
return null;
}
const host = parsed.hostname;
if (!host) {
return null;
}
const port = parsed.port ? `:${parsed.port}` : "";
return `${resolvedScheme}://${host}${port}`;
} catch {
// Fall through to host:port parsing.
}
const withoutPath = trimmed.split("/")[0] ?? "";
if (!withoutPath) {
return null;
}
return `${schemeFallback}://${withoutPath}`;
}
function resolveGatewayPort(cfg: OpenClawPluginApi["config"]): number {
const envRaw =
process.env.OPENCLAW_GATEWAY_PORT?.trim() || process.env.CLAWDBOT_GATEWAY_PORT?.trim();
if (envRaw) {
const parsed = Number.parseInt(envRaw, 10);
if (Number.isFinite(parsed) && parsed > 0) {
return parsed;
}
}
const configPort = cfg.gateway?.port;
if (typeof configPort === "number" && Number.isFinite(configPort) && configPort > 0) {
return configPort;
}
return DEFAULT_GATEWAY_PORT;
}
function resolveScheme(
cfg: OpenClawPluginApi["config"],
opts?: { forceSecure?: boolean },
): "ws" | "wss" {
if (opts?.forceSecure) {
return "wss";
}
return cfg.gateway?.tls?.enabled === true ? "wss" : "ws";
}
function isPrivateIPv4(address: string): boolean {
const parts = address.split(".");
if (parts.length != 4) {
return false;
}
const octets = parts.map((part) => Number.parseInt(part, 10));
if (octets.some((value) => !Number.isFinite(value) || value < 0 || value > 255)) {
return false;
}
const [a, b] = octets;
if (a === 10) {
return true;
}
if (a === 172 && b >= 16 && b <= 31) {
return true;
}
if (a === 192 && b === 168) {
return true;
}
return false;
}
function isTailnetIPv4(address: string): boolean {
const parts = address.split(".");
if (parts.length !== 4) {
return false;
}
const octets = parts.map((part) => Number.parseInt(part, 10));
if (octets.some((value) => !Number.isFinite(value) || value < 0 || value > 255)) {
return false;
}
const [a, b] = octets;
return a === 100 && b >= 64 && b <= 127;
}
function pickLanIPv4(): string | null {
const nets = os.networkInterfaces();
for (const entries of Object.values(nets)) {
if (!entries) {
continue;
}
for (const entry of entries) {
const family = entry?.family;
const isIpv4 = family === "IPv4" || family === 4;
if (!entry || entry.internal || !isIpv4) {
continue;
}
const address = entry.address?.trim() ?? "";
if (!address) {
continue;
}
if (isPrivateIPv4(address)) {
return address;
}
}
}
return null;
}
function pickTailnetIPv4(): string | null {
const nets = os.networkInterfaces();
for (const entries of Object.values(nets)) {
if (!entries) {
continue;
}
for (const entry of entries) {
const family = entry?.family;
const isIpv4 = family === "IPv4" || family === 4;
if (!entry || entry.internal || !isIpv4) {
continue;
}
const address = entry.address?.trim() ?? "";
if (!address) {
continue;
}
if (isTailnetIPv4(address)) {
return address;
}
}
}
return null;
}
async function resolveTailnetHost(api: OpenClawPluginApi): Promise<string | null> {
const candidates = ["tailscale", "/Applications/Tailscale.app/Contents/MacOS/Tailscale"];
for (const candidate of candidates) {
try {
const result = await api.runtime.system.runCommandWithTimeout(
[candidate, "status", "--json"],
{
timeoutMs: 5000,
},
);
if (result.code !== 0) {
continue;
}
const raw = result.stdout.trim();
if (!raw) {
continue;
}
const parsed = parsePossiblyNoisyJsonObject(raw);
const self =
typeof parsed.Self === "object" && parsed.Self !== null
? (parsed.Self as Record<string, unknown>)
: undefined;
const dns = typeof self?.DNSName === "string" ? self.DNSName : undefined;
if (dns && dns.length > 0) {
return dns.replace(/\.$/, "");
}
const ips = Array.isArray(self?.TailscaleIPs) ? (self?.TailscaleIPs as string[]) : [];
if (ips.length > 0) {
return ips[0] ?? null;
}
} catch {
continue;
}
}
return null;
}
function parsePossiblyNoisyJsonObject(raw: string): Record<string, unknown> {
const start = raw.indexOf("{");
const end = raw.lastIndexOf("}");
if (start === -1 || end <= start) {
return {};
}
try {
return JSON.parse(raw.slice(start, end + 1)) as Record<string, unknown>;
} catch {
return {};
}
}
function resolveAuth(cfg: OpenClawPluginApi["config"]): ResolveAuthResult {
const mode = cfg.gateway?.auth?.mode;
const token =
process.env.OPENCLAW_GATEWAY_TOKEN?.trim() ||
process.env.CLAWDBOT_GATEWAY_TOKEN?.trim() ||
cfg.gateway?.auth?.token?.trim();
const password =
process.env.OPENCLAW_GATEWAY_PASSWORD?.trim() ||
process.env.CLAWDBOT_GATEWAY_PASSWORD?.trim() ||
cfg.gateway?.auth?.password?.trim();
if (mode === "password") {
if (!password) {
return { error: "Gateway auth is set to password, but no password is configured." };
}
return { password, label: "password" };
}
if (mode === "token") {
if (!token) {
return { error: "Gateway auth is set to token, but no token is configured." };
}
return { token, label: "token" };
}
if (token) {
return { token, label: "token" };
}
if (password) {
return { password, label: "password" };
}
return { error: "Gateway auth is not configured (no token or password)." };
}
async function resolveGatewayUrl(api: OpenClawPluginApi): Promise<ResolveUrlResult> {
const cfg = api.config;
const pluginCfg = (api.pluginConfig ?? {}) as DevicePairPluginConfig;
const scheme = resolveScheme(cfg);
const port = resolveGatewayPort(cfg);
if (typeof pluginCfg.publicUrl === "string" && pluginCfg.publicUrl.trim()) {
const url = normalizeUrl(pluginCfg.publicUrl, scheme);
if (url) {
return { url, source: "plugins.entries.device-pair.config.publicUrl" };
}
return { error: "Configured publicUrl is invalid." };
}
const tailscaleMode = cfg.gateway?.tailscale?.mode ?? "off";
if (tailscaleMode === "serve" || tailscaleMode === "funnel") {
const host = await resolveTailnetHost(api);
if (!host) {
return { error: "Tailscale Serve is enabled, but MagicDNS could not be resolved." };
}
return { url: `wss://${host}`, source: `gateway.tailscale.mode=${tailscaleMode}` };
}
const remoteUrl = cfg.gateway?.remote?.url;
if (typeof remoteUrl === "string" && remoteUrl.trim()) {
const url = normalizeUrl(remoteUrl, scheme);
if (url) {
return { url, source: "gateway.remote.url" };
}
}
const bind = cfg.gateway?.bind ?? "loopback";
if (bind === "custom") {
const host = cfg.gateway?.customBindHost?.trim();
if (host) {
return { url: `${scheme}://${host}:${port}`, source: "gateway.bind=custom" };
}
return { error: "gateway.bind=custom requires gateway.customBindHost." };
}
if (bind === "tailnet") {
const host = pickTailnetIPv4();
if (host) {
return { url: `${scheme}://${host}:${port}`, source: "gateway.bind=tailnet" };
}
return { error: "gateway.bind=tailnet set, but no tailnet IP was found." };
}
if (bind === "lan") {
const host = pickLanIPv4();
if (host) {
return { url: `${scheme}://${host}:${port}`, source: "gateway.bind=lan" };
}
return { error: "gateway.bind=lan set, but no private LAN IP was found." };
}
return {
error:
"Gateway is only bound to loopback. Set gateway.bind=lan, enable tailscale serve, or configure plugins.entries.device-pair.config.publicUrl.",
};
}
function encodeSetupCode(payload: SetupPayload): string {
const json = JSON.stringify(payload);
const base64 = Buffer.from(json, "utf8").toString("base64");
return base64.replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/g, "");
}
function formatSetupReply(payload: SetupPayload, authLabel: string): string {
const setupCode = encodeSetupCode(payload);
return [
"Pairing setup code generated.",
"",
"1) Open the iOS app → Settings → Gateway",
"2) Paste the setup code below and tap Connect",
"3) Back here, run /pair approve",
"",
"Setup code:",
setupCode,
"",
`Gateway: ${payload.url}`,
`Auth: ${authLabel}`,
].join("\n");
}
function formatSetupInstructions(): string {
return [
"Pairing setup code generated.",
"",
"1) Open the iOS app → Settings → Gateway",
"2) Paste the setup code from my next message and tap Connect",
"3) Back here, run /pair approve",
].join("\n");
}
type PendingPairingRequest = {
requestId: string;
deviceId: string;
displayName?: string;
platform?: string;
remoteIp?: string;
ts?: number;
};
function formatPendingRequests(pending: PendingPairingRequest[]): string {
if (pending.length === 0) {
return "No pending device pairing requests.";
}
const lines: string[] = ["Pending device pairing requests:"];
for (const req of pending) {
const label = req.displayName?.trim() || req.deviceId;
const platform = req.platform?.trim();
const ip = req.remoteIp?.trim();
const parts = [
`- ${req.requestId}`,
label ? `name=${label}` : null,
platform ? `platform=${platform}` : null,
ip ? `ip=${ip}` : null,
].filter(Boolean);
lines.push(parts.join(" · "));
}
return lines.join("\n");
}
export default function register(api: OpenClawPluginApi) {
api.registerCommand({
name: "pair",
description: "Generate setup codes and approve device pairing requests.",
acceptsArgs: true,
handler: async (ctx) => {
const args = ctx.args?.trim() ?? "";
const tokens = args.split(/\s+/).filter(Boolean);
const action = tokens[0]?.toLowerCase() ?? "";
api.logger.info?.(
`device-pair: /pair invoked channel=${ctx.channel} sender=${ctx.senderId ?? "unknown"} action=${
action || "new"
}`,
);
if (action === "status" || action === "pending") {
const list = await listDevicePairing();
return { text: formatPendingRequests(list.pending) };
}
if (action === "approve") {
const requestId = tokens[1];
const list = await listDevicePairing();
const pending = requestId
? list.pending.find((entry) => entry.requestId === requestId)
: [...list.pending].toSorted((a, b) => (b.ts ?? 0) - (a.ts ?? 0))[0];
if (!pending) {
return { text: "No pending device pairing requests." };
}
const approved = await approveDevicePairing(pending.requestId);
if (!approved) {
return { text: "Pairing request not found." };
}
const label = approved.device.displayName?.trim() || approved.device.deviceId;
const platform = approved.device.platform?.trim();
const platformLabel = platform ? ` (${platform})` : "";
return { text: `✅ Paired ${label}${platformLabel}.` };
}
const auth = resolveAuth(api.config);
if (auth.error) {
return { text: `Error: ${auth.error}` };
}
const urlResult = await resolveGatewayUrl(api);
if (!urlResult.url) {
return { text: `Error: ${urlResult.error ?? "Gateway URL unavailable."}` };
}
const payload: SetupPayload = {
url: urlResult.url,
token: auth.token,
password: auth.password,
};
const channel = ctx.channel;
const target = ctx.senderId?.trim() || ctx.from?.trim() || ctx.to?.trim() || "";
const authLabel = auth.label ?? "auth";
if (channel === "telegram" && target) {
try {
const runtimeKeys = Object.keys(api.runtime ?? {});
const channelKeys = Object.keys(api.runtime?.channel ?? {});
api.logger.debug?.(
`device-pair: runtime keys=${runtimeKeys.join(",") || "none"} channel keys=${
channelKeys.join(",") || "none"
}`,
);
const send = api.runtime?.channel?.telegram?.sendMessageTelegram;
if (!send) {
throw new Error(
`telegram runtime unavailable (runtime keys: ${runtimeKeys.join(",")}; channel keys: ${channelKeys.join(
",",
)})`,
);
}
await send(target, formatSetupInstructions(), {
...(ctx.messageThreadId != null ? { messageThreadId: ctx.messageThreadId } : {}),
...(ctx.accountId ? { accountId: ctx.accountId } : {}),
});
api.logger.info?.(
`device-pair: telegram split send ok target=${target} account=${ctx.accountId ?? "none"} thread=${
ctx.messageThreadId ?? "none"
}`,
);
return { text: encodeSetupCode(payload) };
} catch (err) {
api.logger.warn?.(
`device-pair: telegram split send failed, falling back to single message (${String(
(err as Error)?.message ?? err,
)})`,
);
}
}
return {
text: formatSetupReply(payload, authLabel),
};
},
});
}

View File

@ -0,0 +1,20 @@
{
"id": "device-pair",
"name": "Device Pairing",
"description": "Generate setup codes and approve device pairing requests.",
"configSchema": {
"type": "object",
"additionalProperties": false,
"properties": {
"publicUrl": {
"type": "string"
}
}
},
"uiHints": {
"publicUrl": {
"label": "Gateway URL",
"help": "Public WebSocket URL used for /pair setup codes (ws/wss or http/https)."
}
}
}

View File

@ -0,0 +1,333 @@
import type { OpenClawPluginApi, OpenClawPluginService } from "openclaw/plugin-sdk";
import fs from "node:fs/promises";
import path from "node:path";
type ArmGroup = "camera" | "screen" | "writes" | "all";
type ArmStateFile = {
version: 1;
armedAtMs: number;
expiresAtMs: number | null;
removedFromDeny: string[];
};
const STATE_VERSION = 1;
const STATE_REL_PATH = ["plugins", "phone-control", "armed.json"] as const;
const GROUP_COMMANDS: Record<Exclude<ArmGroup, "all">, string[]> = {
camera: ["camera.snap", "camera.clip"],
screen: ["screen.record"],
writes: ["calendar.add", "contacts.add", "reminders.add"],
};
function uniqSorted(values: string[]): string[] {
return [...new Set(values.map((v) => v.trim()).filter(Boolean))].toSorted();
}
function resolveCommandsForGroup(group: ArmGroup): string[] {
if (group === "all") {
return uniqSorted(Object.values(GROUP_COMMANDS).flat());
}
return uniqSorted(GROUP_COMMANDS[group]);
}
function formatGroupList(): string {
return ["camera", "screen", "writes", "all"].join(", ");
}
function parseDurationMs(input: string | undefined): number | null {
if (!input) {
return null;
}
const raw = input.trim().toLowerCase();
if (!raw) {
return null;
}
const m = raw.match(/^(\d+)(s|m|h|d)$/);
if (!m) {
return null;
}
const n = Number.parseInt(m[1] ?? "", 10);
if (!Number.isFinite(n) || n <= 0) {
return null;
}
const unit = m[2];
const mult = unit === "s" ? 1000 : unit === "m" ? 60_000 : unit === "h" ? 3_600_000 : 86_400_000;
return n * mult;
}
function formatDuration(ms: number): string {
const s = Math.max(0, Math.floor(ms / 1000));
if (s < 60) {
return `${s}s`;
}
const m = Math.floor(s / 60);
if (m < 60) {
return `${m}m`;
}
const h = Math.floor(m / 60);
if (h < 48) {
return `${h}h`;
}
const d = Math.floor(h / 24);
return `${d}d`;
}
function resolveStatePath(stateDir: string): string {
return path.join(stateDir, ...STATE_REL_PATH);
}
async function readArmState(statePath: string): Promise<ArmStateFile | null> {
try {
const raw = await fs.readFile(statePath, "utf8");
const parsed = JSON.parse(raw) as Partial<ArmStateFile>;
if (parsed.version !== STATE_VERSION) {
return null;
}
if (typeof parsed.armedAtMs !== "number") {
return null;
}
if (!(parsed.expiresAtMs === null || typeof parsed.expiresAtMs === "number")) {
return null;
}
if (
!Array.isArray(parsed.removedFromDeny) ||
!parsed.removedFromDeny.every((v) => typeof v === "string")
) {
return null;
}
return parsed as ArmStateFile;
} catch {
return null;
}
}
async function writeArmState(statePath: string, state: ArmStateFile | null): Promise<void> {
await fs.mkdir(path.dirname(statePath), { recursive: true });
if (!state) {
try {
await fs.unlink(statePath);
} catch {
// ignore
}
return;
}
await fs.writeFile(statePath, `${JSON.stringify(state, null, 2)}\n`, "utf8");
}
function normalizeDenyList(cfg: OpenClawPluginApi["config"]): string[] {
return uniqSorted([...(cfg.gateway?.nodes?.denyCommands ?? [])]);
}
function patchConfigDenyList(
cfg: OpenClawPluginApi["config"],
denyCommands: string[],
): OpenClawPluginApi["config"] {
return {
...cfg,
gateway: {
...cfg.gateway,
nodes: {
...cfg.gateway?.nodes,
denyCommands,
},
},
};
}
async function disarmNow(params: {
api: OpenClawPluginApi;
stateDir: string;
statePath: string;
reason: string;
}): Promise<{ changed: boolean; restored: string[] }> {
const { api, stateDir, statePath, reason } = params;
const state = await readArmState(statePath);
if (!state) {
return { changed: false, restored: [] };
}
const cfg = api.runtime.config.loadConfig();
const deny = new Set(normalizeDenyList(cfg));
const restored: string[] = [];
for (const cmd of state.removedFromDeny) {
if (!deny.has(cmd)) {
deny.add(cmd);
restored.push(cmd);
}
}
if (restored.length > 0) {
const next = patchConfigDenyList(cfg, uniqSorted([...deny]));
await api.runtime.config.writeConfigFile(next);
}
await writeArmState(statePath, null);
api.logger.info(`phone-control: disarmed (${reason}) stateDir=${stateDir}`);
return { changed: restored.length > 0, restored: uniqSorted(restored) };
}
function formatHelp(): string {
return [
"Phone control commands:",
"",
"/phone status",
"/phone arm <group> [duration]",
"/phone disarm",
"",
"Groups:",
`- ${formatGroupList()}`,
"",
"Duration format: 30s | 10m | 2h | 1d (default: 10m).",
"",
"Notes:",
"- This only toggles what the gateway is allowed to invoke on phone nodes.",
"- iOS will still ask for permissions (camera, photos, contacts, etc.) on first use.",
].join("\n");
}
function parseGroup(raw: string | undefined): ArmGroup | null {
const value = (raw ?? "").trim().toLowerCase();
if (!value) {
return null;
}
if (value === "camera" || value === "screen" || value === "writes" || value === "all") {
return value;
}
return null;
}
function formatStatus(state: ArmStateFile | null): string {
if (!state) {
return "Phone control: disarmed.";
}
const until =
state.expiresAtMs == null
? "manual disarm required"
: `expires in ${formatDuration(Math.max(0, state.expiresAtMs - Date.now()))}`;
const cmds = uniqSorted(state.removedFromDeny);
const cmdLabel = cmds.length > 0 ? cmds.join(", ") : "none";
return `Phone control: armed (${until}).\nTemporarily allowed: ${cmdLabel}`;
}
export default function register(api: OpenClawPluginApi) {
let expiryInterval: ReturnType<typeof setInterval> | null = null;
const timerService: OpenClawPluginService = {
id: "phone-control-expiry",
start: async (ctx) => {
const statePath = resolveStatePath(ctx.stateDir);
const tick = async () => {
const state = await readArmState(statePath);
if (!state || state.expiresAtMs == null) {
return;
}
if (Date.now() < state.expiresAtMs) {
return;
}
await disarmNow({
api,
stateDir: ctx.stateDir,
statePath,
reason: "expired",
});
};
// Best effort; don't crash the gateway if state is corrupt.
await tick().catch(() => {});
expiryInterval = setInterval(() => {
tick().catch(() => {});
}, 15_000);
expiryInterval.unref?.();
return;
},
stop: async () => {
if (expiryInterval) {
clearInterval(expiryInterval);
expiryInterval = null;
}
return;
},
};
api.registerService(timerService);
api.registerCommand({
name: "phone",
description: "Arm/disarm high-risk phone node commands (camera/screen/writes).",
acceptsArgs: true,
handler: async (ctx) => {
const args = ctx.args?.trim() ?? "";
const tokens = args.split(/\s+/).filter(Boolean);
const action = tokens[0]?.toLowerCase() ?? "";
const stateDir = api.runtime.state.resolveStateDir();
const statePath = resolveStatePath(stateDir);
if (!action || action === "help") {
const state = await readArmState(statePath);
return { text: `${formatStatus(state)}\n\n${formatHelp()}` };
}
if (action === "status") {
const state = await readArmState(statePath);
return { text: formatStatus(state) };
}
if (action === "disarm") {
const res = await disarmNow({
api,
stateDir,
statePath,
reason: "manual",
});
if (!res.changed) {
return { text: "Phone control: disarmed." };
}
return {
text: `Phone control: disarmed. Restored denylist for: ${res.restored.join(", ")}`,
};
}
if (action === "arm") {
const group = parseGroup(tokens[1]);
if (!group) {
return { text: `Usage: /phone arm <group> [duration]\nGroups: ${formatGroupList()}` };
}
const durationMs = parseDurationMs(tokens[2]) ?? 10 * 60_000;
const expiresAtMs = Date.now() + durationMs;
const commands = resolveCommandsForGroup(group);
const cfg = api.runtime.config.loadConfig();
const deny = normalizeDenyList(cfg);
const denySet = new Set(deny);
const removed: string[] = [];
for (const cmd of commands) {
if (denySet.delete(cmd)) {
removed.push(cmd);
}
}
const next = patchConfigDenyList(cfg, uniqSorted([...denySet]));
await api.runtime.config.writeConfigFile(next);
await writeArmState(statePath, {
version: STATE_VERSION,
armedAtMs: Date.now(),
expiresAtMs,
removedFromDeny: uniqSorted(removed),
});
const removedLabel =
removed.length > 0 ? uniqSorted(removed).join(", ") : "none (already allowed)";
return {
text:
`Phone control: armed for ${formatDuration(durationMs)}.\n` +
`Temporarily allowed: ${removedLabel}\n` +
`To disarm early: /phone disarm`,
};
}
return { text: formatHelp() };
},
});
}

View File

@ -0,0 +1,10 @@
{
"id": "phone-control",
"name": "Phone Control",
"description": "Arm/disarm high-risk phone node commands (camera/screen/writes) with an optional auto-expiry.",
"configSchema": {
"type": "object",
"additionalProperties": false,
"properties": {}
}
}

View File

@ -0,0 +1,150 @@
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
type ElevenLabsVoice = {
voice_id: string;
name?: string;
category?: string;
description?: string;
};
function mask(s: string, keep: number = 6): string {
const trimmed = s.trim();
if (trimmed.length <= keep) {
return "***";
}
return `${trimmed.slice(0, keep)}`;
}
function isLikelyVoiceId(value: string): boolean {
const v = value.trim();
if (v.length < 10 || v.length > 64) {
return false;
}
return /^[a-zA-Z0-9_-]+$/.test(v);
}
async function listVoices(apiKey: string): Promise<ElevenLabsVoice[]> {
const res = await fetch("https://api.elevenlabs.io/v1/voices", {
headers: {
"xi-api-key": apiKey,
},
});
if (!res.ok) {
throw new Error(`ElevenLabs voices API error (${res.status})`);
}
const json = (await res.json()) as { voices?: ElevenLabsVoice[] };
return Array.isArray(json.voices) ? json.voices : [];
}
function formatVoiceList(voices: ElevenLabsVoice[], limit: number): string {
const sliced = voices.slice(0, Math.max(1, Math.min(limit, 50)));
const lines: string[] = [];
lines.push(`Voices: ${voices.length}`);
lines.push("");
for (const v of sliced) {
const name = (v.name ?? "").trim() || "(unnamed)";
const category = (v.category ?? "").trim();
const meta = category ? ` · ${category}` : "";
lines.push(`- ${name}${meta}`);
lines.push(` id: ${v.voice_id}`);
}
if (voices.length > sliced.length) {
lines.push("");
lines.push(`(showing first ${sliced.length})`);
}
return lines.join("\n");
}
function findVoice(voices: ElevenLabsVoice[], query: string): ElevenLabsVoice | null {
const q = query.trim();
if (!q) {
return null;
}
const lower = q.toLowerCase();
const byId = voices.find((v) => v.voice_id === q);
if (byId) {
return byId;
}
const exactName = voices.find((v) => (v.name ?? "").trim().toLowerCase() === lower);
if (exactName) {
return exactName;
}
const partial = voices.find((v) => (v.name ?? "").trim().toLowerCase().includes(lower));
return partial ?? null;
}
export default function register(api: OpenClawPluginApi) {
api.registerCommand({
name: "voice",
description: "List/set ElevenLabs Talk voice (affects iOS Talk playback).",
acceptsArgs: true,
handler: async (ctx) => {
const args = ctx.args?.trim() ?? "";
const tokens = args.split(/\s+/).filter(Boolean);
const action = (tokens[0] ?? "status").toLowerCase();
const cfg = api.runtime.config.loadConfig();
const apiKey = (cfg.talk?.apiKey ?? "").trim();
if (!apiKey) {
return {
text:
"Talk voice is not configured.\n\n" +
"Missing: talk.apiKey (ElevenLabs API key).\n" +
"Set it on the gateway, then retry.",
};
}
const currentVoiceId = (cfg.talk?.voiceId ?? "").trim();
if (action === "status") {
return {
text:
"Talk voice status:\n" +
`- talk.voiceId: ${currentVoiceId ? currentVoiceId : "(unset)"}\n` +
`- talk.apiKey: ${mask(apiKey)}`,
};
}
if (action === "list") {
const limit = Number.parseInt(tokens[1] ?? "12", 10);
const voices = await listVoices(apiKey);
return { text: formatVoiceList(voices, Number.isFinite(limit) ? limit : 12) };
}
if (action === "set") {
const query = tokens.slice(1).join(" ").trim();
if (!query) {
return { text: "Usage: /voice set <voiceId|name>" };
}
const voices = await listVoices(apiKey);
const chosen = findVoice(voices, query);
if (!chosen) {
const hint = isLikelyVoiceId(query) ? query : `"${query}"`;
return { text: `No voice found for ${hint}. Try: /voice list` };
}
const nextConfig = {
...cfg,
talk: {
...cfg.talk,
voiceId: chosen.voice_id,
},
};
await api.runtime.config.writeConfigFile(nextConfig);
const name = (chosen.name ?? "").trim() || "(unnamed)";
return { text: `✅ Talk voice set to ${name}\n${chosen.voice_id}` };
}
return {
text: [
"Voice commands:",
"",
"/voice status",
"/voice list [limit]",
"/voice set <voiceId|name>",
].join("\n"),
};
},
});
}

View File

@ -0,0 +1,7 @@
{
"id": "talk-voice",
"name": "Talk Voice",
"version": "0.0.1",
"main": "index.ts",
"description": "Manage ElevenLabs Talk voice selection (list/set)."
}

View File

@ -13,7 +13,11 @@ export type NormalizedPluginsConfig = {
entries: Record<string, { enabled?: boolean; config?: unknown }>;
};
export const BUNDLED_ENABLED_BY_DEFAULT = new Set<string>();
export const BUNDLED_ENABLED_BY_DEFAULT = new Set<string>([
"device-pair",
"phone-control",
"talk-voice",
]);
const normalizeList = (value: unknown): string[] => {
if (!Array.isArray(value)) {