Gateway: add PTT chat + nodes CLI

This commit is contained in:
Mariano Belinky
2026-02-01 10:54:17 +01:00
committed by Mariano Belinky
parent 1a48bce294
commit b7aac92ac4
8 changed files with 432 additions and 1 deletions

View File

@@ -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=<id>]` (push-to-talk controls for a paired node)
- `/stop`
- `/restart`
- `/dock-telegram` (alias: `/dock_telegram`) (switch replies to Telegram)

View File

@@ -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=<id> (optional)",
type: "string",
},
],
}),
defineChatCommand({
key: "config",
nativeName: "config",

View File

@@ -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<Comm
handleUsageCommand,
handleRestartCommand,
handleTtsCommands,
handlePTTCommand,
handleHelpCommand,
handleCommandsListCommand,
handleStatusCommand,

View File

@@ -0,0 +1,95 @@
import { describe, expect, it, vi } from "vitest";
import type { OpenClawConfig } from "../../config/config.js";
import type { MsgContext } from "../templating.js";
import { buildCommandContext, handleCommands } from "./commands.js";
import { parseInlineDirectives } from "./directive-handling.js";
const callGateway = vi.fn(async (_opts: { method?: string }) => ({ 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<MsgContext>) {
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");
});
});

View File

@@ -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<string, string> = {
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<NodeSummary[]> {
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=<id>`,
);
}
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 <start|stop|once|cancel> [node=<id>]",
"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 || "<unknown>"}`);
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<string, unknown> = {
nodeId,
command: commandId,
params: {},
idempotencyKey: randomIdempotencyKey(),
timeoutMs: 15_000,
};
const res = await callGateway<{
ok?: boolean;
payload?: Record<string, unknown>;
command?: string;
nodeId?: string;
}>({
method: "node.invoke",
params: invokeParams,
config: cfg,
});
const payload =
res.payload && typeof res.payload === "object"
? (res.payload as Record<string, unknown>)
: {};
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}` } };
}
};

View File

@@ -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");
});
});

View File

@@ -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 <idOrNameOrIp>", "Node id, name, or IP")
.option("--invoke-timeout <ms>", "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<string, unknown> = {
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<string, unknown>)
: {};
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 },
);
}
}

View File

@@ -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);
}