From b7aac92ac4511683c4bf44f4119496227286f909 Mon Sep 17 00:00:00 2001 From: Mariano Belinky Date: Sun, 1 Feb 2026 10:54:17 +0100 Subject: [PATCH] Gateway: add PTT chat + nodes CLI --- docs/tools/slash-commands.md | 1 + src/auto-reply/commands-registry.data.ts | 22 +++ src/auto-reply/reply/commands-core.ts | 4 +- src/auto-reply/reply/commands-ptt.test.ts | 95 ++++++++++ src/auto-reply/reply/commands-ptt.ts | 211 ++++++++++++++++++++++ src/cli/nodes-cli.coverage.test.ts | 19 ++ src/cli/nodes-cli/register.talk.ts | 79 ++++++++ src/cli/nodes-cli/register.ts | 2 + 8 files changed, 432 insertions(+), 1 deletion(-) create mode 100644 src/auto-reply/reply/commands-ptt.test.ts create mode 100644 src/auto-reply/reply/commands-ptt.ts create mode 100644 src/cli/nodes-cli/register.talk.ts diff --git a/docs/tools/slash-commands.md b/docs/tools/slash-commands.md index 24684c72b..3b52ab348 100644 --- a/docs/tools/slash-commands.md +++ b/docs/tools/slash-commands.md @@ -75,6 +75,7 @@ Text + native (when enabled): - `/usage off|tokens|full|cost` (per-response usage footer or local cost summary) - `/tts off|always|inbound|tagged|status|provider|limit|summary|audio` (control TTS; see [/tts](/tts)) - Discord: native command is `/voice` (Discord reserves `/tts`); text `/tts` still works. +- `/ptt start|stop|once|cancel [node=]` (push-to-talk controls for a paired node) - `/stop` - `/restart` - `/dock-telegram` (alias: `/dock_telegram`) (switch replies to Telegram) diff --git a/src/auto-reply/commands-registry.data.ts b/src/auto-reply/commands-registry.data.ts index 076541d98..a9a8c730c 100644 --- a/src/auto-reply/commands-registry.data.ts +++ b/src/auto-reply/commands-registry.data.ts @@ -273,6 +273,28 @@ function buildChatCommands(): ChatCommandDefinition[] { ], argsMenu: "auto", }), + defineChatCommand({ + key: "ptt", + nativeName: "ptt", + description: "Push-to-talk controls for a paired node.", + textAlias: "/ptt", + acceptsArgs: true, + argsParsing: "none", + category: "tools", + args: [ + { + name: "action", + description: "start, stop, once, or cancel", + type: "string", + choices: ["start", "stop", "once", "cancel"], + }, + { + name: "node", + description: "node= (optional)", + type: "string", + }, + ], + }), defineChatCommand({ key: "config", nativeName: "config", diff --git a/src/auto-reply/reply/commands-core.ts b/src/auto-reply/reply/commands-core.ts index c139fd6f6..4a4fa0a32 100644 --- a/src/auto-reply/reply/commands-core.ts +++ b/src/auto-reply/reply/commands-core.ts @@ -21,6 +21,8 @@ import { } from "./commands-info.js"; import { handleModelsCommand } from "./commands-models.js"; import { handlePluginCommand } from "./commands-plugin.js"; +import { handlePTTCommand } from "./commands-ptt.js"; +import { handleTtsCommands } from "./commands-tts.js"; import { handleAbortTrigger, handleActivationCommand, @@ -30,7 +32,6 @@ import { handleUsageCommand, } from "./commands-session.js"; import { handleSubagentsCommand } from "./commands-subagents.js"; -import { handleTtsCommands } from "./commands-tts.js"; import { routeReply } from "./route-reply.js"; let HANDLERS: CommandHandler[] | null = null; @@ -46,6 +47,7 @@ export async function handleCommands(params: HandleCommandsParams): Promise ({ ok: true })); + +vi.mock("../../gateway/call.js", () => ({ + callGateway: (opts: unknown) => callGateway(opts as { method?: string }), + randomIdempotencyKey: () => "idem-test", +})); + +function buildParams(commandBody: string, cfg: OpenClawConfig, ctxOverrides?: Partial) { + const ctx = { + Body: commandBody, + CommandBody: commandBody, + CommandSource: "text", + CommandAuthorized: true, + Provider: "telegram", + Surface: "telegram", + ...ctxOverrides, + } as MsgContext; + + const command = buildCommandContext({ + ctx, + cfg, + isGroup: false, + triggerBodyNormalized: commandBody.trim().toLowerCase(), + commandAuthorized: true, + }); + + return { + ctx, + cfg, + command, + directives: parseInlineDirectives(commandBody), + elevated: { enabled: true, allowed: true, failures: [] }, + sessionKey: "agent:main:main", + workspaceDir: "/tmp", + defaultGroupActivation: () => "mention", + resolvedVerboseLevel: "off" as const, + resolvedReasoningLevel: "off" as const, + resolveDefaultThinkingLevel: async () => undefined, + provider: "telegram", + model: "test-model", + contextTokens: 0, + isGroup: false, + }; +} + +describe("handleCommands /ptt", () => { + it("invokes talk.ptt.once on the default iOS node", async () => { + callGateway.mockImplementation(async (opts: { method?: string; params?: unknown }) => { + if (opts.method === "node.list") { + return { + nodes: [ + { + nodeId: "ios-1", + displayName: "iPhone", + platform: "ios", + connected: true, + }, + ], + }; + } + if (opts.method === "node.invoke") { + return { + ok: true, + nodeId: "ios-1", + command: "talk.ptt.once", + payload: { status: "offline" }, + }; + } + return { ok: true }; + }); + + const cfg = { + commands: { text: true }, + channels: { telegram: { allowFrom: ["*"] } }, + } as OpenClawConfig; + const params = buildParams("/ptt once", cfg); + const result = await handleCommands(params); + + expect(result.shouldContinue).toBe(false); + expect(result.reply?.text).toContain("PTT once"); + expect(result.reply?.text).toContain("status: offline"); + + const invokeCall = callGateway.mock.calls.find((call) => call[0]?.method === "node.invoke"); + expect(invokeCall).toBeTruthy(); + expect(invokeCall?.[0]?.params?.command).toBe("talk.ptt.once"); + expect(invokeCall?.[0]?.params?.idempotencyKey).toBe("idem-test"); + }); +}); diff --git a/src/auto-reply/reply/commands-ptt.ts b/src/auto-reply/reply/commands-ptt.ts new file mode 100644 index 000000000..bbf415a64 --- /dev/null +++ b/src/auto-reply/reply/commands-ptt.ts @@ -0,0 +1,211 @@ +import { logVerbose } from "../../globals.js"; +import { callGateway, randomIdempotencyKey } from "../../gateway/call.js"; +import type { OpenClawConfig } from "../../config/config.js"; +import type { CommandHandler } from "./commands-types.js"; + +type NodeSummary = { + nodeId: string; + displayName?: string; + platform?: string; + deviceFamily?: string; + remoteIp?: string; + connected?: boolean; +}; + +const PTT_COMMANDS: Record = { + 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 as Record) + : {}; + + 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}` } }; + } +}; diff --git a/src/cli/nodes-cli.coverage.test.ts b/src/cli/nodes-cli.coverage.test.ts index 13fc36caf..d5ae7c14b 100644 --- a/src/cli/nodes-cli.coverage.test.ts +++ b/src/cli/nodes-cli.coverage.test.ts @@ -251,4 +251,23 @@ describe("nodes-cli coverage", () => { }); expect(invoke?.params?.timeoutMs).toBe(6000); }); + + it("invokes talk.ptt.once via nodes talk ptt once", async () => { + runtimeLogs.length = 0; + runtimeErrors.length = 0; + callGateway.mockClear(); + randomIdempotencyKey.mockClear(); + + const { registerNodesCli } = await import("./nodes-cli.js"); + const program = new Command(); + program.exitOverride(); + registerNodesCli(program); + + await program.parseAsync(["nodes", "talk", "ptt", "once", "--node", "mac-1"], { from: "user" }); + + const invoke = callGateway.mock.calls.find((call) => call[0]?.method === "node.invoke")?.[0]; + expect(invoke).toBeTruthy(); + expect(invoke?.params?.command).toBe("talk.ptt.once"); + expect(invoke?.params?.idempotencyKey).toBe("rk_test"); + }); }); diff --git a/src/cli/nodes-cli/register.talk.ts b/src/cli/nodes-cli/register.talk.ts new file mode 100644 index 000000000..6bc77a198 --- /dev/null +++ b/src/cli/nodes-cli/register.talk.ts @@ -0,0 +1,79 @@ +import type { Command } from "commander"; +import { randomIdempotencyKey } from "../../gateway/call.js"; +import { defaultRuntime } from "../../runtime.js"; +import { runNodesCommand } from "./cli-utils.js"; +import { callGatewayCli, nodesCallOpts, resolveNodeId } from "./rpc.js"; +import type { NodesRpcOpts } from "./types.js"; + +type PTTAction = { + name: string; + command: string; + description: string; +}; + +const PTT_ACTIONS: PTTAction[] = [ + { name: "start", command: "talk.ptt.start", description: "Start push-to-talk capture" }, + { name: "stop", command: "talk.ptt.stop", description: "Stop push-to-talk capture" }, + { name: "once", command: "talk.ptt.once", description: "Run push-to-talk once" }, + { name: "cancel", command: "talk.ptt.cancel", description: "Cancel push-to-talk capture" }, +]; + +export function registerNodesTalkCommands(nodes: Command) { + const talk = nodes.command("talk").description("Talk/voice controls on a paired node"); + const ptt = talk.command("ptt").description("Push-to-talk controls"); + + for (const action of PTT_ACTIONS) { + nodesCallOpts( + ptt + .command(action.name) + .description(action.description) + .requiredOption("--node ", "Node id, name, or IP") + .option("--invoke-timeout ", "Node invoke timeout in ms (default 15000)", "15000") + .action(async (opts: NodesRpcOpts) => { + await runNodesCommand(`talk ptt ${action.name}`, async () => { + const nodeId = await resolveNodeId(opts, String(opts.node ?? "")); + const invokeTimeoutMs = opts.invokeTimeout + ? Number.parseInt(String(opts.invokeTimeout), 10) + : undefined; + + const invokeParams: Record = { + nodeId, + command: action.command, + params: {}, + idempotencyKey: randomIdempotencyKey(), + }; + if (typeof invokeTimeoutMs === "number" && Number.isFinite(invokeTimeoutMs)) { + invokeParams.timeoutMs = invokeTimeoutMs; + } + + const raw = await callGatewayCli("node.invoke", opts, invokeParams); + const res = + typeof raw === "object" && raw !== null ? (raw as { payload?: unknown }) : {}; + const payload = + res.payload && typeof res.payload === "object" + ? (res.payload as Record) + : {}; + + if (opts.json) { + defaultRuntime.log(JSON.stringify(payload, null, 2)); + return; + } + + const lines = [`PTT ${action.name} → ${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}`); + } + + defaultRuntime.log(lines.join("\n")); + }); + }), + { timeoutMs: 30_000 }, + ); + } +} diff --git a/src/cli/nodes-cli/register.ts b/src/cli/nodes-cli/register.ts index 04e4391bf..5297b5933 100644 --- a/src/cli/nodes-cli/register.ts +++ b/src/cli/nodes-cli/register.ts @@ -9,6 +9,7 @@ import { registerNodesNotifyCommand } from "./register.notify.js"; import { registerNodesPairingCommands } from "./register.pairing.js"; import { registerNodesScreenCommands } from "./register.screen.js"; import { registerNodesStatusCommands } from "./register.status.js"; +import { registerNodesTalkCommands } from "./register.talk.js"; export function registerNodesCli(program: Command) { const nodes = program @@ -28,4 +29,5 @@ export function registerNodesCli(program: Command) { registerNodesCameraCommands(nodes); registerNodesScreenCommands(nodes); registerNodesLocationCommands(nodes); + registerNodesTalkCommands(nodes); }