initial commit
This commit is contained in:
@@ -0,0 +1,42 @@
|
||||
import { WhatsAppClient } from "./whatsapp";
|
||||
|
||||
const wa = new WhatsAppClient("ws://127.0.0.1:8787");
|
||||
await wa.connect();
|
||||
|
||||
const chats = await wa.listChats({
|
||||
limit: 10,
|
||||
groupsOnly: true,
|
||||
// usersOnly: true,
|
||||
});
|
||||
|
||||
console.log(
|
||||
chats.map((i) => ({
|
||||
id: typeof i.id === "object" ? i.id?._serialized : i.id,
|
||||
lid: i.accountLid,
|
||||
name: i.formattedTitle,
|
||||
})),
|
||||
);
|
||||
|
||||
const messages = await wa.getMessages({
|
||||
chatId: "[email protected]",
|
||||
count: 5,
|
||||
});
|
||||
|
||||
console.log(
|
||||
messages.map((i) => ({
|
||||
id: i.id.id,
|
||||
from: i.from,
|
||||
text: i.body,
|
||||
})),
|
||||
);
|
||||
|
||||
// const result = await wa.searchMessages({
|
||||
// query: "pasien",
|
||||
// chatId: "[email protected]",
|
||||
// limit: 20,
|
||||
// });
|
||||
|
||||
// await wa.sendText({
|
||||
// chatId: "[email protected]",
|
||||
// text: "Hello from Node!",
|
||||
// });
|
||||
@@ -0,0 +1,138 @@
|
||||
import WebSocket from "ws";
|
||||
|
||||
import type {
|
||||
GetMessagesParams,
|
||||
ListChatsParams,
|
||||
SearchMessagesParams,
|
||||
SendTextParams,
|
||||
} from "../shared/protocol";
|
||||
import type {
|
||||
ChatDTO,
|
||||
ContactDTO,
|
||||
GroupMetadataDTO,
|
||||
MessageDTO,
|
||||
} from "../shared/dto";
|
||||
|
||||
export class WhatsAppClient {
|
||||
private url: string = "";
|
||||
private isConnected: boolean = false;
|
||||
private socket: WebSocket | null = null;
|
||||
|
||||
private pending = new Map<
|
||||
string,
|
||||
{
|
||||
resolve: (value: any) => void;
|
||||
reject: (error: Error) => void;
|
||||
timer: NodeJS.Timeout;
|
||||
}
|
||||
>();
|
||||
|
||||
constructor(url = "ws://127.0.0.1:8787") {
|
||||
this.url = url;
|
||||
}
|
||||
|
||||
async connect() {
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
if (this.socket) return;
|
||||
|
||||
this.socket = new WebSocket(this.url);
|
||||
|
||||
this.socket.on("open", () => {
|
||||
console.log("Connected to bridge");
|
||||
this.isConnected = true;
|
||||
resolve();
|
||||
});
|
||||
|
||||
this.socket.on("message", (data) => {
|
||||
const message = JSON.parse(data.toString());
|
||||
|
||||
if (message.type !== "response") {
|
||||
return;
|
||||
}
|
||||
|
||||
const pending = this.pending.get(message.id);
|
||||
|
||||
if (!pending) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.pending.delete(message.id);
|
||||
clearTimeout(pending.timer);
|
||||
|
||||
if (message.ok) {
|
||||
pending.resolve(message.result);
|
||||
} else {
|
||||
pending.reject(new Error(message.error?.message ?? "Unknown error"));
|
||||
}
|
||||
});
|
||||
|
||||
this.socket.on("close", () => {
|
||||
this.socket = null;
|
||||
this.isConnected = false;
|
||||
setTimeout(() => this.connect(), 3000);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
private request<T>(method: string, params?: unknown): Promise<T> {
|
||||
if (!this.isConnected || !this.socket) {
|
||||
throw new Error("WhatsApp is not connected");
|
||||
}
|
||||
|
||||
const id = crypto.randomUUID();
|
||||
|
||||
return new Promise<T>((resolve, reject) => {
|
||||
const timer = setTimeout(() => {
|
||||
this.pending.delete(id);
|
||||
|
||||
reject(new Error(`Request timeout: ${method}`));
|
||||
}, 30_000);
|
||||
|
||||
this.pending.set(id, {
|
||||
resolve,
|
||||
reject,
|
||||
timer,
|
||||
});
|
||||
|
||||
this.socket!.send(
|
||||
JSON.stringify({
|
||||
type: "request",
|
||||
id,
|
||||
method,
|
||||
params,
|
||||
}),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
status() {
|
||||
return this.request<{
|
||||
authenticated: boolean;
|
||||
ready: boolean;
|
||||
}>("status");
|
||||
}
|
||||
|
||||
listChats(params?: ListChatsParams) {
|
||||
return this.request<ChatDTO[]>("listChats", params);
|
||||
}
|
||||
|
||||
listContacts(params?: { limit?: number }) {
|
||||
return this.request<ContactDTO[]>("listContacts", params);
|
||||
}
|
||||
|
||||
listGroups() {
|
||||
return this.request<GroupMetadataDTO[]>("listGroups");
|
||||
}
|
||||
|
||||
getMessages(params: GetMessagesParams) {
|
||||
return this.request<MessageDTO[]>("getMessages", params);
|
||||
}
|
||||
|
||||
searchMessages(params: SearchMessagesParams) {
|
||||
return this.request<MessageDTO[]>("searchMessages", params);
|
||||
}
|
||||
|
||||
sendText(params: SendTextParams) {
|
||||
return this.request<any>("sendText", params);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,206 @@
|
||||
import { WebSocketServer, WebSocket } from "ws";
|
||||
|
||||
import type { BridgeRequest, RpcResponse } from "../shared/protocol";
|
||||
|
||||
const PORT = 8787;
|
||||
|
||||
const wss = new WebSocketServer({
|
||||
host: "0.0.0.0",
|
||||
port: PORT,
|
||||
});
|
||||
|
||||
let whatsapp: WebSocket | undefined;
|
||||
|
||||
const pending = new Map<
|
||||
string,
|
||||
{
|
||||
resolve: (value: unknown) => void;
|
||||
reject: (reason: unknown) => void;
|
||||
timer: NodeJS.Timeout;
|
||||
}
|
||||
>();
|
||||
|
||||
function requestId() {
|
||||
return crypto.randomUUID();
|
||||
}
|
||||
|
||||
function call<T>(
|
||||
method: BridgeRequest["method"],
|
||||
params?: unknown,
|
||||
): Promise<T> {
|
||||
if (!whatsapp || whatsapp.readyState !== WebSocket.OPEN) {
|
||||
throw new Error("WhatsApp Web is not connected");
|
||||
}
|
||||
|
||||
const id = requestId();
|
||||
|
||||
return new Promise<T>((resolve, reject) => {
|
||||
const timer = setTimeout(() => {
|
||||
pending.delete(id);
|
||||
|
||||
reject(new Error(`WhatsApp request timed out: ${method}`));
|
||||
}, 30_000);
|
||||
|
||||
pending.set(id, {
|
||||
resolve: resolve as never,
|
||||
reject,
|
||||
timer,
|
||||
});
|
||||
|
||||
whatsapp!.send(
|
||||
JSON.stringify({
|
||||
type: "request",
|
||||
id,
|
||||
method,
|
||||
params,
|
||||
}),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
function handleWhatsAppMessage(data: string) {
|
||||
const message = JSON.parse(data) as
|
||||
| RpcResponse
|
||||
| {
|
||||
type: "event";
|
||||
event: string;
|
||||
data: unknown;
|
||||
};
|
||||
|
||||
if (message.type === "response") {
|
||||
const request = pending.get(message.id);
|
||||
if (!request) {
|
||||
return;
|
||||
}
|
||||
|
||||
pending.delete(message.id);
|
||||
clearTimeout(request.timer);
|
||||
|
||||
if (message.ok) {
|
||||
const res = JSON.parse(message.result as string);
|
||||
request.resolve(res);
|
||||
} else {
|
||||
request.reject(
|
||||
new Error(message.error?.message ?? "Unknown WhatsApp error"),
|
||||
);
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (message.type === "event") {
|
||||
console.log("[WhatsApp event]", message.event, message.data);
|
||||
}
|
||||
}
|
||||
|
||||
wss.on("connection", (ws) => {
|
||||
console.log("[WS] client connected");
|
||||
|
||||
/*
|
||||
* First connection from Tampermonkey is the
|
||||
* WhatsApp bridge.
|
||||
*/
|
||||
if (!whatsapp) {
|
||||
whatsapp = ws;
|
||||
|
||||
console.log("[WS] WhatsApp bridge attached");
|
||||
|
||||
ws.on("message", (data) => {
|
||||
handleWhatsAppMessage(data.toString());
|
||||
});
|
||||
|
||||
ws.on("close", () => {
|
||||
if (whatsapp === ws) {
|
||||
whatsapp = undefined;
|
||||
}
|
||||
|
||||
console.log("[WS] WhatsApp disconnected");
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
/*
|
||||
* This is an external application/client.
|
||||
*
|
||||
* Example:
|
||||
* my backend
|
||||
* CLI
|
||||
* React application
|
||||
* another service
|
||||
*/
|
||||
|
||||
ws.on("message", async (data) => {
|
||||
let request: any;
|
||||
|
||||
try {
|
||||
request = JSON.parse(data.toString()) as BridgeRequest;
|
||||
|
||||
if (request.type !== "request") {
|
||||
return;
|
||||
}
|
||||
|
||||
let result: unknown;
|
||||
|
||||
switch (request.method) {
|
||||
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;
|
||||
|
||||
default:
|
||||
throw new Error(`Unsupported method: ${(request as any).method}`);
|
||||
}
|
||||
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
type: "response",
|
||||
id: request.id,
|
||||
ok: true,
|
||||
result,
|
||||
}),
|
||||
);
|
||||
} catch (err) {
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
type: "response",
|
||||
id: request?.id || null,
|
||||
ok: false,
|
||||
error: {
|
||||
code: "ERROR",
|
||||
message: err instanceof Error ? err.message : String(err),
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
ws.on("close", () => {
|
||||
console.log("[WS] external client disconnected");
|
||||
});
|
||||
});
|
||||
|
||||
console.log(`WhatsApp bridge listening on ws://127.0.0.1:${PORT}`);
|
||||
@@ -0,0 +1,199 @@
|
||||
export type JsonValue =
|
||||
| string
|
||||
| number
|
||||
| boolean
|
||||
| null
|
||||
| JsonValue[]
|
||||
| {
|
||||
[key: string]: JsonValue;
|
||||
};
|
||||
|
||||
export interface SerializedId {
|
||||
_serialized?: string;
|
||||
user?: string;
|
||||
server?: string;
|
||||
domain?: string;
|
||||
device?: string;
|
||||
agent?: string;
|
||||
[key: string]: JsonValue | undefined;
|
||||
}
|
||||
|
||||
export interface ContactDTO {
|
||||
id: SerializedId | string | null;
|
||||
|
||||
accountLid?: string | null;
|
||||
|
||||
name?: string | null;
|
||||
pushname?: string | null;
|
||||
shortName?: string | null;
|
||||
formattedName?: string | null;
|
||||
|
||||
number?: string | null;
|
||||
|
||||
isMe?: boolean;
|
||||
isUser?: boolean;
|
||||
isBusiness?: boolean;
|
||||
isEnterprise?: boolean;
|
||||
isGroup?: boolean;
|
||||
|
||||
isMyContact?: boolean;
|
||||
isBlocked?: boolean;
|
||||
|
||||
profilePicThumbObj?: unknown;
|
||||
}
|
||||
|
||||
export interface ChatDTO {
|
||||
id: SerializedId | string | null;
|
||||
|
||||
accountLid?: string | null;
|
||||
|
||||
name?: string | null;
|
||||
formattedTitle?: string | null;
|
||||
|
||||
isGroup?: boolean;
|
||||
isUser?: boolean;
|
||||
isNewsletter?: boolean;
|
||||
isCommunity?: boolean;
|
||||
|
||||
isReadOnly?: boolean;
|
||||
isAnnounceGrpRestrict?: boolean;
|
||||
|
||||
archived?: boolean;
|
||||
pinned?: boolean;
|
||||
muted?: boolean;
|
||||
|
||||
unreadCount?: number;
|
||||
unreadMentionsCount?: number;
|
||||
|
||||
timestamp?: number | null;
|
||||
lastMessage?: MessageDTO | null;
|
||||
contact?: ContactDTO | null;
|
||||
groupMetadata?: GroupMetadataDTO | null;
|
||||
}
|
||||
|
||||
export interface GroupMetadataDTO {
|
||||
id: SerializedId | string | null;
|
||||
|
||||
subject?: string | null;
|
||||
subjectOwner?: SerializedId | string | null;
|
||||
|
||||
subjectTime?: number | null;
|
||||
creation?: number | null;
|
||||
|
||||
owner?: SerializedId | string | null;
|
||||
|
||||
participants?: GroupParticipantDTO[];
|
||||
|
||||
size?: number;
|
||||
|
||||
restrict?: boolean;
|
||||
announce?: boolean;
|
||||
|
||||
isCommunity?: boolean;
|
||||
isCommunityAnnounce?: boolean;
|
||||
}
|
||||
|
||||
export interface GroupParticipantDTO {
|
||||
id: SerializedId | string | null;
|
||||
|
||||
isAdmin?: boolean;
|
||||
isSuperAdmin?: boolean;
|
||||
isSuperParticipant?: boolean;
|
||||
|
||||
isBusiness?: boolean;
|
||||
}
|
||||
|
||||
export interface MessageDTO {
|
||||
id: MessageIdDTO;
|
||||
|
||||
chatId?: string | null;
|
||||
|
||||
from?: SerializedId | string | null;
|
||||
to?: SerializedId | string | null;
|
||||
|
||||
author?: SerializedId | string | null;
|
||||
|
||||
fromMe?: boolean;
|
||||
|
||||
body?: string | null;
|
||||
caption?: string | null;
|
||||
|
||||
type?: string | null;
|
||||
subtype?: string | null;
|
||||
|
||||
timestamp?: number | null;
|
||||
|
||||
ack?: number | null;
|
||||
|
||||
hasMedia?: boolean;
|
||||
mediaKey?: string | null;
|
||||
|
||||
isForwarded?: boolean;
|
||||
forwardingScore?: number;
|
||||
|
||||
isStarred?: boolean;
|
||||
|
||||
broadcast?: boolean;
|
||||
|
||||
mentionedIds?: (SerializedId | string)[];
|
||||
|
||||
quotedMsgId?: MessageIdDTO | null;
|
||||
|
||||
quotedMsg?: MessageDTO | null;
|
||||
|
||||
links?: MessageLinkDTO[];
|
||||
|
||||
location?: LocationDTO | null;
|
||||
|
||||
vCard?: string | null;
|
||||
vCards?: string[];
|
||||
|
||||
filename?: string | null;
|
||||
mimetype?: string | null;
|
||||
size?: number | null;
|
||||
|
||||
duration?: number | null;
|
||||
}
|
||||
|
||||
export interface MessageIdDTO {
|
||||
id?: string | null;
|
||||
_serialized?: string | null;
|
||||
|
||||
fromMe?: boolean;
|
||||
remote?: SerializedId | string | null;
|
||||
participant?: SerializedId | string | null;
|
||||
}
|
||||
|
||||
export interface MessageLinkDTO {
|
||||
link?: string | null;
|
||||
isSuspicious?: boolean;
|
||||
}
|
||||
|
||||
export interface LocationDTO {
|
||||
latitude?: number | null;
|
||||
longitude?: number | null;
|
||||
|
||||
description?: string | null;
|
||||
address?: string | null;
|
||||
|
||||
url?: string | null;
|
||||
|
||||
name?: string | null;
|
||||
}
|
||||
|
||||
export interface SentMessageDTO {
|
||||
id: MessageIdDTO | null;
|
||||
|
||||
ack?: number | null;
|
||||
|
||||
timestamp?: number | null;
|
||||
|
||||
chatId?: string | null;
|
||||
|
||||
from?: SerializedId | string | null;
|
||||
to?: SerializedId | string | null;
|
||||
|
||||
body?: string | null;
|
||||
|
||||
type?: string | null;
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
export type RequestId = string;
|
||||
|
||||
export interface RpcRequest<M extends string = string, P = unknown> {
|
||||
type: "request";
|
||||
id: RequestId;
|
||||
method: M;
|
||||
params: P;
|
||||
}
|
||||
|
||||
export interface RpcResponse<T = unknown> {
|
||||
type: "response";
|
||||
id: RequestId;
|
||||
ok: boolean;
|
||||
result?: T;
|
||||
error?: {
|
||||
code: string;
|
||||
message: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface RpcEvent<T = unknown> {
|
||||
type: "event";
|
||||
event: string;
|
||||
data: T;
|
||||
}
|
||||
|
||||
export type ServerMessage = RpcResponse | RpcEvent;
|
||||
|
||||
export interface ListChatsParams {
|
||||
limit?: number;
|
||||
groupsOnly?: boolean;
|
||||
usersOnly?: boolean;
|
||||
}
|
||||
|
||||
export interface GetMessagesParams {
|
||||
chatId: string;
|
||||
count?: number;
|
||||
direction?: "before" | "after";
|
||||
id?: string;
|
||||
}
|
||||
|
||||
export interface SearchMessagesParams {
|
||||
query: string;
|
||||
chatId?: string;
|
||||
limit?: number;
|
||||
}
|
||||
|
||||
export interface SendTextParams {
|
||||
chatId: string;
|
||||
text: string;
|
||||
|
||||
options?: {
|
||||
delay?: number;
|
||||
quotedMsg?: string;
|
||||
mentionedList?: string[];
|
||||
waitForAck?: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
export interface GetContactsParams {
|
||||
limit?: number;
|
||||
}
|
||||
|
||||
export type BridgeRequest =
|
||||
| RpcRequest<"status">
|
||||
| RpcRequest<"listChats", ListChatsParams>
|
||||
| RpcRequest<"listContacts", GetContactsParams>
|
||||
| RpcRequest<"listGroups">
|
||||
| RpcRequest<"getMessages", GetMessagesParams>
|
||||
| RpcRequest<"searchMessages", SearchMessagesParams>
|
||||
| RpcRequest<"sendText", SendTextParams>;
|
||||
@@ -0,0 +1,257 @@
|
||||
import type { BridgeRequest } from "../shared/protocol";
|
||||
import {
|
||||
serialize,
|
||||
serializeChat,
|
||||
serializeContact,
|
||||
serializeGroupMetadata,
|
||||
serializeMessage,
|
||||
} from "./serializer";
|
||||
|
||||
const WS_URL = "ws://127.0.0.1:8787";
|
||||
|
||||
let socket: WebSocket | undefined;
|
||||
|
||||
function send(message: unknown) {
|
||||
if (socket?.readyState !== WebSocket.OPEN) {
|
||||
throw new Error("WebSocket is not connected");
|
||||
}
|
||||
|
||||
socket.send(JSON.stringify(message));
|
||||
}
|
||||
|
||||
function reply(id: string, result: unknown) {
|
||||
send({
|
||||
type: "response",
|
||||
id,
|
||||
ok: true,
|
||||
result: serialize(result),
|
||||
});
|
||||
}
|
||||
|
||||
function error(id: string, err: unknown) {
|
||||
send({
|
||||
type: "response",
|
||||
id,
|
||||
ok: false,
|
||||
error: {
|
||||
code: "ERROR",
|
||||
message: err instanceof Error ? err.message : String(err),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function handleRequest(request: BridgeRequest) {
|
||||
switch (request.method) {
|
||||
case "status":
|
||||
return {
|
||||
authenticated: WPP.conn.isAuthenticated(),
|
||||
ready: WPP.isReady,
|
||||
};
|
||||
|
||||
case "listChats": {
|
||||
const chats = await WPP.chat.list({
|
||||
...(request.params?.limit != null
|
||||
? { count: request.params.limit }
|
||||
: {}),
|
||||
...(request.params?.groupsOnly ? { onlyGroups: true } : {}),
|
||||
...(request.params?.usersOnly ? { onlyUsers: true } : {}),
|
||||
});
|
||||
|
||||
return chats.filter(Boolean).map(serializeChat);
|
||||
}
|
||||
|
||||
case "listContacts": {
|
||||
const contacts = await WPP.contact.getAllContacts();
|
||||
|
||||
const result = request.params?.limit
|
||||
? contacts.slice(0, request.params.limit)
|
||||
: contacts;
|
||||
|
||||
return result.filter(Boolean).map(serializeContact);
|
||||
}
|
||||
|
||||
case "listGroups": {
|
||||
const groups = await WPP.group.getAllGroups();
|
||||
|
||||
return groups.filter(Boolean).map(serializeGroupMetadata);
|
||||
}
|
||||
|
||||
case "getMessages": {
|
||||
const messages = await WPP.chat.getMessages(request.params.chatId, {
|
||||
count: request.params.count ?? 50,
|
||||
direction: request.params.direction,
|
||||
id: request.params.id,
|
||||
});
|
||||
|
||||
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,
|
||||
request.params.text,
|
||||
{
|
||||
delay: request.params.options?.delay,
|
||||
quotedMsg: request.params.options?.quotedMsg,
|
||||
mentionedList: request.params.options?.mentionedList,
|
||||
waitForAck: request.params.options?.waitForAck,
|
||||
},
|
||||
);
|
||||
|
||||
return serialize(result);
|
||||
}
|
||||
|
||||
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 &&
|
||||
(socket.readyState === WebSocket.OPEN ||
|
||||
socket.readyState === WebSocket.CONNECTING)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
socket = new WebSocket(WS_URL);
|
||||
|
||||
socket.onopen = () => {
|
||||
console.log("[WA Bridge] connected");
|
||||
|
||||
send({
|
||||
type: "event",
|
||||
event: "ready",
|
||||
data: {
|
||||
authenticated: WPP.conn.isAuthenticated(),
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
socket.onmessage = async (event) => {
|
||||
try {
|
||||
const request = JSON.parse(event.data) as BridgeRequest;
|
||||
|
||||
if (request.type !== "request") {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await handleRequest(request);
|
||||
|
||||
reply(request.id, JSON.stringify(result));
|
||||
} catch (err) {
|
||||
error(request.id, err);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("[WA Bridge] invalid message", err);
|
||||
}
|
||||
};
|
||||
|
||||
socket.onclose = (event) => {
|
||||
console.log("[WA Bridge] disconnected", {
|
||||
code: event.code,
|
||||
reason: event.reason,
|
||||
wasClean: event.wasClean,
|
||||
});
|
||||
|
||||
socket = undefined;
|
||||
setTimeout(connect, 3000);
|
||||
};
|
||||
|
||||
socket.onerror = (event) => {
|
||||
console.error("[WA Bridge] websocket error", event);
|
||||
};
|
||||
}
|
||||
|
||||
function start() {
|
||||
WPP.loader.onReady(() => {
|
||||
console.log("[WA Bridge] WA-JS ready");
|
||||
|
||||
connect();
|
||||
|
||||
/*
|
||||
* Forward new-message events to Node.
|
||||
*/
|
||||
WPP.chat.on("chat.new_message", (message: unknown) => {
|
||||
if (socket?.readyState !== WebSocket.OPEN) {
|
||||
return;
|
||||
}
|
||||
|
||||
send({
|
||||
type: "event",
|
||||
event: "message",
|
||||
data: serialize(message),
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
start();
|
||||
@@ -0,0 +1,298 @@
|
||||
import type {
|
||||
ChatDTO,
|
||||
ContactDTO,
|
||||
GroupMetadataDTO,
|
||||
GroupParticipantDTO,
|
||||
LocationDTO,
|
||||
MessageDTO,
|
||||
MessageIdDTO,
|
||||
SerializedId,
|
||||
} from "../shared/dto";
|
||||
|
||||
/**
|
||||
* WA models contain a lot of internal properties and sometimes
|
||||
* circular references. Never JSON.stringify them directly.
|
||||
*/
|
||||
export function serialize(value: any): any {
|
||||
if (value == null) {
|
||||
return value;
|
||||
}
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
return value.map(serialize);
|
||||
}
|
||||
|
||||
if (typeof value !== "object") {
|
||||
return value;
|
||||
}
|
||||
|
||||
const result: Record<string, unknown> = {};
|
||||
|
||||
for (const key of Object.keys(value)) {
|
||||
const v = value[key];
|
||||
|
||||
if (
|
||||
typeof v === "string" ||
|
||||
typeof v === "number" ||
|
||||
typeof v === "boolean" ||
|
||||
v === null
|
||||
) {
|
||||
result[key] = v;
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
function safeProperty<T>(object: unknown, key: string): T | undefined {
|
||||
try {
|
||||
if (object && typeof object === "object") {
|
||||
return (object as any)[key];
|
||||
}
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function safeGet<T = unknown>(
|
||||
object: unknown,
|
||||
key: string,
|
||||
): T | undefined {
|
||||
if (object == null || typeof object !== "object") {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
try {
|
||||
return (object as any)[key];
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
export function pick<T extends object>(
|
||||
source: any,
|
||||
keys: readonly string[],
|
||||
): Partial<T> {
|
||||
const result: Record<string, unknown> = {};
|
||||
|
||||
for (const key of keys) {
|
||||
const value = safeGet(source, key);
|
||||
|
||||
if (value !== undefined) {
|
||||
result[key] = serialize(value);
|
||||
}
|
||||
}
|
||||
|
||||
return result as Partial<T>;
|
||||
}
|
||||
|
||||
export function serializeId(id: any): SerializedId | string | null {
|
||||
if (id == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (typeof id === "string") {
|
||||
return id;
|
||||
}
|
||||
|
||||
if (typeof id !== "object") {
|
||||
return String(id);
|
||||
}
|
||||
|
||||
const result: SerializedId = {};
|
||||
|
||||
for (const key of [
|
||||
"_serialized",
|
||||
"user",
|
||||
"server",
|
||||
"domain",
|
||||
"device",
|
||||
"agent",
|
||||
"fromMe",
|
||||
"remote",
|
||||
"participant",
|
||||
]) {
|
||||
const value = safeGet(id, key);
|
||||
|
||||
if (value !== undefined) {
|
||||
result[key] = serialize(value) as any;
|
||||
}
|
||||
}
|
||||
|
||||
// Some WA objects expose useful fields that aren't
|
||||
// enumerable.
|
||||
if (!result._serialized && typeof safeGet(id, "toString") === "function") {
|
||||
try {
|
||||
const serialized = id.toString();
|
||||
|
||||
if (serialized && serialized !== "[object Object]") {
|
||||
result._serialized = serialized;
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
|
||||
return Object.keys(result).length ? result : String(id);
|
||||
}
|
||||
|
||||
export function serializeChat(chat: any): ChatDTO {
|
||||
return {
|
||||
id: serializeId(chat.id),
|
||||
accountLid: chat.accountLid,
|
||||
name: chat.name,
|
||||
formattedTitle: chat.formattedTitle,
|
||||
|
||||
isGroup: chat.isGroup,
|
||||
isUser: chat.isUser,
|
||||
isReadOnly: chat.isReadOnly,
|
||||
archived: chat.archived,
|
||||
unreadCount: chat.unreadCount,
|
||||
|
||||
timestamp: chat.t,
|
||||
lastMessage: chat.lastMessage ? serialize(chat.lastMessage) : null,
|
||||
};
|
||||
}
|
||||
|
||||
export function serializeContact(contact: any): ContactDTO {
|
||||
return {
|
||||
id: serializeId(contact.id),
|
||||
|
||||
accountLid: contact.accountLid ?? null,
|
||||
name: contact.name ?? null,
|
||||
pushname: contact.pushname ?? null,
|
||||
shortName: contact.shortName ?? null,
|
||||
formattedName: contact.formattedName ?? null,
|
||||
number: contact.number ?? null,
|
||||
|
||||
isMe: contact.isMe ?? false,
|
||||
isUser: contact.isUser ?? false,
|
||||
isBusiness: contact.isBusiness ?? false,
|
||||
isEnterprise: contact.isEnterprise ?? false,
|
||||
isGroup: contact.isGroup ?? false,
|
||||
isMyContact: contact.isMyContact ?? false,
|
||||
isBlocked: contact.isBlocked ?? false,
|
||||
|
||||
profilePicThumbObj: contact.profilePicThumbObj
|
||||
? serialize(contact.profilePicThumbObj)
|
||||
: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
export function serializeGroupParticipant(
|
||||
participant: any,
|
||||
): GroupParticipantDTO {
|
||||
return {
|
||||
id: serializeId(participant.id),
|
||||
|
||||
isAdmin: participant.isAdmin ?? false,
|
||||
isSuperAdmin: participant.isSuperAdmin ?? false,
|
||||
isSuperParticipant: participant.isSuperParticipant ?? false,
|
||||
isBusiness: participant.isBusiness ?? false,
|
||||
};
|
||||
}
|
||||
|
||||
export function serializeGroupMetadata(metadata: any): GroupMetadataDTO {
|
||||
return {
|
||||
id: serializeId(metadata.id),
|
||||
|
||||
subject: metadata.subject ?? null,
|
||||
subjectOwner: serializeId(metadata.subjectOwner),
|
||||
subjectTime: metadata.subjectTime ?? null,
|
||||
creation: metadata.creation ?? null,
|
||||
owner: serializeId(metadata.owner),
|
||||
|
||||
participants: Array.isArray(metadata.participants)
|
||||
? (metadata.participants
|
||||
.map(serializeGroupParticipant)
|
||||
.filter(Boolean) as GroupParticipantDTO[])
|
||||
: [],
|
||||
|
||||
size: metadata.size ?? metadata.participants?.length ?? 0,
|
||||
restrict: metadata.restrict ?? false,
|
||||
announce: metadata.announce ?? false,
|
||||
isCommunity: metadata.isCommunity ?? false,
|
||||
isCommunityAnnounce: metadata.isCommunityAnnounce ?? false,
|
||||
};
|
||||
}
|
||||
export function serializeMessageId(id: any): MessageIdDTO | null {
|
||||
if (!id) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
id: id.id ?? null,
|
||||
_serialized: id._serialized ?? null,
|
||||
fromMe: id.fromMe ?? false,
|
||||
remote: serializeId(id.remote),
|
||||
participant: serializeId(id.participant),
|
||||
};
|
||||
}
|
||||
export function serializeLocation(location: any): LocationDTO | null {
|
||||
if (!location) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
latitude: location.latitude ?? null,
|
||||
longitude: location.longitude ?? null,
|
||||
description: location.description ?? null,
|
||||
address: location.address ?? null,
|
||||
url: location.url ?? null,
|
||||
name: location.name ?? null,
|
||||
};
|
||||
}
|
||||
export function serializeMessageLink(link: any): any {
|
||||
return {
|
||||
link: link?.link ?? null,
|
||||
isSuspicious: link?.isSuspicious ?? false,
|
||||
};
|
||||
}
|
||||
export function serializeMessage(message: any): MessageDTO {
|
||||
const quotedMsgId = safeProperty<any>(message, "quotedMsgId");
|
||||
const hasQuoted = !!quotedMsgId;
|
||||
let quotedMsg = null;
|
||||
|
||||
if (hasQuoted) {
|
||||
const rawQuoted = safeProperty<any>(message, "quotedMsg");
|
||||
if (rawQuoted) {
|
||||
quotedMsg = serializeMessage(rawQuoted);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
id: serializeMessageId(message.id)!,
|
||||
chatId: message.chatId ?? message.id?.remote?._serialized ?? null,
|
||||
from: serializeId(message.from),
|
||||
to: serializeId(message.to),
|
||||
author: serializeId(message.author),
|
||||
fromMe: message.fromMe ?? false,
|
||||
body: message.body ?? null,
|
||||
caption: message.caption ?? null,
|
||||
type: message.type ?? null,
|
||||
subtype: message.subtype ?? null,
|
||||
timestamp: message.timestamp ?? null,
|
||||
ack: message.ack ?? null,
|
||||
hasMedia: message.hasMedia ?? false,
|
||||
mediaKey: message.mediaKey ?? null,
|
||||
isForwarded: message.isForwarded ?? false,
|
||||
forwardingScore: message.forwardingScore ?? 0,
|
||||
isStarred: message.isStarred ?? false,
|
||||
broadcast: message.broadcast ?? false,
|
||||
mentionedIds: Array.isArray(message.mentionedIds)
|
||||
? message.mentionedIds.map(serializeId)
|
||||
: [],
|
||||
quotedMsgId: serializeMessageId(quotedMsgId),
|
||||
quotedMsg,
|
||||
links: Array.isArray(message.links)
|
||||
? message.links.map(serializeMessageLink)
|
||||
: [],
|
||||
location: serializeLocation(message.location),
|
||||
vCard: message.vCard ?? null,
|
||||
vCards: Array.isArray(message.vCards) ? message.vCards : [],
|
||||
filename: message.filename ?? null,
|
||||
mimetype: message.mimetype ?? null,
|
||||
size: message.size ?? null,
|
||||
duration: message.duration ?? null,
|
||||
};
|
||||
}
|
||||
Vendored
+62
@@ -0,0 +1,62 @@
|
||||
declare const WPP: {
|
||||
isReady: boolean;
|
||||
|
||||
loader: {
|
||||
onReady(callback: () => void): void;
|
||||
};
|
||||
|
||||
conn: {
|
||||
isAuthenticated(): boolean;
|
||||
};
|
||||
|
||||
chat: {
|
||||
list(options?: {
|
||||
count?: number;
|
||||
onlyUsers?: boolean;
|
||||
onlyGroups?: boolean;
|
||||
onlyCommunities?: boolean;
|
||||
onlyNewsletter?: boolean;
|
||||
onlyArchived?: boolean;
|
||||
}): Promise<any[]>;
|
||||
|
||||
get(chatId: string): any | undefined;
|
||||
|
||||
getMessages(
|
||||
chatId: string,
|
||||
options?: {
|
||||
count?: number;
|
||||
direction?: "before" | "after";
|
||||
id?: string;
|
||||
includeCallMessages?: boolean;
|
||||
media?: "image" | "document" | "url" | "all";
|
||||
onlyUnread?: boolean;
|
||||
},
|
||||
): Promise<any[]>;
|
||||
|
||||
sendTextMessage(
|
||||
chatId: string,
|
||||
text: string,
|
||||
options?: {
|
||||
delay?: number;
|
||||
detectMentioned?: boolean;
|
||||
markIsRead?: boolean;
|
||||
mentionedList?: string[];
|
||||
messageId?: string;
|
||||
quotedMsg?: string;
|
||||
quotedMsgPayload?: string;
|
||||
waitForAck?: boolean;
|
||||
},
|
||||
): Promise<any>;
|
||||
|
||||
on(event: string, callback: (...args: any[]) => void): void;
|
||||
};
|
||||
|
||||
contact: {
|
||||
getAllContacts(): Promise<any[]>;
|
||||
get(contactId: string): any | undefined;
|
||||
};
|
||||
|
||||
group: {
|
||||
getAllGroups(): Promise<any[]>;
|
||||
};
|
||||
};
|
||||
Reference in New Issue
Block a user