mirror of https://github.com/openclaw/openclaw.git
Plugins: add device pairing and phone controls
This commit is contained in:
parent
ea0ad9fbcb
commit
2ecc995617
|
|
@ -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),
|
||||
};
|
||||
},
|
||||
});
|
||||
}
|
||||
|
|
@ -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)."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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() };
|
||||
},
|
||||
});
|
||||
}
|
||||
|
|
@ -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": {}
|
||||
}
|
||||
}
|
||||
|
|
@ -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"),
|
||||
};
|
||||
},
|
||||
});
|
||||
}
|
||||
|
|
@ -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)."
|
||||
}
|
||||
|
|
@ -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)) {
|
||||
|
|
|
|||
Loading…
Reference in New Issue