mirror of
https://github.com/hansjone/dsh-im-ops.git
synced 2026-10-16 08:00:49 +08:00
feat: add WhatsApp QR channel
This commit is contained in:
parent
f8841da76f
commit
c54463843f
29 changed files with 4565 additions and 224 deletions
299
src/channels/whatsapp/whatsapp-runtime.mjs
Normal file
299
src/channels/whatsapp/whatsapp-runtime.mjs
Normal file
|
|
@ -0,0 +1,299 @@
|
|||
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;
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue