Compare commits

...
1 Commits
Author SHA1 Message Date
khairul169 166c96caf2 feat: add device select & cmd exec 2026-09-22 14:42:36 +08:00
5 changed files with 190 additions and 8 deletions
+8 -4
View File
@@ -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
View File
@@ -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>
);
}
+9
View File
@@ -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)]"> */}
+51
View File
@@ -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 };
+17
View File
@@ -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 };