diff --git a/src/auto-reply/reply/commands-core.ts b/src/auto-reply/reply/commands-core.ts index c3425161773..7bc763b6985 100644 --- a/src/auto-reply/reply/commands-core.ts +++ b/src/auto-reply/reply/commands-core.ts @@ -26,6 +26,7 @@ import { handleMcpCommand } from "./commands-mcp.js"; import { handleModelsCommand } from "./commands-models.js"; import { handlePluginCommand } from "./commands-plugin.js"; import { handlePluginsCommand } from "./commands-plugins.js"; +import { handlePTTCommand } from "./commands-ptt.js"; import { handleAbortTrigger, handleActivationCommand, @@ -195,6 +196,7 @@ export async function handleCommands(params: HandleCommandsParams): Promise = { + start: "talk.ptt.start", + stop: "talk.ptt.stop", + once: "talk.ptt.once", + cancel: "talk.ptt.cancel", +}; + +function normalizeNodeKey(value: string) { + return value + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-+/, "") + .replace(/-+$/, ""); +} + +function isIOSNode(node: NodeSummary): boolean { + const platform = node.platform?.toLowerCase() ?? ""; + const family = node.deviceFamily?.toLowerCase() ?? ""; + return ( + platform.startsWith("ios") || + family.includes("iphone") || + family.includes("ipad") || + family.includes("ios") + ); +} + +async function loadNodes(cfg: OpenClawConfig): Promise { + try { + const res = await callGateway<{ nodes?: NodeSummary[] }>({ + method: "node.list", + params: {}, + config: cfg, + }); + return Array.isArray(res.nodes) ? res.nodes : []; + } catch { + const res = await callGateway<{ pending?: unknown[]; paired?: NodeSummary[] }>({ + method: "node.pair.list", + params: {}, + config: cfg, + }); + return Array.isArray(res.paired) ? res.paired : []; + } +} + +function describeNodes(nodes: NodeSummary[]) { + return nodes + .map((node) => node.displayName || node.remoteIp || node.nodeId) + .filter(Boolean) + .join(", "); +} + +function resolveNodeId(nodes: NodeSummary[], query?: string): string { + const trimmed = String(query ?? "").trim(); + if (trimmed) { + const qNorm = normalizeNodeKey(trimmed); + const matches = nodes.filter((node) => { + if (node.nodeId === trimmed) { + return true; + } + if (typeof node.remoteIp === "string" && node.remoteIp === trimmed) { + return true; + } + const name = typeof node.displayName === "string" ? node.displayName : ""; + if (name && normalizeNodeKey(name) === qNorm) { + return true; + } + if (trimmed.length >= 6 && node.nodeId.startsWith(trimmed)) { + return true; + } + return false; + }); + + if (matches.length === 1) { + return matches[0].nodeId; + } + const known = describeNodes(nodes); + if (matches.length === 0) { + throw new Error(`unknown node: ${trimmed}${known ? ` (known: ${known})` : ""}`); + } + throw new Error( + `ambiguous node: ${trimmed} (matches: ${matches + .map((node) => node.displayName || node.remoteIp || node.nodeId) + .join(", ")})`, + ); + } + + const iosNodes = nodes.filter(isIOSNode); + const iosConnected = iosNodes.filter((node) => node.connected); + const iosCandidates = iosConnected.length > 0 ? iosConnected : iosNodes; + if (iosCandidates.length === 1) { + return iosCandidates[0].nodeId; + } + if (iosCandidates.length > 1) { + throw new Error( + `multiple iOS nodes found (${describeNodes(iosCandidates)}); specify node=`, + ); + } + + const connected = nodes.filter((node) => node.connected); + const fallback = connected.length > 0 ? connected : nodes; + if (fallback.length === 1) { + return fallback[0].nodeId; + } + + const known = describeNodes(nodes); + throw new Error(`node required${known ? ` (known: ${known})` : ""}`); +} + +function parsePTTArgs(commandBody: string) { + const tokens = commandBody.trim().split(/\s+/).slice(1); + let action: string | undefined; + let node: string | undefined; + for (const token of tokens) { + if (!token) { + continue; + } + if (token.toLowerCase().startsWith("node=")) { + node = token.slice("node=".length); + continue; + } + if (!action) { + action = token; + } + } + return { action, node }; +} + +function buildPTTHelpText() { + return [ + "Usage: /ptt [node=]", + "Example: /ptt once node=iphone", + ].join("\n"); +} + +export const handlePTTCommand: CommandHandler = async (params, allowTextCommands) => { + if (!allowTextCommands) { + return null; + } + const { command, cfg } = params; + const normalized = command.commandBodyNormalized.trim(); + if (!normalized.startsWith("/ptt")) { + return null; + } + if (!command.isAuthorizedSender) { + logVerbose(`Ignoring /ptt from unauthorized sender: ${command.senderId || ""}`); + return { shouldContinue: false, reply: { text: "PTT requires an authorized sender." } }; + } + + const parsed = parsePTTArgs(normalized); + const actionKey = parsed.action?.trim().toLowerCase() ?? ""; + const commandId = PTT_COMMANDS[actionKey]; + if (!commandId) { + return { shouldContinue: false, reply: { text: buildPTTHelpText() } }; + } + + try { + const nodes = await loadNodes(cfg); + const nodeId = resolveNodeId(nodes, parsed.node); + const invokeParams: Record = { + nodeId, + command: commandId, + params: {}, + idempotencyKey: randomIdempotencyKey(), + timeoutMs: 15_000, + }; + const res = await callGateway<{ + ok?: boolean; + payload?: Record; + command?: string; + nodeId?: string; + }>({ + method: "node.invoke", + params: invokeParams, + config: cfg, + }); + const payload = res.payload && typeof res.payload === "object" ? res.payload : {}; + + const lines = [`PTT ${actionKey} → ${nodeId}`]; + if (typeof payload.status === "string") { + lines.push(`status: ${payload.status}`); + } + if (typeof payload.captureId === "string") { + lines.push(`captureId: ${payload.captureId}`); + } + if (typeof payload.transcript === "string" && payload.transcript.trim()) { + lines.push(`transcript: ${payload.transcript}`); + } + + return { shouldContinue: false, reply: { text: lines.join("\n") } }; + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + return { shouldContinue: false, reply: { text: `PTT failed: ${message}` } }; + } +};