fix(gateway): resolve iOS node invokes
This commit is contained in:
@@ -1,5 +1,5 @@
|
|||||||
import type { Command } from "commander";
|
import type { Command } from "commander";
|
||||||
import { callGateway } from "../gateway/call.js";
|
import { callGateway, randomIdempotencyKey } from "../gateway/call.js";
|
||||||
import { defaultRuntime } from "../runtime.js";
|
import { defaultRuntime } from "../runtime.js";
|
||||||
|
|
||||||
type NodesRpcOpts = {
|
type NodesRpcOpts = {
|
||||||
@@ -11,6 +11,16 @@ type NodesRpcOpts = {
|
|||||||
command?: string;
|
command?: string;
|
||||||
params?: string;
|
params?: string;
|
||||||
invokeTimeout?: string;
|
invokeTimeout?: string;
|
||||||
|
idempotencyKey?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type NodeListNode = {
|
||||||
|
nodeId: string;
|
||||||
|
displayName?: string;
|
||||||
|
platform?: string;
|
||||||
|
version?: string;
|
||||||
|
remoteIp?: string;
|
||||||
|
connected?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
type PendingRequest = {
|
type PendingRequest = {
|
||||||
@@ -40,11 +50,15 @@ type PairingList = {
|
|||||||
paired: PairedNode[];
|
paired: PairedNode[];
|
||||||
};
|
};
|
||||||
|
|
||||||
const nodesCallOpts = (cmd: Command) =>
|
const nodesCallOpts = (cmd: Command, defaults?: { timeoutMs?: number }) =>
|
||||||
cmd
|
cmd
|
||||||
.option("--url <url>", "Gateway WebSocket URL", "ws://127.0.0.1:18789")
|
.option("--url <url>", "Gateway WebSocket URL", "ws://127.0.0.1:18789")
|
||||||
.option("--token <token>", "Gateway token (if required)")
|
.option("--token <token>", "Gateway token (if required)")
|
||||||
.option("--timeout <ms>", "Timeout in ms", "10000")
|
.option(
|
||||||
|
"--timeout <ms>",
|
||||||
|
"Timeout in ms",
|
||||||
|
String(defaults?.timeoutMs ?? 10_000),
|
||||||
|
)
|
||||||
.option("--json", "Output JSON", false);
|
.option("--json", "Output JSON", false);
|
||||||
|
|
||||||
const callGatewayCli = async (
|
const callGatewayCli = async (
|
||||||
@@ -85,6 +99,67 @@ function parsePairingList(value: unknown): PairingList {
|
|||||||
return { pending, paired };
|
return { pending, paired };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function parseNodeList(value: unknown): NodeListNode[] {
|
||||||
|
const obj =
|
||||||
|
typeof value === "object" && value !== null
|
||||||
|
? (value as Record<string, unknown>)
|
||||||
|
: {};
|
||||||
|
return Array.isArray(obj.nodes) ? (obj.nodes as NodeListNode[]) : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeNodeKey(value: string) {
|
||||||
|
return value
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/[^a-z0-9]+/g, "-")
|
||||||
|
.replace(/^-+/, "")
|
||||||
|
.replace(/-+$/, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resolveNodeId(opts: NodesRpcOpts, query: string) {
|
||||||
|
const q = String(query ?? "").trim();
|
||||||
|
if (!q) throw new Error("node required");
|
||||||
|
|
||||||
|
let nodes: NodeListNode[] = [];
|
||||||
|
try {
|
||||||
|
const res = (await callGatewayCli("node.list", opts, {})) as unknown;
|
||||||
|
nodes = parseNodeList(res);
|
||||||
|
} catch {
|
||||||
|
const res = (await callGatewayCli("node.pair.list", opts, {})) as unknown;
|
||||||
|
const { paired } = parsePairingList(res);
|
||||||
|
nodes = paired.map((n) => ({
|
||||||
|
nodeId: n.nodeId,
|
||||||
|
displayName: n.displayName,
|
||||||
|
platform: n.platform,
|
||||||
|
version: n.version,
|
||||||
|
remoteIp: n.remoteIp,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
const qNorm = normalizeNodeKey(q);
|
||||||
|
const matches = nodes.filter((n) => {
|
||||||
|
if (n.nodeId === q) return true;
|
||||||
|
if (typeof n.remoteIp === "string" && n.remoteIp === q) return true;
|
||||||
|
const name = typeof n.displayName === "string" ? n.displayName : "";
|
||||||
|
if (name && normalizeNodeKey(name) === qNorm) return true;
|
||||||
|
if (q.length >= 6 && n.nodeId.startsWith(q)) return true;
|
||||||
|
return false;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (matches.length === 1) return matches[0].nodeId;
|
||||||
|
if (matches.length === 0) {
|
||||||
|
const known = nodes
|
||||||
|
.map((n) => n.displayName || n.remoteIp || n.nodeId)
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(", ");
|
||||||
|
throw new Error(`unknown node: ${q}${known ? ` (known: ${known})` : ""}`);
|
||||||
|
}
|
||||||
|
throw new Error(
|
||||||
|
`ambiguous node: ${q} (matches: ${matches
|
||||||
|
.map((n) => n.displayName || n.remoteIp || n.nodeId)
|
||||||
|
.join(", ")})`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function registerNodesCli(program: Command) {
|
export function registerNodesCli(program: Command) {
|
||||||
const nodes = program
|
const nodes = program
|
||||||
.command("nodes")
|
.command("nodes")
|
||||||
@@ -215,32 +290,38 @@ export function registerNodesCli(program: Command) {
|
|||||||
nodesCallOpts(
|
nodesCallOpts(
|
||||||
nodes
|
nodes
|
||||||
.command("invoke")
|
.command("invoke")
|
||||||
.description("Invoke a command on a connected node")
|
.description("Invoke a command on a paired node")
|
||||||
.requiredOption("--node <nodeId>", "Node id (instanceId)")
|
.requiredOption("--node <idOrNameOrIp>", "Node id, name, or IP")
|
||||||
.requiredOption("--command <command>", "Command (e.g. screen.eval)")
|
.requiredOption("--command <command>", "Command (e.g. screen.eval)")
|
||||||
.option("--params <json>", "JSON object string for params")
|
.option("--params <json>", "JSON object string for params", "{}")
|
||||||
.option(
|
.option(
|
||||||
"--invoke-timeout <ms>",
|
"--invoke-timeout <ms>",
|
||||||
"Node invoke timeout in ms (default 15000)",
|
"Node invoke timeout in ms (default 15000)",
|
||||||
|
"15000",
|
||||||
)
|
)
|
||||||
|
.option("--idempotency-key <key>", "Idempotency key (optional)")
|
||||||
.action(async (opts: NodesRpcOpts) => {
|
.action(async (opts: NodesRpcOpts) => {
|
||||||
try {
|
try {
|
||||||
const nodeId = String(opts.node ?? "").trim();
|
const nodeId = await resolveNodeId(opts, String(opts.node ?? ""));
|
||||||
const command = String(opts.command ?? "").trim();
|
const command = String(opts.command ?? "").trim();
|
||||||
if (!nodeId || !command) {
|
if (!nodeId || !command) {
|
||||||
defaultRuntime.error("--node and --command required");
|
defaultRuntime.error("--node and --command required");
|
||||||
defaultRuntime.exit(1);
|
defaultRuntime.exit(1);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const params = opts.params
|
const params = JSON.parse(String(opts.params ?? "{}")) as unknown;
|
||||||
? (JSON.parse(String(opts.params)) as unknown)
|
|
||||||
: undefined;
|
|
||||||
const timeoutMs = opts.invokeTimeout
|
const timeoutMs = opts.invokeTimeout
|
||||||
? Number.parseInt(String(opts.invokeTimeout), 10)
|
? Number.parseInt(String(opts.invokeTimeout), 10)
|
||||||
: undefined;
|
: undefined;
|
||||||
|
|
||||||
const invokeParams: Record<string, unknown> = { nodeId, command };
|
const invokeParams: Record<string, unknown> = {
|
||||||
if (params !== undefined) invokeParams.params = params;
|
nodeId,
|
||||||
|
command,
|
||||||
|
params,
|
||||||
|
idempotencyKey: String(
|
||||||
|
opts.idempotencyKey ?? randomIdempotencyKey(),
|
||||||
|
),
|
||||||
|
};
|
||||||
if (typeof timeoutMs === "number" && Number.isFinite(timeoutMs)) {
|
if (typeof timeoutMs === "number" && Number.isFinite(timeoutMs)) {
|
||||||
invokeParams.timeoutMs = timeoutMs;
|
invokeParams.timeoutMs = timeoutMs;
|
||||||
}
|
}
|
||||||
@@ -250,11 +331,13 @@ export function registerNodesCli(program: Command) {
|
|||||||
opts,
|
opts,
|
||||||
invokeParams,
|
invokeParams,
|
||||||
);
|
);
|
||||||
|
|
||||||
defaultRuntime.log(JSON.stringify(result, null, 2));
|
defaultRuntime.log(JSON.stringify(result, null, 2));
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
defaultRuntime.error(`nodes invoke failed: ${String(err)}`);
|
defaultRuntime.error(`nodes invoke failed: ${String(err)}`);
|
||||||
defaultRuntime.exit(1);
|
defaultRuntime.exit(1);
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
|
{ timeoutMs: 30_000 },
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ vi.mock("../provider-web.js", () => ({
|
|||||||
}));
|
}));
|
||||||
vi.mock("../gateway/call.js", () => ({
|
vi.mock("../gateway/call.js", () => ({
|
||||||
callGateway,
|
callGateway,
|
||||||
|
randomIdempotencyKey: () => "idem-test",
|
||||||
}));
|
}));
|
||||||
vi.mock("../webchat/server.js", () => ({
|
vi.mock("../webchat/server.js", () => ({
|
||||||
startWebChatServer,
|
startWebChatServer,
|
||||||
@@ -93,12 +94,24 @@ describe("cli program", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("runs nodes invoke and calls node.invoke", async () => {
|
it("runs nodes invoke and calls node.invoke", async () => {
|
||||||
callGateway.mockResolvedValue({
|
callGateway
|
||||||
ok: true,
|
.mockResolvedValueOnce({
|
||||||
nodeId: "ios-node",
|
ts: Date.now(),
|
||||||
command: "screen.eval",
|
nodes: [
|
||||||
payload: { result: "ok" },
|
{
|
||||||
});
|
nodeId: "ios-node",
|
||||||
|
displayName: "iOS Node",
|
||||||
|
remoteIp: "192.168.0.88",
|
||||||
|
connected: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
})
|
||||||
|
.mockResolvedValueOnce({
|
||||||
|
ok: true,
|
||||||
|
nodeId: "ios-node",
|
||||||
|
command: "screen.eval",
|
||||||
|
payload: { result: "ok" },
|
||||||
|
});
|
||||||
|
|
||||||
const program = buildProgram();
|
const program = buildProgram();
|
||||||
runtime.log.mockClear();
|
runtime.log.mockClear();
|
||||||
@@ -116,13 +129,20 @@ describe("cli program", () => {
|
|||||||
{ from: "user" },
|
{ from: "user" },
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(callGateway).toHaveBeenCalledWith(
|
expect(callGateway).toHaveBeenNthCalledWith(
|
||||||
|
1,
|
||||||
|
expect.objectContaining({ method: "node.list", params: {} }),
|
||||||
|
);
|
||||||
|
expect(callGateway).toHaveBeenNthCalledWith(
|
||||||
|
2,
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
method: "node.invoke",
|
method: "node.invoke",
|
||||||
params: {
|
params: {
|
||||||
nodeId: "ios-node",
|
nodeId: "ios-node",
|
||||||
command: "screen.eval",
|
command: "screen.eval",
|
||||||
params: { javaScript: "1+1" },
|
params: { javaScript: "1+1" },
|
||||||
|
timeoutMs: 15000,
|
||||||
|
idempotencyKey: "idem-test",
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -51,7 +51,8 @@ export class GatewayClient {
|
|||||||
start() {
|
start() {
|
||||||
if (this.closed) return;
|
if (this.closed) return;
|
||||||
const url = this.opts.url ?? "ws://127.0.0.1:18789";
|
const url = this.opts.url ?? "ws://127.0.0.1:18789";
|
||||||
this.ws = new WebSocket(url, { maxPayload: 512 * 1024 });
|
// Allow node screen snapshots and other large responses.
|
||||||
|
this.ws = new WebSocket(url, { maxPayload: 10 * 1024 * 1024 });
|
||||||
|
|
||||||
this.ws.on("open", () => this.sendConnect());
|
this.ws.on("open", () => this.sendConnect());
|
||||||
this.ws.on("message", (data) => this.handleMessage(data.toString()));
|
this.ws.on("message", (data) => this.handleMessage(data.toString()));
|
||||||
|
|||||||
@@ -38,6 +38,8 @@ import {
|
|||||||
HelloOkSchema,
|
HelloOkSchema,
|
||||||
type NodeInvokeParams,
|
type NodeInvokeParams,
|
||||||
NodeInvokeParamsSchema,
|
NodeInvokeParamsSchema,
|
||||||
|
type NodeListParams,
|
||||||
|
NodeListParamsSchema,
|
||||||
type NodePairApproveParams,
|
type NodePairApproveParams,
|
||||||
NodePairApproveParamsSchema,
|
NodePairApproveParamsSchema,
|
||||||
type NodePairListParams,
|
type NodePairListParams,
|
||||||
@@ -105,6 +107,8 @@ export const validateNodePairRejectParams = ajv.compile<NodePairRejectParams>(
|
|||||||
export const validateNodePairVerifyParams = ajv.compile<NodePairVerifyParams>(
|
export const validateNodePairVerifyParams = ajv.compile<NodePairVerifyParams>(
|
||||||
NodePairVerifyParamsSchema,
|
NodePairVerifyParamsSchema,
|
||||||
);
|
);
|
||||||
|
export const validateNodeListParams =
|
||||||
|
ajv.compile<NodeListParams>(NodeListParamsSchema);
|
||||||
export const validateNodeInvokeParams = ajv.compile<NodeInvokeParams>(
|
export const validateNodeInvokeParams = ajv.compile<NodeInvokeParams>(
|
||||||
NodeInvokeParamsSchema,
|
NodeInvokeParamsSchema,
|
||||||
);
|
);
|
||||||
@@ -163,6 +167,7 @@ export {
|
|||||||
NodePairApproveParamsSchema,
|
NodePairApproveParamsSchema,
|
||||||
NodePairRejectParamsSchema,
|
NodePairRejectParamsSchema,
|
||||||
NodePairVerifyParamsSchema,
|
NodePairVerifyParamsSchema,
|
||||||
|
NodeListParamsSchema,
|
||||||
NodeInvokeParamsSchema,
|
NodeInvokeParamsSchema,
|
||||||
SessionsListParamsSchema,
|
SessionsListParamsSchema,
|
||||||
SessionsPatchParamsSchema,
|
SessionsPatchParamsSchema,
|
||||||
@@ -205,6 +210,7 @@ export type {
|
|||||||
NodePairApproveParams,
|
NodePairApproveParams,
|
||||||
NodePairRejectParams,
|
NodePairRejectParams,
|
||||||
NodePairVerifyParams,
|
NodePairVerifyParams,
|
||||||
|
NodeListParams,
|
||||||
NodeInvokeParams,
|
NodeInvokeParams,
|
||||||
SessionsListParams,
|
SessionsListParams,
|
||||||
SessionsPatchParams,
|
SessionsPatchParams,
|
||||||
|
|||||||
@@ -243,12 +243,18 @@ export const NodePairVerifyParamsSchema = Type.Object(
|
|||||||
{ additionalProperties: false },
|
{ additionalProperties: false },
|
||||||
);
|
);
|
||||||
|
|
||||||
|
export const NodeListParamsSchema = Type.Object(
|
||||||
|
{},
|
||||||
|
{ additionalProperties: false },
|
||||||
|
);
|
||||||
|
|
||||||
export const NodeInvokeParamsSchema = Type.Object(
|
export const NodeInvokeParamsSchema = Type.Object(
|
||||||
{
|
{
|
||||||
nodeId: NonEmptyString,
|
nodeId: NonEmptyString,
|
||||||
command: NonEmptyString,
|
command: NonEmptyString,
|
||||||
params: Type.Optional(Type.Unknown()),
|
params: Type.Optional(Type.Unknown()),
|
||||||
timeoutMs: Type.Optional(Type.Integer({ minimum: 0 })),
|
timeoutMs: Type.Optional(Type.Integer({ minimum: 0 })),
|
||||||
|
idempotencyKey: NonEmptyString,
|
||||||
},
|
},
|
||||||
{ additionalProperties: false },
|
{ additionalProperties: false },
|
||||||
);
|
);
|
||||||
@@ -507,6 +513,7 @@ export const ProtocolSchemas: Record<string, TSchema> = {
|
|||||||
NodePairApproveParams: NodePairApproveParamsSchema,
|
NodePairApproveParams: NodePairApproveParamsSchema,
|
||||||
NodePairRejectParams: NodePairRejectParamsSchema,
|
NodePairRejectParams: NodePairRejectParamsSchema,
|
||||||
NodePairVerifyParams: NodePairVerifyParamsSchema,
|
NodePairVerifyParams: NodePairVerifyParamsSchema,
|
||||||
|
NodeListParams: NodeListParamsSchema,
|
||||||
NodeInvokeParams: NodeInvokeParamsSchema,
|
NodeInvokeParams: NodeInvokeParamsSchema,
|
||||||
SessionsListParams: SessionsListParamsSchema,
|
SessionsListParams: SessionsListParamsSchema,
|
||||||
SessionsPatchParams: SessionsPatchParamsSchema,
|
SessionsPatchParams: SessionsPatchParamsSchema,
|
||||||
@@ -545,6 +552,7 @@ export type NodePairListParams = Static<typeof NodePairListParamsSchema>;
|
|||||||
export type NodePairApproveParams = Static<typeof NodePairApproveParamsSchema>;
|
export type NodePairApproveParams = Static<typeof NodePairApproveParamsSchema>;
|
||||||
export type NodePairRejectParams = Static<typeof NodePairRejectParamsSchema>;
|
export type NodePairRejectParams = Static<typeof NodePairRejectParamsSchema>;
|
||||||
export type NodePairVerifyParams = Static<typeof NodePairVerifyParamsSchema>;
|
export type NodePairVerifyParams = Static<typeof NodePairVerifyParamsSchema>;
|
||||||
|
export type NodeListParams = Static<typeof NodeListParamsSchema>;
|
||||||
export type NodeInvokeParams = Static<typeof NodeInvokeParamsSchema>;
|
export type NodeInvokeParams = Static<typeof NodeInvokeParamsSchema>;
|
||||||
export type SessionsListParams = Static<typeof SessionsListParamsSchema>;
|
export type SessionsListParams = Static<typeof SessionsListParamsSchema>;
|
||||||
export type SessionsPatchParams = Static<typeof SessionsPatchParamsSchema>;
|
export type SessionsPatchParams = Static<typeof SessionsPatchParamsSchema>;
|
||||||
|
|||||||
@@ -399,6 +399,7 @@ describe("gateway server", () => {
|
|||||||
command: "screen.eval",
|
command: "screen.eval",
|
||||||
params: { javaScript: "2+2" },
|
params: { javaScript: "2+2" },
|
||||||
timeoutMs: 123,
|
timeoutMs: 123,
|
||||||
|
idempotencyKey: "idem-1",
|
||||||
});
|
});
|
||||||
expect(res.ok).toBe(true);
|
expect(res.ok).toBe(true);
|
||||||
|
|
||||||
|
|||||||
@@ -101,6 +101,7 @@ import {
|
|||||||
validateCronStatusParams,
|
validateCronStatusParams,
|
||||||
validateCronUpdateParams,
|
validateCronUpdateParams,
|
||||||
validateNodeInvokeParams,
|
validateNodeInvokeParams,
|
||||||
|
validateNodeListParams,
|
||||||
validateNodePairApproveParams,
|
validateNodePairApproveParams,
|
||||||
validateNodePairListParams,
|
validateNodePairListParams,
|
||||||
validateNodePairRejectParams,
|
validateNodePairRejectParams,
|
||||||
@@ -177,6 +178,7 @@ const METHODS = [
|
|||||||
"node.pair.approve",
|
"node.pair.approve",
|
||||||
"node.pair.reject",
|
"node.pair.reject",
|
||||||
"node.pair.verify",
|
"node.pair.verify",
|
||||||
|
"node.list",
|
||||||
"node.invoke",
|
"node.invoke",
|
||||||
"cron.list",
|
"cron.list",
|
||||||
"cron.status",
|
"cron.status",
|
||||||
@@ -2056,6 +2058,49 @@ export async function startGatewayServer(
|
|||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
case "node.list": {
|
||||||
|
const params = (req.params ?? {}) as Record<string, unknown>;
|
||||||
|
if (!validateNodeListParams(params)) {
|
||||||
|
respond(
|
||||||
|
false,
|
||||||
|
undefined,
|
||||||
|
errorShape(
|
||||||
|
ErrorCodes.INVALID_REQUEST,
|
||||||
|
`invalid node.list params: ${formatValidationErrors(validateNodeListParams.errors)}`,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const list = await listNodePairing();
|
||||||
|
const connected = bridge?.listConnected?.() ?? [];
|
||||||
|
const connectedById = new Map(
|
||||||
|
connected.map((n) => [n.nodeId, n]),
|
||||||
|
);
|
||||||
|
|
||||||
|
const nodes = list.paired.map((n) => {
|
||||||
|
const live = connectedById.get(n.nodeId);
|
||||||
|
return {
|
||||||
|
nodeId: n.nodeId,
|
||||||
|
displayName: live?.displayName ?? n.displayName,
|
||||||
|
platform: live?.platform ?? n.platform,
|
||||||
|
version: live?.version ?? n.version,
|
||||||
|
remoteIp: live?.remoteIp ?? n.remoteIp,
|
||||||
|
connected: Boolean(live),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
respond(true, { ts: Date.now(), nodes }, undefined);
|
||||||
|
} catch (err) {
|
||||||
|
respond(
|
||||||
|
false,
|
||||||
|
undefined,
|
||||||
|
errorShape(ErrorCodes.UNAVAILABLE, formatForLog(err)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
case "node.invoke": {
|
case "node.invoke": {
|
||||||
const params = (req.params ?? {}) as Record<string, unknown>;
|
const params = (req.params ?? {}) as Record<string, unknown>;
|
||||||
if (!validateNodeInvokeParams(params)) {
|
if (!validateNodeInvokeParams(params)) {
|
||||||
@@ -2082,6 +2127,7 @@ export async function startGatewayServer(
|
|||||||
command: string;
|
command: string;
|
||||||
params?: unknown;
|
params?: unknown;
|
||||||
timeoutMs?: number;
|
timeoutMs?: number;
|
||||||
|
idempotencyKey: string;
|
||||||
};
|
};
|
||||||
const nodeId = String(p.nodeId ?? "").trim();
|
const nodeId = String(p.nodeId ?? "").trim();
|
||||||
const command = String(p.command ?? "").trim();
|
const command = String(p.command ?? "").trim();
|
||||||
|
|||||||
Reference in New Issue
Block a user