mirror of
https://github.com/hansjone/dsh-im-ops.git
synced 2026-10-10 14:20:45 +08:00
299 lines
9.4 KiB
JavaScript
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;
|
|
}
|
|
}
|