Packages

WhatsApp Web client for Elixir powered by Baileys

Current section

Files

Jump to
irish priv bridge.ts
Raw

priv/bridge.ts

#!/usr/bin/env -S deno run --allow-all --node-modules-dir=auto
/**
* Irish Bridge — Connects Elixir to Baileys via JSON lines over stdio.
*
* Protocol (bidirectional):
* Elixir -> Bridge (commands): {"v":1,"id":"…","cmd":"…","args":{…}}
* Elixir -> Bridge (responses): {"v":1,"id":"…","ok":true|false,"data":{…}}
* Bridge -> Elixir (responses): {"v":1,"id":"…","ok":true,"data":{…}}
* Bridge -> Elixir (requests): {"v":1,"id":"…","req":"…","args":{…}}
* Bridge -> Elixir (events): {"v":1,"event":"…","data":{…}}
*/
import makeWASocket, {
DisconnectReason,
makeCacheableSignalKeyStore,
initAuthCreds,
BufferJSON,
downloadMediaMessage,
type WASocket,
type BaileysEventMap,
type AuthenticationCreds,
type SignalKeyStore,
} from "baileys";
// ---------------------------------------------------------------------------
// Logger — must write to stderr so stdout stays clean for the JSON protocol
// ---------------------------------------------------------------------------
type LogFn = (...args: unknown[]) => void;
interface Logger {
level: string;
trace: LogFn;
debug: LogFn;
info: LogFn;
warn: LogFn;
error: LogFn;
fatal: LogFn;
child: (bindings: Record<string, unknown>) => Logger;
}
function makeLogger(level = "warn"): Logger {
const levels = ["trace", "debug", "info", "warn", "error", "fatal"];
const threshold = levels.indexOf(level);
const enc = new TextEncoder();
const write = (lvl: string, args: unknown[]) => {
if (levels.indexOf(lvl) < threshold) return;
const parts = args.map((a) =>
typeof a === "string" ? a : JSON.stringify(a)
);
try {
Deno.stderr.writeSync(
enc.encode(`[${lvl.toUpperCase()}] ${parts.join(" ")}\n`)
);
} catch {
// stderr broken — parent gone
}
};
const logger: Logger = {
level,
trace: (...a) => write("trace", a),
debug: (...a) => write("debug", a),
info: (...a) => write("info", a),
warn: (...a) => write("warn", a),
error: (...a) => write("error", a),
fatal: (...a) => write("fatal", a),
child: () => makeLogger(level),
};
return logger;
}
const logger = makeLogger("warn");
// Redirect console.* to stderr — Baileys' Signal layer uses console.log
// directly (bypassing the logger), which would pollute the stdout JSON protocol.
{
const ce = new TextEncoder();
const stderrWrite = (prefix: string) => (...args: unknown[]) => {
const parts = args.map((a) => typeof a === "string" ? a : JSON.stringify(a));
try {
Deno.stderr.writeSync(ce.encode(`[${prefix}] ${parts.join(" ")}\n`));
} catch { /* stderr broken */ }
};
// deno-lint-ignore no-global-assign
console = {
...console,
log: stderrWrite("LOG"),
info: stderrWrite("INFO"),
warn: stderrWrite("WARN"),
error: stderrWrite("ERROR"),
debug: stderrWrite("DEBUG"),
};
}
// ---------------------------------------------------------------------------
// JSON serialization — handles Buffer / Uint8Array / BigInt
// ---------------------------------------------------------------------------
function uint8ToBase64(bytes: Uint8Array): string {
const chunks: string[] = [];
const sz = 0x8000;
for (let i = 0; i < bytes.length; i += sz) {
chunks.push(String.fromCharCode(...bytes.subarray(i, i + sz)));
}
return btoa(chunks.join(""));
}
function replacer(_key: string, value: unknown): unknown {
if (value instanceof Uint8Array) {
return { __b64: uint8ToBase64(value) };
}
if (typeof value === "bigint") {
return Number(value);
}
if (typeof value === "object" && value !== null) {
const v = value as Record<string, unknown>;
if (v.type === "Buffer" && Array.isArray(v.data)) {
return { __b64: uint8ToBase64(new Uint8Array(v.data as number[])) };
}
}
return value;
}
function reviver(_key: string, value: unknown): unknown {
if (typeof value === "object" && value !== null && "__b64" in value) {
return Buffer.from((value as any).__b64, "base64");
}
return value;
}
// ---------------------------------------------------------------------------
// Stdio protocol
// ---------------------------------------------------------------------------
const enc = new TextEncoder();
const PROTOCOL_VERSION = 1;
function emit(msg: Record<string, unknown>) {
try {
Deno.stdout.writeSync(
enc.encode(JSON.stringify({ v: PROTOCOL_VERSION, ...msg }, replacer) + "\n")
);
} catch {
// Broken pipe — Elixir closed the port. Exit cleanly.
Deno.exit(0);
}
}
// ---------------------------------------------------------------------------
// Concurrent stdin dispatcher
// ---------------------------------------------------------------------------
// Pending responses from Elixir to bridge requests (keyed by request id)
const pendingResponses = new Map<string, {
resolve: (data: any) => void;
reject: (err: Error) => void;
timer: number;
}>();
// Queue for incoming commands from Elixir
const commandQueue: Array<{ resolve: (line: string) => void }> = [];
const bufferedCommands: string[] = [];
function dispatchLine(line: string) {
let parsed: any;
try {
parsed = JSON.parse(line);
} catch {
// Not JSON — ignore
return;
}
// Response to a bridge→Elixir request
if ("ok" in parsed && typeof parsed.id === "string" && pendingResponses.has(parsed.id)) {
const pending = pendingResponses.get(parsed.id)!;
pendingResponses.delete(parsed.id);
clearTimeout(pending.timer);
if (parsed.ok) {
pending.resolve(parsed.data);
} else {
pending.reject(new Error(parsed.error || "store error"));
}
return;
}
// Command from Elixir (has "cmd" field)
if ("cmd" in parsed) {
if (commandQueue.length > 0) {
commandQueue.shift()!.resolve(line);
} else {
bufferedCommands.push(line);
}
return;
}
}
/** Read the next command from Elixir (blocks until one arrives). */
function nextCommand(): Promise<string> {
if (bufferedCommands.length > 0) {
return Promise.resolve(bufferedCommands.shift()!);
}
return new Promise((resolve) => commandQueue.push({ resolve }));
}
/** Start the stdin reader loop. Must be called once at startup. */
async function startStdinDispatcher() {
const reader = Deno.stdin.readable.getReader();
const decoder = new TextDecoder();
let buf = "";
while (true) {
const { value, done } = await reader.read();
if (done) {
// stdin closed — Elixir exited
Deno.exit(0);
}
buf += decoder.decode(value, { stream: true });
const lines = buf.split("\n");
buf = lines.pop()!;
for (const line of lines) {
if (line.trim()) dispatchLine(line);
}
}
}
let requestCounter = 0;
/** Send a request to Elixir and await the response. */
function requestElixir(req: string, args: Record<string, unknown> = {}): Promise<any> {
const id = `br_${++requestCounter}`;
return new Promise((resolve, reject) => {
const timer = setTimeout(() => {
pendingResponses.delete(id);
reject(new Error(`request to Elixir timed out: ${req}`));
}, 30_000);
pendingResponses.set(id, { resolve, reject, timer });
emit({ id, req, args });
});
}
// ---------------------------------------------------------------------------
// Callback auth adapter — delegates to Elixir via requestElixir
// ---------------------------------------------------------------------------
async function loadCallbackAuth(): Promise<{
state: { creds: AuthenticationCreds; keys: SignalKeyStore };
saveCreds: () => Promise<void>;
}> {
// Load or initialize credentials
const raw = await requestElixir("auth.load_creds");
let creds: AuthenticationCreds;
if (raw) {
creds = JSON.parse(JSON.stringify(raw), BufferJSON.reviver);
} else {
creds = initAuthCreds();
// Save the freshly generated creds
await requestElixir("auth.save_creds", {
creds: JSON.parse(JSON.stringify(creds, BufferJSON.replacer)),
});
}
const keys: SignalKeyStore = {
get: async (type: string, ids: string[]) => {
const data = await requestElixir("auth.keys_get", { type, ids });
const result: Record<string, any> = {};
if (data) {
for (const [id, val] of Object.entries(data)) {
result[id] = JSON.parse(JSON.stringify(val), BufferJSON.reviver);
}
}
return result;
},
set: async (data: Record<string, Record<string, any>>) => {
// Serialize with BufferJSON so Elixir stores opaque JSON
const serialized: Record<string, Record<string, any>> = {};
for (const [type, entries] of Object.entries(data)) {
serialized[type] = {};
for (const [id, val] of Object.entries(entries)) {
serialized[type][id] = val === null || val === undefined
? null
: JSON.parse(JSON.stringify(val, BufferJSON.replacer));
}
}
await requestElixir("auth.keys_set", { data: serialized });
},
};
const saveCreds = async () => {
await requestElixir("auth.save_creds", {
creds: JSON.parse(JSON.stringify(creds, BufferJSON.replacer)),
});
};
return {
state: { creds, keys: makeCacheableSignalKeyStore(keys, logger as any) },
saveCreds,
};
}
// ---------------------------------------------------------------------------
// Baileys connection
// ---------------------------------------------------------------------------
const EVENTS: (keyof BaileysEventMap)[] = [
"connection.update",
"creds.update",
"messaging-history.set",
"chats.upsert",
"chats.update",
"chats.delete",
"contacts.upsert",
"contacts.update",
"messages.upsert",
"messages.update",
"messages.delete",
"messages.reaction",
"messages.media-update",
"message-receipt.update",
"groups.upsert",
"groups.update",
"group-participants.update",
"group.join-request",
"presence.update",
"blocklist.set",
"blocklist.update",
"call",
"labels.edit",
"labels.association",
];
let sock: WASocket | null = null;
let reconnecting = false;
const msgCache = new Map<string, unknown>();
const MAX_CACHE = 5000;
async function connectWithCallbackAuth(
config: Record<string, unknown> = {}
) {
const { state, saveCreds } = await loadCallbackAuth();
sock = makeWASocket({
auth: {
creds: state.creds,
keys: state.keys,
},
logger: logger as any,
browser: ["Irish", "Elixir", "1.0.0"] as [string, string, string],
printQRInTerminal: false,
getMessage: async (key) => msgCache.get(key.id ?? "") as any,
...config,
} as any);
// Forward every event to Elixir
for (const event of EVENTS) {
sock.ev.on(event, (data: unknown) => {
// Cache messages for retry decryption
if (event === "messages.upsert") {
const msgs = (data as any).messages as any[];
for (const m of msgs) {
if (m.message && m.key?.id) {
if (msgCache.size >= MAX_CACHE) {
const oldest = msgCache.keys().next().value;
if (oldest) msgCache.delete(oldest);
}
msgCache.set(m.key.id, m.message);
}
}
}
emit({ event, data });
});
}
// Persist credentials on update
sock.ev.on("creds.update", saveCreds);
// Handle disconnects
sock.ev.on("connection.update", ({ connection, lastDisconnect }) => {
if (connection === "close") {
const code = (lastDisconnect?.error as any)?.output?.statusCode;
if (code === DisconnectReason.loggedOut) {
emit({ event: "__exit", data: { reason: "logged_out" } });
Deno.exit(1);
}
if (!reconnecting) {
reconnecting = true;
logger.info("Reconnecting…");
connectWithCallbackAuth(config).finally(() => {
reconnecting = false;
});
}
}
});
}
// ---------------------------------------------------------------------------
// Command dispatch
// ---------------------------------------------------------------------------
type Cmd = { id?: string; cmd: string; args: Record<string, any> };
async function handle(c: Cmd): Promise<unknown> {
if (!sock) throw new Error("not_connected");
const a = c.args;
switch (c.cmd) {
// --- messaging ---
case "send_message":
return sock.sendMessage(a.jid, a.content, a.options);
case "read_messages":
await sock.readMessages(a.keys);
return null;
case "send_presence_update":
await sock.sendPresenceUpdate(a.type, a.jid);
return null;
case "presence_subscribe":
await sock.presenceSubscribe(a.jid);
return null;
// --- profile ---
case "profile_picture_url":
return sock.profilePictureUrl(a.jid, a.type || "preview");
case "update_profile_status":
await sock.updateProfileStatus(a.status);
return null;
case "update_profile_name":
await sock.updateProfileName(a.name);
return null;
case "fetch_status":
return sock.fetchStatus(...a.jids);
case "on_whatsapp":
return sock.onWhatsApp(...a.phone_numbers);
// --- groups ---
case "group_metadata":
return sock.groupMetadata(a.jid);
case "group_create":
return sock.groupCreate(a.subject, a.participants);
case "group_update_subject":
await sock.groupUpdateSubject(a.jid, a.subject);
return null;
case "group_update_description":
await sock.groupUpdateDescription(a.jid, a.description);
return null;
case "group_participants_update":
return sock.groupParticipantsUpdate(a.jid, a.participants, a.action);
case "group_invite_code":
return sock.groupInviteCode(a.jid);
case "group_leave":
await sock.groupLeave(a.jid);
return null;
case "group_fetch_all_participating":
return sock.groupFetchAllParticipating();
// --- privacy ---
case "update_block_status":
await sock.updateBlockStatus(a.jid, a.action);
return null;
case "fetch_blocklist":
return sock.fetchBlocklist();
// --- media ---
case "download_media":
return await downloadMediaMessage(a.message, "buffer", {});
// --- groups (extended) ---
case "group_toggle_ephemeral":
await sock.groupToggleEphemeral(a.jid, a.expiration);
return null;
case "group_setting_update":
await sock.groupSettingUpdate(a.jid, a.setting);
return null;
case "group_revoke_invite":
return sock.groupRevokeInvite(a.jid);
case "group_accept_invite":
return sock.groupAcceptInvite(a.code);
case "group_get_invite_info":
return sock.groupGetInviteInfo(a.code);
case "group_request_participants_list":
return sock.groupRequestParticipantsList(a.jid);
case "group_request_participants_update":
return sock.groupRequestParticipantsUpdate(a.jid, a.participants, a.action);
case "group_member_add_mode":
return sock.groupMemberAddMode(a.jid, a.mode);
case "group_join_approval_mode":
return sock.groupJoinApprovalMode(a.jid, a.mode);
// --- profile (extended) ---
case "update_profile_picture":
await sock.updateProfilePicture(a.jid, a.content);
return null;
case "remove_profile_picture":
await sock.removeProfilePicture(a.jid);
return null;
// --- chat ---
case "chat_modify":
await sock.chatModify(a.mod, a.jid);
return null;
case "star_messages":
await sock.star(a.jid, a.messages, a.star);
return null;
case "send_receipts":
await sock.sendReceipts(a.keys, a.type);
return null;
// --- auth ---
case "request_pairing_code":
return sock.requestPairingCode(a.phone_number, a.custom_code);
case "logout":
await sock.logout();
return null;
default:
throw Object.assign(new Error(`unknown command: ${c.cmd}`), {
code: "unknown_command",
});
}
}
// ---------------------------------------------------------------------------
// Main
// ---------------------------------------------------------------------------
async function main() {
// Start the concurrent stdin dispatcher
startStdinDispatcher();
// First message must be the init command
const firstLine = await nextCommand();
const init: Cmd = JSON.parse(firstLine, reviver);
if (init.cmd !== "init") {
logger.error("First command must be init");
Deno.exit(1);
}
try {
await connectWithCallbackAuth(init.args.config || {});
emit({ id: init.id, ok: true, data: { status: "initialized" } });
} catch (err) {
emit({
id: init.id,
ok: false,
error: err instanceof Error ? err.message : String(err),
});
Deno.exit(1);
}
// Command loop
while (true) {
const line = await nextCommand();
let id: string | undefined;
try {
const cmd: Cmd = JSON.parse(line, reviver);
id = cmd.id;
const result = await handle(cmd);
emit({ id, ok: true, data: result ?? null });
} catch (err) {
const code =
err instanceof Error && "code" in err
? (err as any).code
: "command_error";
const message = err instanceof Error ? err.message : String(err);
emit({
id,
ok: false,
error: { code, message, details: {} },
});
}
}
}
main();