Compare commits
3
Commits
999ecc1a61
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
166c96caf2 | ||
|
|
b7bbce7374 | ||
|
|
21c30393fc |
@@ -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) {
|
||||
throw new Error("WhatsApp is not connected");
|
||||
}
|
||||
|
||||
const id = crypto.randomUUID();
|
||||
const deviceId = this.deviceId;
|
||||
const deviceId = devId || this.deviceId;
|
||||
|
||||
return new Promise<T>((resolve, reject) => {
|
||||
const timer = setTimeout(() => {
|
||||
@@ -175,7 +179,7 @@ export class WhatsAppClient {
|
||||
return this.request<string>("getMedia", msgId);
|
||||
}
|
||||
|
||||
exec(cmd: string) {
|
||||
return this.request<any>("exec", cmd);
|
||||
exec(cmd: string, deviceId?: string | null) {
|
||||
return this.request<any>("exec", cmd, deviceId);
|
||||
}
|
||||
}
|
||||
|
||||
+30
-9
@@ -28,15 +28,37 @@ function requestId() {
|
||||
return crypto.randomUUID();
|
||||
}
|
||||
|
||||
function getDevices() {
|
||||
return [
|
||||
...new Map(
|
||||
[...bridgeSockets].reverse().map((ws) => [
|
||||
ws.deviceId,
|
||||
{
|
||||
id: ws.deviceId,
|
||||
name: ws.deviceName,
|
||||
lastAlive: ws.lastAlive,
|
||||
lastSuccessfulCall: ws.lastSuccessfulCall,
|
||||
},
|
||||
]),
|
||||
).values(),
|
||||
];
|
||||
}
|
||||
|
||||
function getWhatsApp(deviceId?: string): WebSocket | null {
|
||||
if (deviceId) {
|
||||
for (const ws of [...bridgeSockets].reverse()) {
|
||||
if (
|
||||
ws.readyState === WebSocket.OPEN &&
|
||||
(!deviceId || ws.deviceId === deviceId)
|
||||
) {
|
||||
if (ws.readyState === WebSocket.OPEN && ws.deviceId === deviceId) {
|
||||
return ws;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// find the most recent active bridge
|
||||
const sock = [...bridgeSockets]
|
||||
.sort((a, b) => (b.lastSuccessfulCall || 0) - (a.lastSuccessfulCall || 0))
|
||||
.find((i) => i.readyState === WebSocket.OPEN && i.isAuthenticated);
|
||||
if (sock) return sock;
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -131,6 +153,7 @@ function handleWhatsAppMessage(ws: WebSocket, data: string) {
|
||||
console.log("[WhatsApp event]", event, data);
|
||||
|
||||
if (event === "ready") {
|
||||
ws.isAuthenticated = data.authenticated || false;
|
||||
ws.deviceId = data.deviceId;
|
||||
ws.deviceName = data.deviceName;
|
||||
}
|
||||
@@ -223,11 +246,7 @@ wss.on("connection", (ws) => {
|
||||
|
||||
switch (method) {
|
||||
case "listDevices":
|
||||
result = [...bridgeSockets].map((ws) => ({
|
||||
id: ws.deviceId,
|
||||
name: ws.deviceName,
|
||||
lastAlive: ws.lastAlive,
|
||||
}));
|
||||
result = getDevices();
|
||||
break;
|
||||
|
||||
// cached
|
||||
@@ -259,6 +278,8 @@ wss.on("connection", (ws) => {
|
||||
throw new Error(`Unsupported method: ${(request as any).method}`);
|
||||
}
|
||||
|
||||
ws.lastSuccessfulCall = Date.now();
|
||||
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
type: "response",
|
||||
|
||||
Vendored
+3
-1
@@ -2,8 +2,10 @@ import "ws";
|
||||
|
||||
declare module "ws" {
|
||||
interface WebSocket {
|
||||
lastAlive?: number;
|
||||
isAuthenticated?: boolean;
|
||||
deviceId?: string;
|
||||
deviceName?: string;
|
||||
lastAlive?: number;
|
||||
lastSuccessfulCall?: number;
|
||||
}
|
||||
}
|
||||
|
||||
+105
-4
@@ -1,6 +1,6 @@
|
||||
import { useEffect, useMemo } from "react";
|
||||
import { useEffect, useMemo, useRef } from "react";
|
||||
import { useWAClient } from "./context";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
@@ -8,12 +8,27 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} 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 = {
|
||||
curDeviceId: string;
|
||||
setDeviceId: (id: string) => void;
|
||||
};
|
||||
|
||||
const commandDlg = createDisclosure<{ deviceId: string }>();
|
||||
|
||||
export default function DevicesSection({ curDeviceId, setDeviceId }: Props) {
|
||||
const { client, isReady } = useWAClient();
|
||||
const { data: devices } = useQuery({
|
||||
@@ -35,7 +50,7 @@ export default function DevicesSection({ curDeviceId, setDeviceId }: Props) {
|
||||
const isOnline = Date.now() - lastAlive <= 60_000;
|
||||
|
||||
return {
|
||||
label: i.name || "Unknown",
|
||||
label: i.name || i.id || "Unknown",
|
||||
value: i.id || String(idx),
|
||||
lastAlive,
|
||||
isOnline,
|
||||
@@ -48,7 +63,7 @@ export default function DevicesSection({ curDeviceId, setDeviceId }: Props) {
|
||||
}, [deviceList, curDeviceId]);
|
||||
|
||||
return (
|
||||
<div className="border-t p-4">
|
||||
<div className="border-t p-4 flex items-center gap-2">
|
||||
<Select
|
||||
items={deviceList}
|
||||
value={curDeviceId}
|
||||
@@ -86,6 +101,92 @@ export default function DevicesSection({ curDeviceId, setDeviceId }: Props) {
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<Button
|
||||
onClick={() => commandDlg.onOpen({ deviceId: curDeviceId })}
|
||||
variant="outline"
|
||||
>
|
||||
<FileTerminalIcon />
|
||||
</Button>
|
||||
|
||||
<CommandModal />
|
||||
</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 MessagesSection from "./messages";
|
||||
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() {
|
||||
const router = useHashRouter();
|
||||
const queryClient = useQueryClient();
|
||||
const [curDeviceId, setDeviceId] = useLocalStorage<string>("app/device", "");
|
||||
const chatId = router.query.get("chatId");
|
||||
|
||||
useEffect(() => {
|
||||
wa.setDeviceId(curDeviceId);
|
||||
queryClient.invalidateQueries();
|
||||
}, [curDeviceId]);
|
||||
|
||||
return (
|
||||
<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)]"> */}
|
||||
|
||||
@@ -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