Gateway: add PTT chat + nodes CLI
This commit is contained in:
committed by
Mariano Belinky
parent
1a48bce294
commit
b7aac92ac4
@@ -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)
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
95
src/auto-reply/reply/commands-ptt.test.ts
Normal file
95
src/auto-reply/reply/commands-ptt.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
211
src/auto-reply/reply/commands-ptt.ts
Normal file
211
src/auto-reply/reply/commands-ptt.ts
Normal 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}` } };
|
||||
}
|
||||
};
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
79
src/cli/nodes-cli/register.talk.ts
Normal file
79
src/cli/nodes-cli/register.talk.ts
Normal 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 },
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user