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== // ==UserScript==
// @name WhatsApp Web Bridge // @name WhatsApp Web Bridge
// @namespace https://rul.sh/ // @namespace https://rul.sh/
// @version 0.0.6 // @version 0.0.7
// @description WhatsApp Web WebSocket bridge // @description WhatsApp Web WebSocket bridge
// @match https://web.whatsapp.com/* // @match https://web.whatsapp.com/*
// @updateURL https://git.rul.sh/khairul169/whatsapp-bridge/raw/branch/main/dist/whatsapp-bridge.user.js // @updateURL https://git.rul.sh/khairul169/whatsapp-bridge/raw/branch/main/dist/whatsapp-bridge.user.js
@@ -201,7 +201,7 @@
from: serializeId(message.from), from: serializeId(message.from),
to: serializeId(message.to), to: serializeId(message.to),
author: serializeId(message.author), author: serializeId(message.author),
fromMe: message.id?.fromMe ?? message.fromMe ?? false, fromMe: message.id?.fromMe || message.fromMe || false,
body: message.body ?? null, body: message.body ?? null,
caption: message.caption ?? null, caption: message.caption ?? null,
type: message.type ?? null, type: message.type ?? null,
@@ -230,8 +230,22 @@
} }
// src/userscript/index.ts // src/userscript/index.ts
var WS_URL = "wss://wa-bridge.rul.sh/ws?bridge=1";
var socket; 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) { function send(message) {
if (socket?.readyState !== WebSocket.OPEN) { if (socket?.readyState !== WebSocket.OPEN) {
throw new Error("WebSocket is not connected"); throw new Error("WebSocket is not connected");
@@ -262,7 +276,9 @@
case "status": case "status":
return { return {
authenticated: WPP.conn.isAuthenticated(), authenticated: WPP.conn.isAuthenticated(),
ready: WPP.isReady ready: WPP.isReady,
deviceId: consts.DEVICE_ID,
deviceName: consts.DEVICE_NAME
}; };
case "listChats": { case "listChats": {
const chats = await WPP.chat.list({ const chats = await WPP.chat.list({
@@ -279,7 +295,7 @@
} }
case "listGroups": { case "listGroups": {
const groups = await WPP.group.getAllGroups(); 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": { case "getMessages": {
const messages = await WPP.chat.getMessages(request.params.chatId, { const messages = await WPP.chat.getMessages(request.params.chatId, {
@@ -289,12 +305,6 @@
}); });
return messages.filter(Boolean).map(serializeMessage); return messages.filter(Boolean).map(serializeMessage);
} }
case "searchMessages":
return searchMessages(
request.params.query,
request.params.chatId,
request.params.limit ?? 100
);
case "sendText": { case "sendText": {
const result = await WPP.chat.sendTextMessage( const result = await WPP.chat.sendTextMessage(
request.params.chatId, request.params.chatId,
@@ -311,68 +321,36 @@
case "getMedia": case "getMedia":
const media = await WPP.chat.downloadMedia(request.params).then(WPP.util.blobToBase64); const media = await WPP.chat.downloadMedia(request.params).then(WPP.util.blobToBase64);
return media; return media;
case "exec":
const fn = new Function(request.params);
return fn();
default: default:
throw new Error(`Unknown method: ${request.method}`); 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() { function connect() {
if (socket && (socket.readyState === WebSocket.OPEN || socket.readyState === WebSocket.CONNECTING)) { if (socket && (socket.readyState === WebSocket.OPEN || socket.readyState === WebSocket.CONNECTING)) {
return; return;
} }
socket = new WebSocket(WS_URL); socket = new WebSocket("wss://wa-bridge.rul.sh/ws");
socket.onopen = () => { socket.onopen = () => {
console.log("[WA Bridge] connected"); console.log("[WA Bridge] connected", "wss://wa-bridge.rul.sh/ws");
send({ send({
type: "event", type: "event",
event: "ready", event: "ready",
data: { data: {
authenticated: WPP.conn.isAuthenticated() authenticated: WPP.conn.isAuthenticated(),
deviceId: consts.DEVICE_ID,
deviceName: consts.DEVICE_NAME
} }
}); });
}; };
socket.onmessage = async (event) => { socket.onmessage = async (event) => {
try { try {
const request = JSON.parse(event.data); const request = JSON.parse(event.data);
if (request.type === "ping") {
send({ type: "pong" });
}
if (request.type !== "request") { if (request.type !== "request") {
return; return;
} }
@@ -415,6 +393,7 @@
}); });
}); });
} }
init();
start(); start();
})(); })();
//# sourceMappingURL=whatsapp-bridge.user.js.map //# 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"; 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({ await esbuild.build({
entryPoints: ["src/userscript/index.ts"], entryPoints: ["src/userscript/index.ts"],
outfile: "dist/whatsapp-bridge.user.js", outfile: "dist/whatsapp-bridge.user.js",
@@ -9,12 +20,16 @@ await esbuild.build({
platform: "browser", platform: "browser",
target: "es2020", target: "es2020",
define: {
__WS_URL__: JSON.stringify(WS_URL),
},
banner: { banner: {
js: ` js: `
// ==UserScript== // ==UserScript==
// @name WhatsApp Web Bridge // @name WhatsApp Web Bridge
// @namespace https://rul.sh/ // @namespace https://rul.sh/
// @version 0.0.6 // @version ${VERSION}
// @description WhatsApp Web WebSocket bridge // @description WhatsApp Web WebSocket bridge
// @match https://web.whatsapp.com/* // @match https://web.whatsapp.com/*
// @updateURL https://git.rul.sh/khairul169/whatsapp-bridge/raw/branch/main/dist/whatsapp-bridge.user.js // @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", "type": "module",
"private": true, "private": true,
"scripts": { "scripts": {
"build": "node esbuild.mjs", "build": "bun run esbuild.mjs",
"server": "bun run src/server/main.ts" "server": "bun run src/server/main.ts"
}, },
"devDependencies": { "devDependencies": {
+9 -6
View File
@@ -1,7 +1,6 @@
import type { import type {
GetMessagesParams, GetMessagesParams,
ListChatsParams, ListChatsParams,
SearchMessagesParams,
SendTextParams, SendTextParams,
} from "../shared/protocol"; } from "../shared/protocol";
import type { import type {
@@ -22,7 +21,7 @@ export class WhatsAppClient {
{ {
resolve: (value: any) => void; resolve: (value: any) => void;
reject: (error: Error) => void; reject: (error: Error) => void;
timer: NodeJS.Timeout; timer: any;
} }
>(); >();
@@ -127,6 +126,10 @@ export class WhatsAppClient {
}>("status"); }>("status");
} }
listDevices() {
return this.request<{ id: string; name: string }[]>("listDevices");
}
listChats(params?: ListChatsParams) { listChats(params?: ListChatsParams) {
return this.request<ChatDTO[]>("listChats", params); return this.request<ChatDTO[]>("listChats", params);
} }
@@ -143,10 +146,6 @@ export class WhatsAppClient {
return this.request<MessageDTO[]>("getMessages", params); return this.request<MessageDTO[]>("getMessages", params);
} }
searchMessages(params: SearchMessagesParams) {
return this.request<MessageDTO[]>("searchMessages", params);
}
sendText(params: SendTextParams) { sendText(params: SendTextParams) {
return this.request<any>("sendText", params); return this.request<any>("sendText", params);
} }
@@ -154,4 +153,8 @@ export class WhatsAppClient {
getMedia(msgId: string) { getMedia(msgId: string) {
return this.request<string>("getMedia", msgId); 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(); return crypto.randomUUID();
} }
function getWhatsApp(): WebSocket | undefined { function getWhatsApp(deviceId?: string): WebSocket | null {
for (const ws of bridgeSockets) { for (const ws of [...bridgeSockets].reverse()) {
if (ws.readyState === WebSocket.OPEN) { if (
ws.readyState === WebSocket.OPEN &&
(!deviceId || ws.deviceId === deviceId)
) {
return ws; return ws;
} }
} }
return null;
return undefined;
} }
function call<T>( function call<T>(
wa: WebSocket | null,
method: BridgeRequest["method"], method: BridgeRequest["method"],
params?: unknown, params?: unknown,
): Promise<T> { ): Promise<T> {
const wa = getWhatsApp();
if (!wa || wa.readyState !== WebSocket.OPEN) { if (!wa || wa.readyState !== WebSocket.OPEN) {
throw new Error("WhatsApp Web is not connected"); 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 const message = JSON.parse(data) as
| RpcResponse | RpcResponse
| { type: "pong" }
| { | {
type: "event"; type: "event";
event: string; event: string;
data: unknown; data: unknown;
}; };
if (message.type === "pong") {
ws.lastAlive = Date.now();
return;
}
if (message.type === "response") { if (message.type === "response") {
const request = pending.get(message.id); const request = pending.get(message.id);
if (!request) { if (!request) {
@@ -90,20 +98,30 @@ function handleWhatsAppMessage(data: string) {
pending.delete(message.id); pending.delete(message.id);
clearTimeout(request.timer); 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); const res = JSON.parse(message.result as string);
request.resolve(res); request.resolve(res);
} else { } catch (err) {
request.reject( request.reject(err);
new Error(message.error?.message ?? "Unknown WhatsApp error"),
);
} }
return; return;
} }
if (message.type === "event") { 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") { if (message.event === "message") {
// forward to clients // forward to clients
@@ -141,17 +159,23 @@ wss.on("connection", (ws) => {
}; };
if (isBridge) { if (isBridge) {
ws.lastAlive = Date.now();
bridgeSockets.add(ws); bridgeSockets.add(ws);
console.log("[WS] WhatsApp bridge attached"); console.log("[WS] WhatsApp bridge attached");
ws.on("message", (data) => { ws.on("message", (data) => {
handleWhatsAppMessage(data.toString()); handleWhatsAppMessage(ws, data.toString());
}); });
ws.on("close", () => { ws.on("close", () => {
clean(); clean();
bridgeSockets.delete(ws); bridgeSockets.delete(ws);
console.log("[WS] WhatsApp bridge disconnected");
console.log("[WS] WhatsApp bridge disconnected", {
deviceId: ws.deviceId,
deviceName: ws.deviceName,
});
}); });
ws.on("error", () => { ws.on("error", () => {
@@ -164,7 +188,7 @@ wss.on("connection", (ws) => {
clientSockets.add(ws); clientSockets.add(ws);
ws.on("message", async (data) => { ws.on("message", async (data) => {
let request: any; let request!: BridgeRequest;
try { try {
request = JSON.parse(data.toString()) as BridgeRequest; request = JSON.parse(data.toString()) as BridgeRequest;
@@ -173,38 +197,26 @@ wss.on("connection", (ws) => {
} }
let result: unknown; let result: unknown;
const wa = getWhatsApp(request.deviceId);
switch (request.method) { switch (request.method) {
case "listDevices":
result = [...bridgeSockets].map((ws) => ({
id: ws.deviceId,
name: ws.deviceName,
lastAlive: ws.lastAlive,
}));
break;
case "status": case "status":
result = await call("status", request.params);
break;
case "listChats": case "listChats":
result = await call("listChats", request.params);
break;
case "listContacts": case "listContacts":
result = await call("listContacts", request.params);
break;
case "listGroups": case "listGroups":
result = await call("listGroups", request.params);
break;
case "getMessages": case "getMessages":
result = await call("getMessages", request.params);
break;
case "searchMessages":
result = await call("searchMessages", request.params);
break;
case "sendText": case "sendText":
result = await call("sendText", request.params);
break;
case "getMedia": case "getMedia":
result = await call("getMedia", request.params); case "exec":
result = await call(wa, request.method, request.params);
break; break;
default: 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 type RequestId = string;
export interface RpcRequest<M extends string = string, P = unknown> { export interface RpcRequest<M extends string = string, P = unknown> {
type: "request"; type: "request" | "ping";
id: RequestId; id: RequestId;
deviceId?: RequestId;
method: M; method: M;
params: P; params: P;
} }
@@ -39,12 +40,6 @@ export interface GetMessagesParams {
id?: string; id?: string;
} }
export interface SearchMessagesParams {
query: string;
chatId?: string;
limit?: number;
}
export interface SendTextParams { export interface SendTextParams {
chatId: string; chatId: string;
text: string; text: string;
@@ -63,10 +58,11 @@ export interface GetContactsParams {
export type BridgeRequest = export type BridgeRequest =
| RpcRequest<"status"> | RpcRequest<"status">
| RpcRequest<"listDevices">
| RpcRequest<"listChats", ListChatsParams> | RpcRequest<"listChats", ListChatsParams>
| RpcRequest<"listContacts", GetContactsParams> | RpcRequest<"listContacts", GetContactsParams>
| RpcRequest<"listGroups"> | RpcRequest<"listGroups">
| RpcRequest<"getMessages", GetMessagesParams> | RpcRequest<"getMessages", GetMessagesParams>
| RpcRequest<"searchMessages", SearchMessagesParams>
| RpcRequest<"sendText", SendTextParams> | RpcRequest<"sendText", SendTextParams>
| RpcRequest<"getMedia", string>; | RpcRequest<"getMedia", string>
| RpcRequest<"exec", string>;
+33 -71
View File
@@ -7,9 +7,22 @@ import {
serializeMessage, serializeMessage,
} from "./serializer"; } from "./serializer";
const WS_URL = "wss://wa-bridge.rul.sh/ws?bridge=1";
let socket: WebSocket | undefined; 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) { function send(message: unknown) {
if (socket?.readyState !== WebSocket.OPEN) { if (socket?.readyState !== WebSocket.OPEN) {
@@ -46,6 +59,8 @@ async function handleRequest(request: BridgeRequest) {
return { return {
authenticated: WPP.conn.isAuthenticated(), authenticated: WPP.conn.isAuthenticated(),
ready: WPP.isReady, ready: WPP.isReady,
deviceId: consts.DEVICE_ID,
deviceName: consts.DEVICE_NAME,
}; };
case "listChats": { case "listChats": {
@@ -73,7 +88,9 @@ async function handleRequest(request: BridgeRequest) {
case "listGroups": { case "listGroups": {
const groups = await WPP.group.getAllGroups(); 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": { case "getMessages": {
@@ -86,13 +103,6 @@ async function handleRequest(request: BridgeRequest) {
return messages.filter(Boolean).map(serializeMessage); return messages.filter(Boolean).map(serializeMessage);
} }
case "searchMessages":
return searchMessages(
request.params.query,
request.params.chatId,
request.params.limit ?? 100,
);
case "sendText": { case "sendText": {
const result = await WPP.chat.sendTextMessage( const result = await WPP.chat.sendTextMessage(
request.params.chatId, request.params.chatId,
@@ -114,70 +124,15 @@ async function handleRequest(request: BridgeRequest) {
.then(WPP.util.blobToBase64); .then(WPP.util.blobToBase64);
return media; return media;
case "exec":
const fn = new Function(request.params);
return fn();
default: default:
throw new Error(`Unknown method: ${(request as any).method}`); 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() { function connect() {
if ( if (
socket && socket &&
@@ -187,16 +142,18 @@ function connect() {
return; return;
} }
socket = new WebSocket(WS_URL); socket = new WebSocket(__WS_URL__);
socket.onopen = () => { socket.onopen = () => {
console.log("[WA Bridge] connected"); console.log("[WA Bridge] connected", __WS_URL__);
send({ send({
type: "event", type: "event",
event: "ready", event: "ready",
data: { data: {
authenticated: WPP.conn.isAuthenticated(), authenticated: WPP.conn.isAuthenticated(),
deviceId: consts.DEVICE_ID,
deviceName: consts.DEVICE_NAME,
}, },
}); });
}; };
@@ -205,6 +162,10 @@ function connect() {
try { try {
const request = JSON.parse(event.data) as BridgeRequest; const request = JSON.parse(event.data) as BridgeRequest;
if (request.type === "ping") {
send({ type: "pong" });
}
if (request.type !== "request") { if (request.type !== "request") {
return; return;
} }
@@ -260,4 +221,5 @@ function start() {
}); });
} }
init();
start(); start();
+1 -1
View File
@@ -272,7 +272,7 @@ export function serializeMessage(message: any): MessageDTO {
from: serializeId(message.from), from: serializeId(message.from),
to: serializeId(message.to), to: serializeId(message.to),
author: serializeId(message.author), author: serializeId(message.author),
fromMe: message.id?.fromMe ?? message.fromMe ?? false, fromMe: message.id?.fromMe || message.fromMe || false,
body: message.body ?? null, body: message.body ?? null,
caption: message.caption ?? null, caption: message.caption ?? null,
type: message.type ?? null, type: message.type ?? null,
+2
View File
@@ -1,3 +1,5 @@
declare const __WS_URL__: string;
declare const WPP: { declare const WPP: {
isReady: boolean; 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);