diff --git a/extensions/device-pair/index.ts b/extensions/device-pair/index.ts new file mode 100644 index 00000000000..cb21d3875fd --- /dev/null +++ b/extensions/device-pair/index.ts @@ -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 { + 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) + : 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 { + 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; + } 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 { + 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), + }; + }, + }); +} diff --git a/extensions/device-pair/openclaw.plugin.json b/extensions/device-pair/openclaw.plugin.json new file mode 100644 index 00000000000..b72a075bd49 --- /dev/null +++ b/extensions/device-pair/openclaw.plugin.json @@ -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)." + } + } +} diff --git a/extensions/phone-control/index.ts b/extensions/phone-control/index.ts new file mode 100644 index 00000000000..0aabc4ad7bf --- /dev/null +++ b/extensions/phone-control/index.ts @@ -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, 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 { + try { + const raw = await fs.readFile(statePath, "utf8"); + const parsed = JSON.parse(raw) as Partial; + 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 { + 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 [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 | 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 [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() }; + }, + }); +} diff --git a/extensions/phone-control/openclaw.plugin.json b/extensions/phone-control/openclaw.plugin.json new file mode 100644 index 00000000000..4d73c85e43b --- /dev/null +++ b/extensions/phone-control/openclaw.plugin.json @@ -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": {} + } +} diff --git a/extensions/talk-voice/index.ts b/extensions/talk-voice/index.ts new file mode 100644 index 00000000000..d47705719a2 --- /dev/null +++ b/extensions/talk-voice/index.ts @@ -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 { + 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 " }; + } + 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 ", + ].join("\n"), + }; + }, + }); +} diff --git a/extensions/talk-voice/openclaw.plugin.json b/extensions/talk-voice/openclaw.plugin.json new file mode 100644 index 00000000000..ae7439b390f --- /dev/null +++ b/extensions/talk-voice/openclaw.plugin.json @@ -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)." +} diff --git a/src/plugins/config-state.ts b/src/plugins/config-state.ts index 72344daa33f..5e41de9a86b 100644 --- a/src/plugins/config-state.ts +++ b/src/plugins/config-state.ts @@ -13,7 +13,11 @@ export type NormalizedPluginsConfig = { entries: Record; }; -export const BUNDLED_ENABLED_BY_DEFAULT = new Set(); +export const BUNDLED_ENABLED_BY_DEFAULT = new Set([ + "device-pair", + "phone-control", + "talk-voice", +]); const normalizeList = (value: unknown): string[] => { if (!Array.isArray(value)) {