dsh-im-ops/src/channels/whatsapp/whatsapp-runtime.mjs
2026-08-16 17:30:38 +08:00

299 lines
9.4 KiB
JavaScript

import {
areJidsSameUser,
normalizeMessageContent,
} from '@whiskeysockets/baileys';
import { splitMessageText } from '../shared/editable-message-stream.mjs';
import { createWhatsappBridgeStatus, WhatsappHarnessBridge } from './whatsapp-bridge.mjs';
import { createWhatsappWebSession } from './whatsapp-web-session.mjs';
function messageContext(content) {
return content?.extendedTextMessage?.contextInfo
?? content?.imageMessage?.contextInfo
?? content?.videoMessage?.contextInfo
?? content?.documentMessage?.contextInfo
?? null;
}
function messageText(content) {
return content?.conversation
?? content?.extendedTextMessage?.text
?? content?.imageMessage?.caption
?? content?.videoMessage?.caption
?? content?.documentMessage?.caption
?? '';
}
export function normalizeWhatsappMessage(message, accountJid) {
const remoteJid = typeof message?.key?.remoteJid === 'string' ? message.key.remoteJid : '';
const alternateRemoteJid = typeof message?.key?.remoteJidAlt === 'string'
? message.key.remoteJidAlt : '';
const messageId = typeof message?.key?.id === 'string' ? message.key.id : '';
if (!remoteJid || !messageId || remoteJid === 'status@broadcast'
|| remoteJid.endsWith('@newsletter')) return null;
const group = remoteJid.endsWith('@g.us');
const fromMe = message.key.fromMe === true;
const selfChat = fromMe && !group
&& [remoteJid, alternateRemoteJid].some((jid) => jid && areJidsSameUser(jid, accountJid));
if (fromMe && !selfChat) return null;
const senderJid = selfChat ? accountJid : group ? message.key.participant : remoteJid;
if (typeof senderJid !== 'string' || !senderJid) return null;
const content = normalizeMessageContent(message.message);
const context = messageContext(content);
const mentioned = Array.isArray(context?.mentionedJid)
&& context.mentionedJid.some((jid) => areJidsSameUser(jid, accountJid));
const replyToSelf = typeof context?.participant === 'string'
&& areJidsSameUser(context.participant, accountJid);
return {
messageId: `${remoteJid}:${messageId}`,
providerMessageId: messageId,
senderId: senderJid,
senderIsBot: false,
kind: group ? 'group' : 'direct',
conversationId: remoteJid,
content: messageText(content),
addressed: !group || mentioned || replyToSelf,
selfChat,
replyTarget: { jid: remoteJid, quoted: message, selfChat },
};
}
class RecentWhatsappOutboundIds {
#ids = new Map();
has(id) {
this.#purge();
return this.#ids.has(id);
}
remember(id) {
if (typeof id !== 'string' || !id) return;
this.#purge();
this.#ids.set(id, Date.now() + 5 * 60_000);
while (this.#ids.size > 256) this.#ids.delete(this.#ids.keys().next().value);
}
#purge() {
const now = Date.now();
for (const [id, expiresAt] of this.#ids) {
if (expiresAt > now) continue;
this.#ids.delete(id);
}
}
}
class WhatsappBotClient {
#socket;
#outboundIds;
#typingTimers = new Map();
constructor(socket, outboundIds) {
this.#socket = socket;
this.#outboundIds = outboundIds;
}
async sendText(target, text) {
await this.#stopTyping(target.jid);
let result = null;
for (const [index, chunk] of splitMessageText(text, 4_000).entries()) {
result = await this.#socket.sendMessage(
target.jid,
{ text: chunk },
index === 0 && target.quoted ? { quoted: target.quoted } : undefined,
);
this.#outboundIds.remember(result?.key?.id);
}
return result;
}
async sendTyping(target) {
if (!target.selfChat && target.quoted?.key) {
await this.#socket.readMessages([target.quoted.key]).catch(() => undefined);
}
await this.#socket.sendPresenceUpdate('composing', target.jid);
await this.#stopTyping(target.jid, false);
const timer = setInterval(() => {
void this.#socket.sendPresenceUpdate('composing', target.jid).catch(() => {
void this.#stopTyping(target.jid);
});
}, 20_000);
timer.unref?.();
this.#typingTimers.set(target.jid, timer);
}
async close() {
const jids = [...this.#typingTimers.keys()];
await Promise.allSettled(jids.map((jid) => this.#stopTyping(jid)));
}
async #stopTyping(jid, sendPaused = true) {
const timer = this.#typingTimers.get(jid);
if (timer) clearInterval(timer);
this.#typingTimers.delete(jid);
if (sendPaused) await this.#socket.sendPresenceUpdate('paused', jid).catch(() => undefined);
}
}
export function createWhatsappRuntimeStatus() {
return {
startedAt: null,
ready: false,
connectionState: 'idle',
harnessReachable: false,
lastCheckedAt: null,
lastConnectedAt: null,
lastError: null,
...createWhatsappBridgeStatus(),
};
}
export class WhatsappRuntime {
#config;
#authDir;
#harness;
#state;
#logger;
#replyTimeoutMs;
#connectTimeoutMs;
#createSession;
#status = createWhatsappRuntimeStatus();
#abortController = null;
#session = null;
#client = null;
#bridge = null;
#starting = null;
constructor({
config,
authDir,
harness,
state,
logger = console,
replyTimeoutMs = 600_000,
connectTimeoutMs = 30_000,
createSession = createWhatsappWebSession,
}) {
if (!config || !authDir || !harness || !state || typeof createSession !== 'function') {
throw new TypeError('WhatsappRuntime requires config, auth directory, Harness, state, and session factory');
}
this.#config = config;
this.#authDir = authDir;
this.#harness = harness;
this.#state = state;
this.#logger = logger;
this.#replyTimeoutMs = replyTimeoutMs;
this.#connectTimeoutMs = connectTimeoutMs;
this.#createSession = createSession;
}
get status() {
return structuredClone(this.#status);
}
async start() {
if (this.#status.ready && this.#session) return this.status;
if (this.#starting) return this.#starting;
this.#starting = this.#start().finally(() => { this.#starting = null; });
return this.#starting;
}
async #start() {
await this.stop();
this.#status.startedAt = new Date().toISOString();
this.#status.connectionState = 'connecting';
this.#status.lastError = null;
await this.#harness.ensureRunning();
this.#status.harnessReachable = true;
const controller = new AbortController();
this.#abortController = controller;
const outboundIds = new RecentWhatsappOutboundIds();
let rejectRelink;
const relinkRequired = new Promise((_, reject) => { rejectRelink = reject; });
void relinkRequired.catch(() => undefined);
try {
const session = await this.#createSession({
authDir: this.#authDir,
signal: controller.signal,
logger: this.#logger,
onQr: () => rejectRelink(Object.assign(
new Error('WhatsApp linked-device session must be scanned again'),
{ code: 'relink-required' },
)),
onMessage: async (raw) => {
const message = normalizeWhatsappMessage(raw, this.#config.accountJid);
if (!message || outboundIds.has(message.providerMessageId) || !this.#bridge) return;
this.#status.lastCheckedAt = Date.now();
await this.#bridge.accept(message);
},
onDisconnect: ({ error }) => {
if (controller.signal.aborted) return;
this.#status.ready = false;
this.#status.connectionState = 'failed';
this.#status.lastError = error?.message ?? 'WhatsApp Web connection closed';
},
});
this.#session = session;
let timer;
const identity = await Promise.race([
session.ready,
relinkRequired,
new Promise((_, reject) => {
timer = setTimeout(
() => reject(new Error('WhatsApp Web did not connect in time')),
this.#connectTimeoutMs,
);
}),
]).finally(() => clearTimeout(timer));
if (!areJidsSameUser(identity.accountJid, this.#config.accountJid)) {
throw new Error('WhatsApp linked account does not match the saved bot');
}
const client = new WhatsappBotClient(session.socket, outboundIds);
this.#client = client;
this.#bridge = new WhatsappHarnessBridge({
bot: client,
harness: this.#harness,
state: this.#state,
status: this.#status,
logger: this.#logger,
replyTimeoutMs: this.#replyTimeoutMs,
});
const now = Date.now();
this.#status.ready = true;
this.#status.connectionState = 'connected';
this.#status.lastCheckedAt = now;
this.#status.lastConnectedAt = now;
return this.status;
} catch (error) {
this.#status.ready = false;
this.#status.connectionState = 'failed';
this.#status.lastError = error?.message ?? String(error);
await this.stop();
throw error;
}
}
async logout() {
await this.#session?.logout().catch(() => undefined);
return this.stop();
}
async stop() {
const session = this.#session;
const client = this.#client;
const bridge = this.#bridge;
this.#abortController?.abort();
this.#abortController = null;
this.#session = null;
this.#client = null;
this.#bridge = null;
await client?.close().catch(() => undefined);
await session?.close().catch(() => undefined);
await Promise.race([
bridge?.waitForIdle() ?? Promise.resolve(),
new Promise((resolve) => setTimeout(resolve, 2_000)),
]);
this.#status.ready = false;
this.#status.connectionState = 'idle';
return this.status;
}
}