feat: add device select & cmd exec
This commit is contained in:
@@ -89,13 +89,17 @@ export class WhatsAppClient {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private request<T>(method: string, params?: unknown): Promise<T> {
|
private request<T>(
|
||||||
|
method: string,
|
||||||
|
params?: unknown,
|
||||||
|
devId?: string | null,
|
||||||
|
): Promise<T> {
|
||||||
if (!this.isConnected || !this.socket) {
|
if (!this.isConnected || !this.socket) {
|
||||||
throw new Error("WhatsApp is not connected");
|
throw new Error("WhatsApp is not connected");
|
||||||
}
|
}
|
||||||
|
|
||||||
const id = crypto.randomUUID();
|
const id = crypto.randomUUID();
|
||||||
const deviceId = this.deviceId;
|
const deviceId = devId || this.deviceId;
|
||||||
|
|
||||||
return new Promise<T>((resolve, reject) => {
|
return new Promise<T>((resolve, reject) => {
|
||||||
const timer = setTimeout(() => {
|
const timer = setTimeout(() => {
|
||||||
@@ -175,7 +179,7 @@ export class WhatsAppClient {
|
|||||||
return this.request<string>("getMedia", msgId);
|
return this.request<string>("getMedia", msgId);
|
||||||
}
|
}
|
||||||
|
|
||||||
exec(cmd: string) {
|
exec(cmd: string, deviceId?: string | null) {
|
||||||
return this.request<any>("exec", cmd);
|
return this.request<any>("exec", cmd, deviceId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+105
-4
@@ -1,6 +1,6 @@
|
|||||||
import { useEffect, useMemo } from "react";
|
import { useEffect, useMemo, useRef } from "react";
|
||||||
import { useWAClient } from "./context";
|
import { useWAClient } from "./context";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||||
import {
|
import {
|
||||||
Select,
|
Select,
|
||||||
SelectContent,
|
SelectContent,
|
||||||
@@ -8,12 +8,27 @@ import {
|
|||||||
SelectTrigger,
|
SelectTrigger,
|
||||||
SelectValue,
|
SelectValue,
|
||||||
} from "@/components/ui/select";
|
} from "@/components/ui/select";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogClose,
|
||||||
|
DialogContent,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { createDisclosure } from "@/lib/utils";
|
||||||
|
import { FileTerminalIcon } from "lucide-react";
|
||||||
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
curDeviceId: string;
|
curDeviceId: string;
|
||||||
setDeviceId: (id: string) => void;
|
setDeviceId: (id: string) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const commandDlg = createDisclosure<{ deviceId: string }>();
|
||||||
|
|
||||||
export default function DevicesSection({ curDeviceId, setDeviceId }: Props) {
|
export default function DevicesSection({ curDeviceId, setDeviceId }: Props) {
|
||||||
const { client, isReady } = useWAClient();
|
const { client, isReady } = useWAClient();
|
||||||
const { data: devices } = useQuery({
|
const { data: devices } = useQuery({
|
||||||
@@ -35,7 +50,7 @@ export default function DevicesSection({ curDeviceId, setDeviceId }: Props) {
|
|||||||
const isOnline = Date.now() - lastAlive <= 60_000;
|
const isOnline = Date.now() - lastAlive <= 60_000;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
label: i.name || "Unknown",
|
label: i.name || i.id || "Unknown",
|
||||||
value: i.id || String(idx),
|
value: i.id || String(idx),
|
||||||
lastAlive,
|
lastAlive,
|
||||||
isOnline,
|
isOnline,
|
||||||
@@ -48,7 +63,7 @@ export default function DevicesSection({ curDeviceId, setDeviceId }: Props) {
|
|||||||
}, [deviceList, curDeviceId]);
|
}, [deviceList, curDeviceId]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="border-t p-4">
|
<div className="border-t p-4 flex items-center gap-2">
|
||||||
<Select
|
<Select
|
||||||
items={deviceList}
|
items={deviceList}
|
||||||
value={curDeviceId}
|
value={curDeviceId}
|
||||||
@@ -86,6 +101,92 @@ export default function DevicesSection({ curDeviceId, setDeviceId }: Props) {
|
|||||||
))}
|
))}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
onClick={() => commandDlg.onOpen({ deviceId: curDeviceId })}
|
||||||
|
variant="outline"
|
||||||
|
>
|
||||||
|
<FileTerminalIcon />
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<CommandModal />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const cmdPreset = [
|
||||||
|
{ title: "Status", command: "status" },
|
||||||
|
{ title: "Has Focus", command: "return document.hasFocus();" },
|
||||||
|
{ title: "Reload", command: "window.location.reload(); return true;" },
|
||||||
|
];
|
||||||
|
|
||||||
|
function CommandModal() {
|
||||||
|
const { data, isOpen } = commandDlg.useStore();
|
||||||
|
const { client } = useWAClient();
|
||||||
|
const cmdTextRef = useRef<HTMLTextAreaElement>(null);
|
||||||
|
|
||||||
|
const cmd = useMutation({
|
||||||
|
async mutationFn(cmd: string) {
|
||||||
|
if (cmd === "status") {
|
||||||
|
return client.status();
|
||||||
|
}
|
||||||
|
return client.exec(cmd, data?.deviceId);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const onSubmit = () => {
|
||||||
|
const command = cmdTextRef.current?.value || "";
|
||||||
|
cmd.mutate(command);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={isOpen} onOpenChange={commandDlg.onOpenChange}>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Execute Command</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Textarea ref={cmdTextRef} placeholder="cmd..." />
|
||||||
|
<div className="flex items-center gap-2 overflow-x-auto mt-2">
|
||||||
|
{cmdPreset.map((i) => (
|
||||||
|
<button
|
||||||
|
key={i.command}
|
||||||
|
onClick={() => (cmdTextRef.current!.value = i.command)}
|
||||||
|
>
|
||||||
|
<Badge className="cursor-pointer">{i.title}</Badge>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{cmd.error ? (
|
||||||
|
<div className="mt-3">
|
||||||
|
<p className="text-sm font-medium leading-tight">Error:</p>
|
||||||
|
<pre className="text-sm text-destructive mt-1">
|
||||||
|
{JSON.stringify(cmd.error, null, 2)}
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{cmd.data !== undefined ? (
|
||||||
|
<div className="mt-3">
|
||||||
|
<p className="text-sm font-medium leading-tight">Result:</p>
|
||||||
|
<pre className="text-sm mt-1">
|
||||||
|
{JSON.stringify((cmd as any).data, null, 2)}
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<DialogClose>
|
||||||
|
<Button variant="ghost">Cancel</Button>
|
||||||
|
</DialogClose>
|
||||||
|
<Button onClick={onSubmit} disabled={cmd.isPending}>
|
||||||
|
Execute
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -4,12 +4,21 @@ import { useLocalStorage } from "@/hooks/useLocalStorage";
|
|||||||
import { useHashRouter } from "@/hooks/useHashRouter";
|
import { useHashRouter } from "@/hooks/useHashRouter";
|
||||||
import MessagesSection from "./messages";
|
import MessagesSection from "./messages";
|
||||||
import ForwardDialog from "@/components/forward-dialog";
|
import ForwardDialog from "@/components/forward-dialog";
|
||||||
|
import { useEffect } from "react";
|
||||||
|
import wa from "@/lib/wa-client";
|
||||||
|
import { useQueryClient } from "@tanstack/react-query";
|
||||||
|
|
||||||
export default function Page() {
|
export default function Page() {
|
||||||
const router = useHashRouter();
|
const router = useHashRouter();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
const [curDeviceId, setDeviceId] = useLocalStorage<string>("app/device", "");
|
const [curDeviceId, setDeviceId] = useLocalStorage<string>("app/device", "");
|
||||||
const chatId = router.query.get("chatId");
|
const chatId = router.query.get("chatId");
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
wa.setDeviceId(curDeviceId);
|
||||||
|
queryClient.invalidateQueries();
|
||||||
|
}, [curDeviceId]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="bg-background text-foreground flex overflow-hidden h-screen max-h-dvh items-stretch">
|
<main className="bg-background text-foreground flex overflow-hidden h-screen max-h-dvh items-stretch">
|
||||||
{/* <div className="mx-auto flex h-dvh max-w-360 overflow-hidden bg-card shadow-xl sm:h-[calc(100dvh-2rem)] sm:rounded-2xl lg:h-[calc(100dvh-3rem)]"> */}
|
{/* <div className="mx-auto flex h-dvh max-w-360 overflow-hidden bg-card shadow-xl sm:h-[calc(100dvh-2rem)] sm:rounded-2xl lg:h-[calc(100dvh-3rem)]"> */}
|
||||||
|
|||||||
@@ -0,0 +1,51 @@
|
|||||||
|
import { mergeProps } from "@base-ui/react/merge-props";
|
||||||
|
import { useRender } from "@base-ui/react/use-render";
|
||||||
|
import { cva, type VariantProps } from "class-variance-authority";
|
||||||
|
import { cn } from "cn";
|
||||||
|
|
||||||
|
const badgeVariants = cva(
|
||||||
|
"group/badge inline-flex h-5 w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded-4xl border border-transparent px-2 py-0.5 text-xs font-medium whitespace-nowrap transition-all focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&>svg]:pointer-events-none [&>svg]:size-3!",
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default: "bg-primary text-primary-foreground [a]:hover:bg-primary/80",
|
||||||
|
secondary:
|
||||||
|
"bg-secondary text-secondary-foreground [a]:hover:bg-secondary/80",
|
||||||
|
destructive:
|
||||||
|
"bg-destructive/10 text-destructive focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:focus-visible:ring-destructive/40 [a]:hover:bg-destructive/20",
|
||||||
|
outline:
|
||||||
|
"border-border text-foreground [a]:hover:bg-muted [a]:hover:text-muted-foreground",
|
||||||
|
ghost:
|
||||||
|
"hover:bg-muted hover:text-muted-foreground dark:hover:bg-muted/50",
|
||||||
|
link: "text-primary underline-offset-4 hover:underline",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: "default",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
function Badge({
|
||||||
|
className,
|
||||||
|
variant = "default",
|
||||||
|
render,
|
||||||
|
...props
|
||||||
|
}: useRender.ComponentProps<"span"> & VariantProps<typeof badgeVariants>) {
|
||||||
|
return useRender({
|
||||||
|
defaultTagName: "span",
|
||||||
|
props: mergeProps<"span">(
|
||||||
|
{
|
||||||
|
className: cn(badgeVariants({ variant }), className),
|
||||||
|
},
|
||||||
|
props,
|
||||||
|
),
|
||||||
|
render,
|
||||||
|
state: {
|
||||||
|
slot: "badge",
|
||||||
|
variant,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Badge, badgeVariants };
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import * as React from "react";
|
||||||
|
import { cn } from "cn";
|
||||||
|
|
||||||
|
function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
|
||||||
|
return (
|
||||||
|
<textarea
|
||||||
|
data-slot="textarea"
|
||||||
|
className={cn(
|
||||||
|
"flex field-sizing-content min-h-16 w-full rounded-md border border-input bg-transparent px-2.5 py-2 text-base shadow-xs transition-[color,box-shadow] outline-none placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Textarea };
|
||||||
Reference in New Issue
Block a user