feat: add more functionality

This commit is contained in:
2026-09-17 04:35:18 +08:00
parent 3fe95e925e
commit e3d55e4b7e
13 changed files with 189 additions and 184 deletions
+33 -54
View File
@@ -2,7 +2,7 @@
// ==UserScript==
// @name WhatsApp Web Bridge
// @namespace https://rul.sh/
// @version 0.0.6
// @version 0.0.7
// @description WhatsApp Web WebSocket bridge
// @match https://web.whatsapp.com/*
// @updateURL https://git.rul.sh/khairul169/whatsapp-bridge/raw/branch/main/dist/whatsapp-bridge.user.js
@@ -201,7 +201,7 @@
from: serializeId(message.from),
to: serializeId(message.to),
author: serializeId(message.author),
fromMe: message.id?.fromMe ?? message.fromMe ?? false,
fromMe: message.id?.fromMe || message.fromMe || false,
body: message.body ?? null,
caption: message.caption ?? null,
type: message.type ?? null,
@@ -230,8 +230,22 @@
}
// src/userscript/index.ts
var WS_URL = "wss://wa-bridge.rul.sh/ws?bridge=1";
var socket;
var consts = {
DEVICE_ID: String(Date.now()),
DEVICE_NAME: ""
};
function init() {
try {
const _consts = localStorage.getItem("wa-bridge-consts");
if (!_consts) {
localStorage.setItem("wa-bridge-consts", JSON.stringify(consts));
} else {
consts = JSON.parse(_consts);
}
} catch (_) {
}
}
function send(message) {
if (socket?.readyState !== WebSocket.OPEN) {
throw new Error("WebSocket is not connected");
@@ -262,7 +276,9 @@
case "status":
return {
authenticated: WPP.conn.isAuthenticated(),
ready: WPP.isReady
ready: WPP.isReady,
deviceId: consts.DEVICE_ID,
deviceName: consts.DEVICE_NAME
};
case "listChats": {
const chats = await WPP.chat.list({
@@ -279,7 +295,7 @@
}
case "listGroups": {
const groups = await WPP.group.getAllGroups();
return groups.filter(Boolean).map(serializeGroupMetadata);
return groups.filter((i) => i.groupMetadata != null).map((i) => serializeGroupMetadata(i.groupMetadata));
}
case "getMessages": {
const messages = await WPP.chat.getMessages(request.params.chatId, {
@@ -289,12 +305,6 @@
});
return messages.filter(Boolean).map(serializeMessage);
}
case "searchMessages":
return searchMessages(
request.params.query,
request.params.chatId,
request.params.limit ?? 100
);
case "sendText": {
const result = await WPP.chat.sendTextMessage(
request.params.chatId,
@@ -311,68 +321,36 @@
case "getMedia":
const media = await WPP.chat.downloadMedia(request.params).then(WPP.util.blobToBase64);
return media;
case "exec":
const fn = new Function(request.params);
return fn();
default:
throw new Error(`Unknown method: ${request.method}`);
}
}
async function searchMessages(query, chatId, limit = 100) {
const normalized = query.toLowerCase();
let chats;
if (chatId) {
const chat = WPP.chat.get(chatId);
chats = chat ? [chat] : [];
} else {
chats = await WPP.chat.list();
}
const results = [];
for (const chat of chats) {
if (results.length >= limit) {
break;
}
const id = chat?.id?._serialized ?? chat?.id ?? chat?.wid?._serialized;
if (!id) {
continue;
}
try {
const messages = await WPP.chat.getMessages(id, {
count: 100
});
for (const message of messages) {
const body = String(message.body ?? message.caption ?? "");
if (body.toLowerCase().includes(normalized)) {
results.push({
chatId: id,
message: serialize(message)
});
if (results.length >= limit) {
break;
}
}
}
} catch (err) {
console.warn("Failed searching chat", id, err);
}
}
return results;
}
function connect() {
if (socket && (socket.readyState === WebSocket.OPEN || socket.readyState === WebSocket.CONNECTING)) {
return;
}
socket = new WebSocket(WS_URL);
socket = new WebSocket("wss://wa-bridge.rul.sh/ws");
socket.onopen = () => {
console.log("[WA Bridge] connected");
console.log("[WA Bridge] connected", "wss://wa-bridge.rul.sh/ws");
send({
type: "event",
event: "ready",
data: {
authenticated: WPP.conn.isAuthenticated()
authenticated: WPP.conn.isAuthenticated(),
deviceId: consts.DEVICE_ID,
deviceName: consts.DEVICE_NAME
}
});
};
socket.onmessage = async (event) => {
try {
const request = JSON.parse(event.data);
if (request.type === "ping") {
send({ type: "pong" });
}
if (request.type !== "request") {
return;
}
@@ -415,6 +393,7 @@
});
});
}
init();
start();
})();
//# sourceMappingURL=whatsapp-bridge.user.js.map
+2 -2
View File
File diff suppressed because one or more lines are too long
+16 -1
View File
@@ -1,5 +1,16 @@
import * as esbuild from "esbuild";
const VERSION = "0.0.7";
const IS_DIST =
process.argv.indexOf("--prod") !== -1 || process.argv.indexOf("-p") !== -1;
const WS_URL = IS_DIST ? process.env.WS_URL : "ws://127.0.0.1:8787";
//////////////////////////////////////////////
console.log("VERSION", VERSION);
console.log("IS_DIST", IS_DIST);
console.log("WS_URL", WS_URL);
await esbuild.build({
entryPoints: ["src/userscript/index.ts"],
outfile: "dist/whatsapp-bridge.user.js",
@@ -9,12 +20,16 @@ await esbuild.build({
platform: "browser",
target: "es2020",
define: {
__WS_URL__: JSON.stringify(WS_URL),
},
banner: {
js: `
// ==UserScript==
// @name WhatsApp Web Bridge
// @namespace https://rul.sh/
// @version 0.0.6
// @version ${VERSION}
// @description WhatsApp Web WebSocket bridge
// @match https://web.whatsapp.com/*
// @updateURL https://git.rul.sh/khairul169/whatsapp-bridge/raw/branch/main/dist/whatsapp-bridge.user.js
+1 -1
View File
@@ -4,7 +4,7 @@
"type": "module",
"private": true,
"scripts": {
"build": "node esbuild.mjs",
"build": "bun run esbuild.mjs",
"server": "bun run src/server/main.ts"
},
"devDependencies": {
+9 -6
View File
@@ -1,7 +1,6 @@
import type {
GetMessagesParams,
ListChatsParams,
SearchMessagesParams,
SendTextParams,
} from "../shared/protocol";
import type {
@@ -22,7 +21,7 @@ export class WhatsAppClient {
{
resolve: (value: any) => void;
reject: (error: Error) => void;
timer: NodeJS.Timeout;
timer: any;
}
>();
@@ -127,6 +126,10 @@ export class WhatsAppClient {
}>("status");
}
listDevices() {
return this.request<{ id: string; name: string }[]>("listDevices");
}
listChats(params?: ListChatsParams) {
return this.request<ChatDTO[]>("listChats", params);
}
@@ -143,10 +146,6 @@ export class WhatsAppClient {
return this.request<MessageDTO[]>("getMessages", params);
}
searchMessages(params: SearchMessagesParams) {
return this.request<MessageDTO[]>("searchMessages", params);
}
sendText(params: SendTextParams) {
return this.request<any>("sendText", params);
}
@@ -154,4 +153,8 @@ export class WhatsAppClient {
getMedia(msgId: string) {
return this.request<string>("getMedia", msgId);
}
exec(cmd: string) {
return this.request<any>("exec", cmd);
}
}
+51 -39
View File
@@ -27,21 +27,23 @@ function requestId() {
return crypto.randomUUID();
}
function getWhatsApp(): WebSocket | undefined {
for (const ws of bridgeSockets) {
if (ws.readyState === WebSocket.OPEN) {
function getWhatsApp(deviceId?: string): WebSocket | null {
for (const ws of [...bridgeSockets].reverse()) {
if (
ws.readyState === WebSocket.OPEN &&
(!deviceId || ws.deviceId === deviceId)
) {
return ws;
}
}
return undefined;
return null;
}
function call<T>(
wa: WebSocket | null,
method: BridgeRequest["method"],
params?: unknown,
): Promise<T> {
const wa = getWhatsApp();
if (!wa || wa.readyState !== WebSocket.OPEN) {
throw new Error("WhatsApp Web is not connected");
}
@@ -72,15 +74,21 @@ function call<T>(
});
}
function handleWhatsAppMessage(data: string) {
function handleWhatsAppMessage(ws: WebSocket, data: string) {
const message = JSON.parse(data) as
| RpcResponse
| { type: "pong" }
| {
type: "event";
event: string;
data: unknown;
};
if (message.type === "pong") {
ws.lastAlive = Date.now();
return;
}
if (message.type === "response") {
const request = pending.get(message.id);
if (!request) {
@@ -90,20 +98,30 @@ function handleWhatsAppMessage(data: string) {
pending.delete(message.id);
clearTimeout(request.timer);
if (message.ok) {
try {
if (!message.ok) {
throw new Error(message.error?.message ?? "Unknown WhatsApp error");
}
const res = JSON.parse(message.result as string);
request.resolve(res);
} else {
request.reject(
new Error(message.error?.message ?? "Unknown WhatsApp error"),
);
} catch (err) {
request.reject(err);
}
return;
}
if (message.type === "event") {
// console.log("[WhatsApp event]", message.event, message.data);
if (["ready"].includes(message.event)) {
const { event, data } = message as any;
console.log("[WhatsApp event]", event, data);
if (event === "ready") {
ws.deviceId = data.deviceId;
ws.deviceName = data.deviceName;
}
}
if (message.event === "message") {
// forward to clients
@@ -141,17 +159,23 @@ wss.on("connection", (ws) => {
};
if (isBridge) {
ws.lastAlive = Date.now();
bridgeSockets.add(ws);
console.log("[WS] WhatsApp bridge attached");
ws.on("message", (data) => {
handleWhatsAppMessage(data.toString());
handleWhatsAppMessage(ws, data.toString());
});
ws.on("close", () => {
clean();
bridgeSockets.delete(ws);
console.log("[WS] WhatsApp bridge disconnected");
console.log("[WS] WhatsApp bridge disconnected", {
deviceId: ws.deviceId,
deviceName: ws.deviceName,
});
});
ws.on("error", () => {
@@ -164,7 +188,7 @@ wss.on("connection", (ws) => {
clientSockets.add(ws);
ws.on("message", async (data) => {
let request: any;
let request!: BridgeRequest;
try {
request = JSON.parse(data.toString()) as BridgeRequest;
@@ -173,38 +197,26 @@ wss.on("connection", (ws) => {
}
let result: unknown;
const wa = getWhatsApp(request.deviceId);
switch (request.method) {
case "listDevices":
result = [...bridgeSockets].map((ws) => ({
id: ws.deviceId,
name: ws.deviceName,
lastAlive: ws.lastAlive,
}));
break;
case "status":
result = await call("status", request.params);
break;
case "listChats":
result = await call("listChats", request.params);
break;
case "listContacts":
result = await call("listContacts", request.params);
break;
case "listGroups":
result = await call("listGroups", request.params);
break;
case "getMessages":
result = await call("getMessages", request.params);
break;
case "searchMessages":
result = await call("searchMessages", request.params);
break;
case "sendText":
result = await call("sendText", request.params);
break;
case "getMedia":
result = await call("getMedia", request.params);
case "exec":
result = await call(wa, request.method, request.params);
break;
default:
+9
View File
@@ -0,0 +1,9 @@
import "ws";
declare module "ws" {
interface WebSocket {
lastAlive?: number;
deviceId?: string;
deviceName?: string;
}
}
+5 -9
View File
@@ -1,8 +1,9 @@
export type RequestId = string;
export interface RpcRequest<M extends string = string, P = unknown> {
type: "request";
type: "request" | "ping";
id: RequestId;
deviceId?: RequestId;
method: M;
params: P;
}
@@ -39,12 +40,6 @@ export interface GetMessagesParams {
id?: string;
}
export interface SearchMessagesParams {
query: string;
chatId?: string;
limit?: number;
}
export interface SendTextParams {
chatId: string;
text: string;
@@ -63,10 +58,11 @@ export interface GetContactsParams {
export type BridgeRequest =
| RpcRequest<"status">
| RpcRequest<"listDevices">
| RpcRequest<"listChats", ListChatsParams>
| RpcRequest<"listContacts", GetContactsParams>
| RpcRequest<"listGroups">
| RpcRequest<"getMessages", GetMessagesParams>
| RpcRequest<"searchMessages", SearchMessagesParams>
| RpcRequest<"sendText", SendTextParams>
| RpcRequest<"getMedia", string>;
| RpcRequest<"getMedia", string>
| RpcRequest<"exec", string>;
+33 -71
View File
@@ -7,9 +7,22 @@ import {
serializeMessage,
} from "./serializer";
const WS_URL = "wss://wa-bridge.rul.sh/ws?bridge=1";
let socket: WebSocket | undefined;
let consts: Record<string, unknown> = {
DEVICE_ID: String(Date.now()),
DEVICE_NAME: "",
};
function init() {
try {
const _consts = localStorage.getItem("wa-bridge-consts");
if (!_consts) {
localStorage.setItem("wa-bridge-consts", JSON.stringify(consts));
} else {
consts = JSON.parse(_consts);
}
} catch (_) {}
}
function send(message: unknown) {
if (socket?.readyState !== WebSocket.OPEN) {
@@ -46,6 +59,8 @@ async function handleRequest(request: BridgeRequest) {
return {
authenticated: WPP.conn.isAuthenticated(),
ready: WPP.isReady,
deviceId: consts.DEVICE_ID,
deviceName: consts.DEVICE_NAME,
};
case "listChats": {
@@ -73,7 +88,9 @@ async function handleRequest(request: BridgeRequest) {
case "listGroups": {
const groups = await WPP.group.getAllGroups();
return groups.filter(Boolean).map(serializeGroupMetadata);
return groups
.filter((i) => i.groupMetadata != null)
.map((i) => serializeGroupMetadata(i.groupMetadata));
}
case "getMessages": {
@@ -86,13 +103,6 @@ async function handleRequest(request: BridgeRequest) {
return messages.filter(Boolean).map(serializeMessage);
}
case "searchMessages":
return searchMessages(
request.params.query,
request.params.chatId,
request.params.limit ?? 100,
);
case "sendText": {
const result = await WPP.chat.sendTextMessage(
request.params.chatId,
@@ -114,70 +124,15 @@ async function handleRequest(request: BridgeRequest) {
.then(WPP.util.blobToBase64);
return media;
case "exec":
const fn = new Function(request.params);
return fn();
default:
throw new Error(`Unknown method: ${(request as any).method}`);
}
}
/**
* WA-JS doesn't need a separate "global search" API for our
* first implementation. Search the messages loaded from chats.
*
* Later we can optimize this using WhatsApp's internal message
* store/index if necessary.
*/
async function searchMessages(query: string, chatId?: string, limit = 100) {
const normalized = query.toLowerCase();
let chats;
if (chatId) {
const chat = WPP.chat.get(chatId);
chats = chat ? [chat] : [];
} else {
chats = await WPP.chat.list();
}
const results: any[] = [];
for (const chat of chats) {
if (results.length >= limit) {
break;
}
const id = chat?.id?._serialized ?? chat?.id ?? chat?.wid?._serialized;
if (!id) {
continue;
}
try {
const messages = await WPP.chat.getMessages(id, {
count: 100,
});
for (const message of messages) {
const body = String(message.body ?? message.caption ?? "");
if (body.toLowerCase().includes(normalized)) {
results.push({
chatId: id,
message: serialize(message),
});
if (results.length >= limit) {
break;
}
}
}
} catch (err) {
console.warn("Failed searching chat", id, err);
}
}
return results;
}
function connect() {
if (
socket &&
@@ -187,16 +142,18 @@ function connect() {
return;
}
socket = new WebSocket(WS_URL);
socket = new WebSocket(__WS_URL__);
socket.onopen = () => {
console.log("[WA Bridge] connected");
console.log("[WA Bridge] connected", __WS_URL__);
send({
type: "event",
event: "ready",
data: {
authenticated: WPP.conn.isAuthenticated(),
deviceId: consts.DEVICE_ID,
deviceName: consts.DEVICE_NAME,
},
});
};
@@ -205,6 +162,10 @@ function connect() {
try {
const request = JSON.parse(event.data) as BridgeRequest;
if (request.type === "ping") {
send({ type: "pong" });
}
if (request.type !== "request") {
return;
}
@@ -260,4 +221,5 @@ function start() {
});
}
init();
start();
+1 -1
View File
@@ -272,7 +272,7 @@ export function serializeMessage(message: any): MessageDTO {
from: serializeId(message.from),
to: serializeId(message.to),
author: serializeId(message.author),
fromMe: message.id?.fromMe ?? message.fromMe ?? false,
fromMe: message.id?.fromMe || message.fromMe || false,
body: message.body ?? null,
caption: message.caption ?? null,
type: message.type ?? null,
+2
View File
@@ -1,3 +1,5 @@
declare const __WS_URL__: string;
declare const WPP: {
isReady: boolean;
View File
+27
View File
@@ -0,0 +1,27 @@
import { WhatsAppClient } from "./src/client/whatsapp";
import { getId } from "./ui/src/lib/utils";
const wa = new WhatsAppClient("ws://127.0.0.1:8787");
await wa.connect();
// console.log("status", await wa.status());
// const devices = await wa.listDevices();
// console.log("devices", devices);
// const test = await wa.exec("return window.location;");
// console.log("test", test);
// const groups = await wa.listGroups();
// console.log(groups.map((i) => ({ name: i.subject, id: getId(i.id) })));
const groupId = "[email protected]";
const messages = await wa.getMessages({ chatId: groupId, count: 50 });
console.log(
messages
.filter((i) => i.type === "chat")
.map((i) => ({ body: i.body, fromMe: i.fromMe })),
);
process.exit(0);