2065 lines
81 KiB
JavaScript
2065 lines
81 KiB
JavaScript
import express from 'express'
|
|
import 'express-async-errors'
|
|
import multer from 'multer'
|
|
import pino from 'pino'
|
|
import QRCode from 'qrcode'
|
|
import crypto from 'node:crypto'
|
|
import fs from 'node:fs/promises'
|
|
import path from 'node:path'
|
|
import https from 'node:https'
|
|
import { Boom } from '@hapi/boom'
|
|
import { HttpsProxyAgent } from 'https-proxy-agent'
|
|
import { ProxyAgent as UndiciProxyAgent } from 'undici'
|
|
import makeWASocket, {
|
|
Browsers,
|
|
BufferJSON,
|
|
DisconnectReason,
|
|
downloadAndProcessHistorySyncNotification,
|
|
downloadMediaMessage,
|
|
fetchLatestBaileysVersion,
|
|
getContentType,
|
|
jidNormalizedUser,
|
|
makeCacheableSignalKeyStore,
|
|
useMultiFileAuthState,
|
|
} from 'baileys'
|
|
|
|
const port = Number(process.env.PORT || 3200)
|
|
const dataDir = process.env.DATA_DIR || '/data'
|
|
const authDir = path.join(dataDir, 'auth')
|
|
const storeDir = path.join(dataDir, 'store')
|
|
const outboundMediaDir = path.join(dataDir, 'outbound-media')
|
|
const mediaCacheDir = path.join(dataDir, 'media-cache')
|
|
const accountsFile = path.join(dataDir, 'accounts.json')
|
|
const webhookDeliveriesFile = path.join(dataDir, 'webhook-deliveries.json')
|
|
const outboundJobsFile = path.join(dataDir, 'outbound-jobs.json')
|
|
const gatewayToken = String(process.env.WHATSAPP_GATEWAY_TOKEN || '')
|
|
const defaultProxyUrl = String(process.env.WHATSAPP_DEFAULT_PROXY_URL || '').trim()
|
|
const maxMessagesPerChat = Math.max(2_000, Number(process.env.MAX_MESSAGES_PER_CHAT || 20_000))
|
|
const log = pino({ level: process.env.LOG_LEVEL || 'info' })
|
|
|
|
if (gatewayToken.length < 24) {
|
|
throw new Error('WHATSAPP_GATEWAY_TOKEN 必须至少 24 个字符')
|
|
}
|
|
|
|
await fs.mkdir(authDir, { recursive: true })
|
|
await fs.mkdir(storeDir, { recursive: true })
|
|
await fs.mkdir(outboundMediaDir, { recursive: true })
|
|
await fs.mkdir(mediaCacheDir, { recursive: true })
|
|
|
|
const accounts = new Map()
|
|
const sessions = new Map()
|
|
const stores = new Map()
|
|
const reconnectTimers = new Map()
|
|
const persistTimers = new Map()
|
|
const webhookDeliveries = new Map()
|
|
const webhookDeliveryTimers = new Map()
|
|
const webhookDeliveriesProcessing = new Set()
|
|
const outboundJobs = new Map()
|
|
const outboundJobTimers = new Map()
|
|
const outboundJobsProcessing = new Set()
|
|
const credentialKey = crypto.createHash('sha256').update(gatewayToken).digest()
|
|
|
|
function encryptCredential(value) {
|
|
if (!value) return ''
|
|
const iv = crypto.randomBytes(12)
|
|
const cipher = crypto.createCipheriv('aes-256-gcm', credentialKey, iv)
|
|
const encrypted = Buffer.concat([cipher.update(String(value), 'utf8'), cipher.final()])
|
|
return [
|
|
'v1',
|
|
iv.toString('base64url'),
|
|
cipher.getAuthTag().toString('base64url'),
|
|
encrypted.toString('base64url'),
|
|
].join('.')
|
|
}
|
|
|
|
function decryptCredential(value) {
|
|
if (!value) return ''
|
|
const parts = String(value).split('.')
|
|
if (parts.length !== 4 || parts[0] !== 'v1') return String(value)
|
|
const decipher = crypto.createDecipheriv(
|
|
'aes-256-gcm',
|
|
credentialKey,
|
|
Buffer.from(parts[1], 'base64url'),
|
|
)
|
|
decipher.setAuthTag(Buffer.from(parts[2], 'base64url'))
|
|
return Buffer.concat([
|
|
decipher.update(Buffer.from(parts[3], 'base64url')),
|
|
decipher.final(),
|
|
]).toString('utf8')
|
|
}
|
|
|
|
function accountWebhookSecret(account) {
|
|
return decryptCredential(account?.webhookSecretEncrypted || account?.webhookSecret || '')
|
|
}
|
|
|
|
function accountProxyUrl(account) {
|
|
return decryptCredential(account?.proxyUrlEncrypted || '') || defaultProxyUrl
|
|
}
|
|
|
|
function jsonSafe(value) {
|
|
return JSON.parse(JSON.stringify(value, BufferJSON.replacer), BufferJSON.reviver)
|
|
}
|
|
|
|
async function readJson(file, fallback) {
|
|
try {
|
|
return JSON.parse(await fs.readFile(file, 'utf8'), BufferJSON.reviver)
|
|
} catch {
|
|
return fallback
|
|
}
|
|
}
|
|
|
|
async function atomicJson(file, value) {
|
|
const temp = `${file}.${crypto.randomUUID()}.tmp`
|
|
await fs.writeFile(temp, JSON.stringify(value, BufferJSON.replacer))
|
|
await fs.rename(temp, file)
|
|
}
|
|
|
|
for (const delivery of await readJson(webhookDeliveriesFile, [])) {
|
|
if (delivery?.id) webhookDeliveries.set(delivery.id, delivery)
|
|
}
|
|
for (const job of await readJson(outboundJobsFile, [])) {
|
|
if (job?.id) outboundJobs.set(job.id, job)
|
|
}
|
|
|
|
async function saveWebhookDeliveries() {
|
|
const sorted = [...webhookDeliveries.values()]
|
|
.sort((a, b) => Date.parse(b.createdAt || '') - Date.parse(a.createdAt || ''))
|
|
const active = sorted.filter((item) => !['delivered', 'dead'].includes(item.status))
|
|
const terminal = sorted.filter((item) => ['delivered', 'dead'].includes(item.status)).slice(0, 2_000)
|
|
const values = [...active, ...terminal]
|
|
const keep = new Set(values.map((item) => item.id))
|
|
for (const id of webhookDeliveries.keys()) {
|
|
if (!keep.has(id)) webhookDeliveries.delete(id)
|
|
}
|
|
await atomicJson(webhookDeliveriesFile, values)
|
|
}
|
|
|
|
let webhookDeliverySaveChain = Promise.resolve()
|
|
function queueWebhookDeliveriesSave() {
|
|
webhookDeliverySaveChain = webhookDeliverySaveChain
|
|
.catch(() => undefined)
|
|
.then(() => saveWebhookDeliveries())
|
|
.catch((error) => log.error({ err: error }, 'webhook delivery persistence failed'))
|
|
return webhookDeliverySaveChain
|
|
}
|
|
|
|
async function saveOutboundJobs() {
|
|
const sorted = [...outboundJobs.values()]
|
|
.sort((a, b) => Date.parse(b.createdAt || '') - Date.parse(a.createdAt || ''))
|
|
const active = sorted.filter((item) => !['sent', 'dead'].includes(item.status))
|
|
const terminal = sorted.filter((item) => ['sent', 'dead'].includes(item.status)).slice(0, 2_000)
|
|
const values = [...active, ...terminal]
|
|
const keep = new Set(values.map((item) => item.id))
|
|
for (const id of outboundJobs.keys()) {
|
|
if (!keep.has(id)) outboundJobs.delete(id)
|
|
}
|
|
await atomicJson(outboundJobsFile, values)
|
|
}
|
|
|
|
let outboundJobsSaveChain = Promise.resolve()
|
|
function queueOutboundJobsSave() {
|
|
outboundJobsSaveChain = outboundJobsSaveChain
|
|
.catch(() => undefined)
|
|
.then(() => saveOutboundJobs())
|
|
.catch((error) => log.error({ err: error }, 'outbound job persistence failed'))
|
|
return outboundJobsSaveChain
|
|
}
|
|
|
|
async function saveAccounts() {
|
|
const values = [...accounts.values()].map(({ qrDataUrl, pairingCode, ...item }) => item)
|
|
await atomicJson(accountsFile, values)
|
|
}
|
|
|
|
function createStore() {
|
|
return {
|
|
chats: new Map(),
|
|
contacts: new Map(),
|
|
messages: new Map(),
|
|
groups: new Map(),
|
|
}
|
|
}
|
|
|
|
async function loadStore(accountId) {
|
|
if (stores.has(accountId)) return stores.get(accountId)
|
|
const raw = await readJson(path.join(storeDir, `${accountId}.json`), {})
|
|
const store = createStore()
|
|
for (const item of raw.chats || []) store.chats.set(item.id, item)
|
|
for (const item of raw.contacts || []) store.contacts.set(item.id, item)
|
|
for (const [jid, messages] of raw.messages || []) store.messages.set(jid, messages)
|
|
for (const item of raw.groups || []) store.groups.set(item.id, item)
|
|
stores.set(accountId, store)
|
|
return store
|
|
}
|
|
|
|
async function readAuthFiles(accountId) {
|
|
const directory = path.join(authDir, accountId)
|
|
const names = await fs.readdir(directory).catch(() => [])
|
|
const files = {}
|
|
for (const name of names) {
|
|
if (!/^[a-zA-Z0-9_.-]+\.json$/.test(name)) continue
|
|
files[name] = await fs.readFile(path.join(directory, name), 'utf8')
|
|
}
|
|
return files
|
|
}
|
|
|
|
function scheduleStoreSave(accountId) {
|
|
if (persistTimers.has(accountId)) return
|
|
const timer = setTimeout(async () => {
|
|
persistTimers.delete(accountId)
|
|
const store = stores.get(accountId)
|
|
if (!store) return
|
|
await atomicJson(path.join(storeDir, `${accountId}.json`), {
|
|
chats: [...store.chats.values()],
|
|
contacts: [...store.contacts.values()],
|
|
messages: [...store.messages.entries()],
|
|
groups: [...store.groups.values()],
|
|
}).catch((error) => log.error({ error, accountId }, 'store persistence failed'))
|
|
}, 350)
|
|
timer.unref?.()
|
|
persistTimers.set(accountId, timer)
|
|
}
|
|
|
|
function unwrapMessage(message = {}) {
|
|
return message.ephemeralMessage?.message
|
|
|| message.viewOnceMessage?.message
|
|
|| message.viewOnceMessageV2?.message
|
|
|| message.documentWithCaptionMessage?.message
|
|
|| message
|
|
}
|
|
|
|
function messageContent(raw) {
|
|
const content = unwrapMessage(raw?.message || {})
|
|
const contentType = getContentType(content)
|
|
const value = contentType ? content[contentType] : null
|
|
const mapping = {
|
|
imageMessage: 'image',
|
|
videoMessage: 'video',
|
|
audioMessage: 'audio',
|
|
documentMessage: 'file',
|
|
stickerMessage: 'sticker',
|
|
contactMessage: 'contact',
|
|
contactsArrayMessage: 'contact',
|
|
locationMessage: 'location',
|
|
liveLocationMessage: 'location',
|
|
pollCreationMessage: 'poll',
|
|
reactionMessage: 'reaction',
|
|
}
|
|
const text = content.conversation
|
|
|| content.extendedTextMessage?.text
|
|
|| (contentType === 'reactionMessage' ? value?.text : '')
|
|
|| value?.caption
|
|
|| value?.fileName
|
|
|| value?.displayName
|
|
|| ''
|
|
return {
|
|
type: mapping[contentType] || 'text',
|
|
contentType: contentType || 'unknown',
|
|
text,
|
|
caption: value?.caption || '',
|
|
mimetype: value?.mimetype || '',
|
|
fileName: value?.fileName || '',
|
|
fileLength: Number(value?.fileLength || 0),
|
|
quotedMessageId: value?.key?.id || value?.contextInfo?.stanzaId || undefined,
|
|
}
|
|
}
|
|
|
|
function normalizedMessage(accountId, raw) {
|
|
const protocol = raw?.message?.protocolMessage
|
|
const editedMessage = protocol?.editedMessage
|
|
const key = editedMessage && protocol?.key ? protocol.key : raw.key || {}
|
|
const jid = String(key.remoteJid || '')
|
|
const timestamp = Number(raw.messageTimestamp || Date.now() / 1000)
|
|
const effectiveRaw = editedMessage
|
|
? { ...raw, key, message: editedMessage }
|
|
: raw
|
|
return {
|
|
id: String(key.id || crypto.randomUUID()),
|
|
accountId,
|
|
chatId: jid,
|
|
senderId: String(key.participant || jid),
|
|
fromMe: Boolean(key.fromMe),
|
|
pushName: raw.pushName || '',
|
|
timestamp,
|
|
createdAt: new Date(timestamp * 1000).toISOString(),
|
|
...messageContent(effectiveRaw),
|
|
edited: Boolean(editedMessage),
|
|
raw: jsonSafe(effectiveRaw),
|
|
}
|
|
}
|
|
|
|
function revokedMessageKey(raw) {
|
|
const protocol = raw?.message?.protocolMessage
|
|
const type = String(protocol?.type ?? '').toUpperCase()
|
|
if (!protocol?.key || !(protocol.type === 0 || type.includes('REVOKE'))) return null
|
|
return protocol.key
|
|
}
|
|
|
|
function messageDeliveryStatus(status, fromMe = true) {
|
|
const value = Number(status)
|
|
if (!Number.isFinite(value)) return fromMe ? 'sent' : 'delivered'
|
|
if (value <= 0) return 'failed'
|
|
if (value === 1) return 'sending'
|
|
if (value === 2) return 'sent'
|
|
if (value === 3) return 'delivered'
|
|
return 'read'
|
|
}
|
|
|
|
function upsertMessage(accountId, raw) {
|
|
const store = stores.get(accountId)
|
|
if (!store || !raw?.key?.remoteJid || !raw?.message) return null
|
|
const message = normalizedMessage(accountId, raw)
|
|
const list = store.messages.get(message.chatId) || []
|
|
const index = list.findIndex((item) => item.id === message.id)
|
|
if (index >= 0) list[index] = message
|
|
else list.push(message)
|
|
list.sort((a, b) => a.timestamp - b.timestamp)
|
|
if (list.length > maxMessagesPerChat) list.splice(0, list.length - maxMessagesPerChat)
|
|
store.messages.set(message.chatId, list)
|
|
const existing = store.chats.get(message.chatId) || { id: message.chatId }
|
|
store.chats.set(message.chatId, {
|
|
...existing,
|
|
id: message.chatId,
|
|
name: existing.name || message.pushName || message.chatId.split('@')[0],
|
|
timestamp: message.timestamp,
|
|
unreadCount: message.fromMe ? Number(existing.unreadCount || 0) : Number(existing.unreadCount || 0) + 1,
|
|
lastMessage: { id: message.id, text: message.text, type: message.type, createdAt: message.createdAt },
|
|
})
|
|
scheduleStoreSave(accountId)
|
|
return message
|
|
}
|
|
|
|
async function replayStoredHistoryNotifications(
|
|
accountId, account, store, logger, processedIds = new Set(), downloadOptions = {},
|
|
) {
|
|
const replayed = new Set(account.replayedHistoryNotificationIds || [])
|
|
const candidates = []
|
|
for (const messages of store.messages.values()) {
|
|
for (const item of messages || []) {
|
|
const notification = item.raw?.message?.protocolMessage?.historySyncNotification
|
|
if (!notification || !item.id || replayed.has(item.id) || processedIds.has(item.id)) continue
|
|
candidates.push({ id: item.id, notification })
|
|
}
|
|
}
|
|
for (const candidate of candidates) {
|
|
try {
|
|
const data = await downloadAndProcessHistorySyncNotification(
|
|
candidate.notification, downloadOptions, logger,
|
|
)
|
|
for (const chat of data.chats || []) {
|
|
const id = String(chat.id || '')
|
|
if (id) store.chats.set(id, { ...(store.chats.get(id) || {}), ...jsonSafe(chat), id })
|
|
}
|
|
for (const contact of data.contacts || []) {
|
|
const id = String(contact.id || '')
|
|
if (id) store.contacts.set(id, { ...(store.contacts.get(id) || {}), ...jsonSafe(contact), id })
|
|
}
|
|
for (const message of data.messages || []) upsertMessage(accountId, message)
|
|
replayed.add(candidate.id)
|
|
account.replayedHistoryNotificationIds = [...replayed].slice(-100)
|
|
account.historySync = {
|
|
status: 'processing',
|
|
syncType: data.syncType ?? candidate.notification.syncType ?? null,
|
|
progress: data.progress ?? candidate.notification.progress ?? null,
|
|
chats: data.chats?.length || 0,
|
|
contacts: data.contacts?.length || 0,
|
|
messages: data.messages?.length || 0,
|
|
recovered: true,
|
|
updatedAt: new Date().toISOString(),
|
|
}
|
|
await saveAccounts()
|
|
scheduleStoreSave(accountId)
|
|
void deliverWebhook(account, 'history.synced', {
|
|
chats: data.chats?.length || 0,
|
|
contacts: data.contacts?.length || 0,
|
|
messages: data.messages?.length || 0,
|
|
progress: data.progress ?? candidate.notification.progress ?? null,
|
|
syncType: data.syncType ?? candidate.notification.syncType ?? null,
|
|
recovered: true,
|
|
isLatest: true,
|
|
})
|
|
} catch (error) {
|
|
logger.warn({ err: error, messageId: candidate.id }, 'stored history notification replay failed')
|
|
}
|
|
}
|
|
}
|
|
|
|
function storedMessage(accountId, jid, messageId) {
|
|
const store = stores.get(accountId)
|
|
return (store?.messages.get(jid) || []).find((item) => item.id === messageId)
|
|
}
|
|
|
|
function accountPublic(account) {
|
|
if (!account) return account
|
|
const {
|
|
webhookSecret: _webhookSecret,
|
|
webhookSecretEncrypted: _webhookSecretEncrypted,
|
|
proxyUrlEncrypted: _proxyUrlEncrypted,
|
|
...safe
|
|
} = account
|
|
return {
|
|
...safe,
|
|
webhookSecretConfigured: Boolean(accountWebhookSecret(account)),
|
|
proxyConfigured: Boolean(accountProxyUrl(account)),
|
|
}
|
|
}
|
|
|
|
function accountSocket(accountId) {
|
|
const session = sessions.get(accountId)
|
|
if (!session?.sock) {
|
|
const error = new Error('WhatsApp 账号尚未启动')
|
|
error.status = 409
|
|
throw error
|
|
}
|
|
return session.sock
|
|
}
|
|
|
|
function disconnectStatus(error) {
|
|
return Number(error?.output?.statusCode || new Boom(error).output?.statusCode || 500)
|
|
}
|
|
|
|
function isTransientPairingError(error) {
|
|
const status = disconnectStatus(error)
|
|
return [408, 428, 500, 503, 515].includes(status)
|
|
|| /connection (?:closed|failure|terminated)|timed out/i.test(String(error?.message || ''))
|
|
}
|
|
|
|
async function resetPairingSession(accountId) {
|
|
const account = accounts.get(accountId)
|
|
if (!account) throw Object.assign(new Error('账号不存在'), { status: 404 })
|
|
const timer = reconnectTimers.get(accountId)
|
|
if (timer) clearTimeout(timer)
|
|
reconnectTimers.delete(accountId)
|
|
const session = sessions.get(accountId)
|
|
sessions.delete(accountId)
|
|
if (session?.sock) {
|
|
try {
|
|
session.sock.end(new Error('Pairing session reset'))
|
|
} catch {
|
|
// Socket may already be closed.
|
|
}
|
|
}
|
|
await fs.rm(path.join(authDir, accountId), { recursive: true, force: true })
|
|
await fs.mkdir(path.join(authDir, accountId), { recursive: true, mode: 0o700 })
|
|
delete account.user
|
|
delete account.qrDataUrl
|
|
delete account.pairingCode
|
|
delete account.lastError
|
|
account.status = 'created'
|
|
account.connectionState = 'idle'
|
|
account.updatedAt = new Date().toISOString()
|
|
await saveAccounts()
|
|
}
|
|
|
|
async function waitForPairingReady(accountId, sock, timeoutMs = 20_000) {
|
|
const account = accounts.get(accountId)
|
|
if (account?.qrDataUrl) return
|
|
try {
|
|
await sock.waitForConnectionUpdate(async (update) => Boolean(update.qr || update.connection === 'open'), timeoutMs)
|
|
} catch (error) {
|
|
if (isTransientPairingError(error)) {
|
|
throw Object.assign(new Error('WhatsApp 连接尚未就绪'), {
|
|
status: 503,
|
|
cause: error,
|
|
})
|
|
}
|
|
throw error
|
|
}
|
|
if (sessions.get(accountId)?.sock !== sock) {
|
|
throw Object.assign(new Error('WhatsApp 连接已重置,请重试'), { status: 503 })
|
|
}
|
|
}
|
|
|
|
function scheduleWebhookDelivery(deliveryId, delayMs = 0) {
|
|
const existing = webhookDeliveryTimers.get(deliveryId)
|
|
if (existing) clearTimeout(existing)
|
|
const timer = setTimeout(() => {
|
|
webhookDeliveryTimers.delete(deliveryId)
|
|
void processWebhookDelivery(deliveryId)
|
|
}, Math.max(0, delayMs))
|
|
timer.unref?.()
|
|
webhookDeliveryTimers.set(deliveryId, timer)
|
|
}
|
|
|
|
async function processWebhookDelivery(deliveryId) {
|
|
if (webhookDeliveriesProcessing.has(deliveryId)) return
|
|
const delivery = webhookDeliveries.get(deliveryId)
|
|
if (!delivery || ['delivered', 'dead'].includes(delivery.status)) return
|
|
const account = accounts.get(delivery.accountId)
|
|
const webhookSecret = accountWebhookSecret(account)
|
|
if (!account?.webhookUrl || !account.webhookActive || !webhookSecret) {
|
|
delivery.status = 'dead'
|
|
delivery.lastError = 'Webhook is not configured or inactive'
|
|
delivery.updatedAt = new Date().toISOString()
|
|
await saveWebhookDeliveries()
|
|
return
|
|
}
|
|
const waitMs = Math.max(0, Date.parse(delivery.nextAttemptAt || '') - Date.now())
|
|
if (waitMs > 0) {
|
|
scheduleWebhookDelivery(deliveryId, waitMs)
|
|
return
|
|
}
|
|
webhookDeliveriesProcessing.add(deliveryId)
|
|
delivery.status = 'delivering'
|
|
delivery.attempts = Number(delivery.attempts || 0) + 1
|
|
delivery.updatedAt = new Date().toISOString()
|
|
void queueWebhookDeliveriesSave()
|
|
try {
|
|
const signature = crypto.createHmac('sha256', webhookSecret)
|
|
.update(delivery.payload)
|
|
.digest('hex')
|
|
const response = await fetch(account.webhookUrl, {
|
|
method: 'POST',
|
|
headers: {
|
|
'content-type': 'application/json',
|
|
'x-webhook-signature': `sha256=${signature}`,
|
|
'x-webhook-attempt': String(delivery.attempts),
|
|
'x-webhook-delivery-id': delivery.id,
|
|
},
|
|
body: delivery.payload,
|
|
signal: AbortSignal.timeout(15_000),
|
|
})
|
|
const responseBody = await response.text().catch(() => '')
|
|
delivery.responseStatus = response.status
|
|
delivery.responseBody = responseBody.slice(0, 500)
|
|
if (!response.ok) throw new Error(`Webhook HTTP ${response.status}${responseBody ? `: ${responseBody.slice(0, 300)}` : ''}`)
|
|
delivery.status = 'delivered'
|
|
delivery.deliveredAt = new Date().toISOString()
|
|
delete delivery.lastError
|
|
delete delivery.nextAttemptAt
|
|
} catch (error) {
|
|
delivery.lastError = error?.cause?.message || error?.message || 'Webhook request failed'
|
|
if (delivery.attempts >= Number(delivery.maxAttempts || 8)) {
|
|
delivery.status = 'dead'
|
|
delivery.deadAt = new Date().toISOString()
|
|
log.error({ accountId: account.id, event: delivery.event, deliveryId, err: error }, 'webhook delivery exhausted')
|
|
} else {
|
|
const delay = Math.min(5 * 60_000, 1000 * (2 ** Math.max(0, delivery.attempts - 1)))
|
|
delivery.status = 'retrying'
|
|
delivery.nextAttemptAt = new Date(Date.now() + delay).toISOString()
|
|
scheduleWebhookDelivery(deliveryId, delay)
|
|
}
|
|
} finally {
|
|
delivery.updatedAt = new Date().toISOString()
|
|
webhookDeliveriesProcessing.delete(deliveryId)
|
|
await queueWebhookDeliveriesSave()
|
|
}
|
|
}
|
|
|
|
async function deliverWebhook(account, event, data) {
|
|
if (!account.webhookUrl || !account.webhookActive || !accountWebhookSecret(account)) return
|
|
const messageId = ['message.received', 'message.sent', 'message.edited'].includes(event)
|
|
? String(data?.message?.id || '')
|
|
: ''
|
|
const id = messageId
|
|
? `wa-${crypto.createHash('sha256').update(`${account.id}:${event}:${messageId}`).digest('hex').slice(0, 32)}`
|
|
: crypto.randomUUID()
|
|
if (webhookDeliveries.has(id)) return
|
|
const payload = JSON.stringify({
|
|
id,
|
|
event,
|
|
occurredAt: new Date().toISOString(),
|
|
data: { accountId: account.id, ...data },
|
|
})
|
|
webhookDeliveries.set(id, {
|
|
id,
|
|
accountId: account.id,
|
|
event,
|
|
payload,
|
|
status: 'pending',
|
|
attempts: 0,
|
|
maxAttempts: 8,
|
|
createdAt: new Date().toISOString(),
|
|
updatedAt: new Date().toISOString(),
|
|
})
|
|
scheduleWebhookDelivery(id)
|
|
await queueWebhookDeliveriesSave()
|
|
}
|
|
|
|
function queuedOutboundMessage(job) {
|
|
const timestamp = Math.floor(Date.parse(job.createdAt) / 1000)
|
|
return {
|
|
id: job.clientMessageId,
|
|
accountId: job.accountId,
|
|
chatId: job.jid,
|
|
senderId: job.jid,
|
|
fromMe: true,
|
|
timestamp,
|
|
createdAt: job.createdAt,
|
|
type: job.type || 'text',
|
|
contentType: job.type === 'text' || !job.type ? 'conversation' : 'queuedMedia',
|
|
text: job.text || job.caption || job.fileName || '',
|
|
caption: job.caption || '',
|
|
mimetype: job.mimetype || '',
|
|
fileName: job.fileName || '',
|
|
deliveryStatus: job.status === 'dead' ? 'failed' : 'sending',
|
|
queued: job.status !== 'sent',
|
|
}
|
|
}
|
|
|
|
function scheduleOutboundJob(jobId, delayMs = 0) {
|
|
const existing = outboundJobTimers.get(jobId)
|
|
if (existing) clearTimeout(existing)
|
|
const timer = setTimeout(() => {
|
|
outboundJobTimers.delete(jobId)
|
|
void processOutboundJob(jobId)
|
|
}, Math.max(0, delayMs))
|
|
timer.unref?.()
|
|
outboundJobTimers.set(jobId, timer)
|
|
}
|
|
|
|
function outboundThrottleDelay(job) {
|
|
const now = Date.now()
|
|
const sent = [...outboundJobs.values()]
|
|
.filter((item) => item.accountId === job.accountId && item.status === 'sent' && item.sentAt)
|
|
const accountWindow = sent
|
|
.map((item) => Date.parse(item.sentAt))
|
|
.filter((time) => time > now - 60_000)
|
|
.sort((a, b) => a - b)
|
|
if (accountWindow.length >= 30) return Math.max(250, accountWindow[0] + 60_000 - now)
|
|
const chatWindow = sent
|
|
.filter((item) => item.jid === job.jid)
|
|
.map((item) => Date.parse(item.sentAt))
|
|
.filter((time) => time > now - 10_000)
|
|
.sort((a, b) => a - b)
|
|
if (chatWindow.length >= 5) return Math.max(250, chatWindow[0] + 10_000 - now)
|
|
return 0
|
|
}
|
|
|
|
async function processOutboundJob(jobId) {
|
|
if (outboundJobsProcessing.has(jobId)) return outboundJobs.get(jobId)?.result
|
|
const job = outboundJobs.get(jobId)
|
|
if (!job || ['sent', 'dead'].includes(job.status)) return job?.result
|
|
const account = accounts.get(job.accountId)
|
|
const sock = sessions.get(job.accountId)?.sock
|
|
if (!account || !sock || account.status !== 'connected') {
|
|
job.status = 'queued'
|
|
job.nextAttemptAt = new Date(Date.now() + 5_000).toISOString()
|
|
job.updatedAt = new Date().toISOString()
|
|
await queueOutboundJobsSave()
|
|
scheduleOutboundJob(job.id, 5_000)
|
|
return queuedOutboundMessage(job)
|
|
}
|
|
const throttleDelay = outboundThrottleDelay(job)
|
|
if (throttleDelay > 0) {
|
|
job.status = 'queued'
|
|
job.throttled = true
|
|
job.nextAttemptAt = new Date(Date.now() + throttleDelay).toISOString()
|
|
job.updatedAt = new Date().toISOString()
|
|
await queueOutboundJobsSave()
|
|
scheduleOutboundJob(job.id, throttleDelay)
|
|
return queuedOutboundMessage(job)
|
|
}
|
|
outboundJobsProcessing.add(job.id)
|
|
job.status = 'sending'
|
|
job.attempts = Number(job.attempts || 0) + 1
|
|
job.updatedAt = new Date().toISOString()
|
|
void queueOutboundJobsSave()
|
|
try {
|
|
const store = await loadStore(job.accountId)
|
|
const quoted = job.quotedMessageId
|
|
? (store.messages.get(job.jid) || []).find((item) => item.id === job.quotedMessageId)?.raw
|
|
: undefined
|
|
let content = { text: job.text }
|
|
if (job.type && job.type !== 'text') {
|
|
const buffer = await fs.readFile(job.filePath)
|
|
if (job.type === 'sticker') content = { sticker: buffer, mimetype: job.mimetype }
|
|
else if (job.type === 'image') content = {
|
|
image: buffer,
|
|
caption: job.caption || '',
|
|
mimetype: job.mimetype,
|
|
}
|
|
else if (job.type === 'video') content = {
|
|
video: buffer,
|
|
caption: job.caption || '',
|
|
mimetype: job.mimetype,
|
|
}
|
|
else if (job.type === 'audio') content = {
|
|
audio: buffer,
|
|
mimetype: job.mimetype,
|
|
ptt: Boolean(job.ptt),
|
|
}
|
|
else content = {
|
|
document: buffer,
|
|
mimetype: job.mimetype,
|
|
fileName: job.fileName,
|
|
caption: job.caption || '',
|
|
}
|
|
}
|
|
const result = await sock.sendMessage(
|
|
job.jid,
|
|
content,
|
|
{
|
|
...(quoted ? { quoted } : {}),
|
|
messageId: job.clientMessageId,
|
|
},
|
|
)
|
|
if (!result) throw new Error('WhatsApp 未返回发送结果')
|
|
const message = upsertMessage(job.accountId, result)
|
|
job.status = 'sent'
|
|
job.result = { ...message, deliveryStatus: 'sent', queued: false }
|
|
job.sentAt = new Date().toISOString()
|
|
delete job.throttled
|
|
delete job.lastError
|
|
delete job.nextAttemptAt
|
|
if (job.filePath) {
|
|
await fs.rm(job.filePath, { force: true }).catch(() => undefined)
|
|
delete job.filePath
|
|
}
|
|
await deliverWebhook(account, 'message.sent', {
|
|
message: job.result,
|
|
idempotencyKey: job.idempotencyKey,
|
|
})
|
|
return job.result
|
|
} catch (error) {
|
|
job.lastError = error?.message || 'WhatsApp 发送失败'
|
|
if (job.attempts >= 8) {
|
|
job.status = 'dead'
|
|
job.deadAt = new Date().toISOString()
|
|
await deliverWebhook(account, 'message.updated', {
|
|
updates: [{
|
|
id: job.clientMessageId,
|
|
chatId: job.jid,
|
|
status: 'failed',
|
|
}],
|
|
})
|
|
} else {
|
|
const riskLimited = /(?:429|rate.?limit|too many|spam|flood|temporar(?:y|ily) unavailable)/i
|
|
.test(String(job.lastError))
|
|
const delay = riskLimited
|
|
? Math.min(30 * 60_000, 60_000 * (2 ** Math.max(0, job.attempts - 1)))
|
|
: Math.min(60_000, 1000 * (2 ** Math.max(0, job.attempts - 1)))
|
|
job.status = 'retrying'
|
|
job.riskBackoff = riskLimited
|
|
job.nextAttemptAt = new Date(Date.now() + delay).toISOString()
|
|
scheduleOutboundJob(job.id, delay)
|
|
}
|
|
return queuedOutboundMessage(job)
|
|
} finally {
|
|
job.updatedAt = new Date().toISOString()
|
|
outboundJobsProcessing.delete(job.id)
|
|
await queueOutboundJobsSave()
|
|
}
|
|
}
|
|
|
|
async function connectAccount(accountId) {
|
|
const account = accounts.get(accountId)
|
|
if (!account) throw Object.assign(new Error('账号不存在'), { status: 404 })
|
|
if (sessions.get(accountId)?.sock) return accountPublic(account)
|
|
if (reconnectTimers.has(accountId)) {
|
|
clearTimeout(reconnectTimers.get(accountId))
|
|
reconnectTimers.delete(accountId)
|
|
}
|
|
const store = await loadStore(accountId)
|
|
const authPath = path.join(authDir, accountId)
|
|
await fs.mkdir(authPath, { recursive: true, mode: 0o700 })
|
|
const { state, saveCreds } = await useMultiFileAuthState(authPath)
|
|
const logger = log.child({ accountId })
|
|
const proxyUrl = accountProxyUrl(account)
|
|
const proxyAgent = proxyUrl ? new HttpsProxyAgent(proxyUrl) : undefined
|
|
const fetchDispatcher = proxyUrl ? new UndiciProxyAgent(proxyUrl) : undefined
|
|
const { version } = await fetchLatestBaileysVersion(
|
|
fetchDispatcher ? { dispatcher: fetchDispatcher } : {},
|
|
)
|
|
const sock = makeWASocket({
|
|
version,
|
|
logger,
|
|
auth: {
|
|
creds: state.creds,
|
|
keys: makeCacheableSignalKeyStore(state.keys, logger),
|
|
},
|
|
browser: Browsers.ubuntu('Lingxi Crossborder'),
|
|
...(proxyAgent ? { agent: proxyAgent, fetchAgent: proxyAgent } : {}),
|
|
...(fetchDispatcher ? { options: { dispatcher: fetchDispatcher } } : {}),
|
|
syncFullHistory: true,
|
|
// Baileys 7 excludes FULL history notifications by default even when
|
|
// syncFullHistory is enabled. Accept every server-provided history phase.
|
|
shouldSyncHistoryMessage: () => true,
|
|
markOnlineOnConnect: false,
|
|
generateHighQualityLinkPreview: true,
|
|
getMessage: async (key) => {
|
|
const list = store.messages.get(String(key.remoteJid || '')) || []
|
|
return list.find((item) => item.id === key.id)?.raw?.message
|
|
},
|
|
cachedGroupMetadata: async (jid) => store.groups.get(jid),
|
|
})
|
|
sessions.set(accountId, { sock, connecting: true, fetchDispatcher })
|
|
account.status = state.creds.registered ? 'connecting' : 'waiting_pairing'
|
|
account.updatedAt = new Date().toISOString()
|
|
await saveAccounts()
|
|
|
|
sock.ev.on('creds.update', saveCreds)
|
|
sock.ev.on('connection.update', async (update) => {
|
|
if (update.qr) {
|
|
account.qrDataUrl = await QRCode.toDataURL(update.qr, { margin: 1, width: 320 })
|
|
account.status = 'waiting_pairing'
|
|
account.connectionState = 'pairing'
|
|
delete account.lastError
|
|
account.updatedAt = new Date().toISOString()
|
|
await saveAccounts()
|
|
}
|
|
if (update.connection === 'open') {
|
|
account.status = 'connected'
|
|
account.connectionState = 'ready'
|
|
account.lastConnectedAt = new Date().toISOString()
|
|
account.user = sock.user ? {
|
|
id: jidNormalizedUser(sock.user.id),
|
|
name: sock.user.name || account.name || account.phoneNumber,
|
|
} : account.user
|
|
delete account.lastError
|
|
delete account.qrDataUrl
|
|
delete account.pairingCode
|
|
const session = sessions.get(accountId)
|
|
if (session) session.connecting = false
|
|
await saveAccounts()
|
|
const processedIds = new Set(
|
|
(state.creds.processedHistoryMessages || []).map((item) => String(item?.key?.id || '')),
|
|
)
|
|
void replayStoredHistoryNotifications(
|
|
accountId,
|
|
account,
|
|
store,
|
|
logger,
|
|
processedIds,
|
|
fetchDispatcher ? { dispatcher: fetchDispatcher } : {},
|
|
)
|
|
.catch((error) => logger.warn({ err: error }, 'stored history recovery failed'))
|
|
for (const job of outboundJobs.values()) {
|
|
if (job.accountId === accountId && ['queued', 'retrying', 'sending'].includes(job.status)) {
|
|
scheduleOutboundJob(job.id)
|
|
}
|
|
}
|
|
void deliverWebhook(account, 'account.connected', { account: accountPublic(account) })
|
|
}
|
|
if (update.connection === 'close') {
|
|
if (sessions.get(accountId)?.sock !== sock) return
|
|
const statusCode = new Boom(update.lastDisconnect?.error).output?.statusCode
|
|
const loggedOut = statusCode === DisconnectReason.loggedOut
|
|
const session = sessions.get(accountId)
|
|
sessions.delete(accountId)
|
|
if (session?.fetchDispatcher) void session.fetchDispatcher.close().catch(() => undefined)
|
|
account.status = loggedOut ? 'logged_out' : 'disconnected'
|
|
account.connectionState = loggedOut ? 'logged_out' : 'reconnecting'
|
|
account.lastError = update.lastDisconnect?.error?.message || `连接关闭 (${statusCode || 'unknown'})`
|
|
log.warn({ accountId, statusCode, loggedOut, error: update.lastDisconnect?.error }, 'WhatsApp connection closed')
|
|
await saveAccounts()
|
|
void deliverWebhook(account, 'account.disconnected', {
|
|
loggedOut,
|
|
reason: account.lastError,
|
|
})
|
|
if (!loggedOut) {
|
|
const timer = setTimeout(() => {
|
|
reconnectTimers.delete(accountId)
|
|
void connectAccount(accountId).catch((error) => log.error({ error, accountId }, 'reconnect failed'))
|
|
}, 5000)
|
|
timer.unref?.()
|
|
reconnectTimers.set(accountId, timer)
|
|
}
|
|
}
|
|
})
|
|
sock.ev.on('messaging-history.set', ({
|
|
chats = [], contacts = [], messages = [], isLatest, progress, syncType, chunkOrder,
|
|
}) => {
|
|
for (const chat of chats) {
|
|
const id = String(chat.id || '')
|
|
if (id) store.chats.set(id, { ...(store.chats.get(id) || {}), ...jsonSafe(chat), id })
|
|
}
|
|
for (const contact of contacts) {
|
|
const id = String(contact.id || '')
|
|
if (id) store.contacts.set(id, { ...(store.contacts.get(id) || {}), ...jsonSafe(contact), id })
|
|
}
|
|
for (const item of messages) upsertMessage(accountId, item)
|
|
account.historySync = {
|
|
status: isLatest || progress === 100 ? 'processing' : 'syncing',
|
|
syncType: syncType ?? null,
|
|
progress: progress ?? null,
|
|
chunkOrder: chunkOrder ?? null,
|
|
chats: chats.length,
|
|
contacts: contacts.length,
|
|
messages: messages.length,
|
|
updatedAt: new Date().toISOString(),
|
|
}
|
|
void saveAccounts()
|
|
scheduleStoreSave(accountId)
|
|
void deliverWebhook(account, 'history.synced', {
|
|
chats: chats.length,
|
|
contacts: contacts.length,
|
|
messages: messages.length,
|
|
isLatest: Boolean(isLatest),
|
|
progress: progress ?? null,
|
|
syncType: syncType ?? null,
|
|
chunkOrder: chunkOrder ?? null,
|
|
})
|
|
})
|
|
sock.ev.on('messaging-history.status', ({ syncType, status, explicit }) => {
|
|
account.historySync = {
|
|
...(account.historySync || {}),
|
|
status,
|
|
syncType,
|
|
explicit: Boolean(explicit),
|
|
updatedAt: new Date().toISOString(),
|
|
...(status === 'complete' ? { completedAt: new Date().toISOString() } : {}),
|
|
}
|
|
void saveAccounts()
|
|
void deliverWebhook(account, 'history.status', account.historySync)
|
|
})
|
|
sock.ev.on('chats.upsert', (items) => {
|
|
for (const chat of items || []) {
|
|
const id = String(chat.id || '')
|
|
if (id) store.chats.set(id, { ...(store.chats.get(id) || {}), ...jsonSafe(chat), id })
|
|
}
|
|
scheduleStoreSave(accountId)
|
|
})
|
|
sock.ev.on('chats.update', (items) => {
|
|
for (const patch of items || []) {
|
|
const id = String(patch.id || '')
|
|
if (id) store.chats.set(id, { ...(store.chats.get(id) || {}), ...jsonSafe(patch), id })
|
|
}
|
|
scheduleStoreSave(accountId)
|
|
})
|
|
sock.ev.on('contacts.upsert', (items) => {
|
|
for (const contact of items || []) {
|
|
const id = String(contact.id || '')
|
|
if (id) store.contacts.set(id, { ...(store.contacts.get(id) || {}), ...jsonSafe(contact), id })
|
|
}
|
|
scheduleStoreSave(accountId)
|
|
void deliverWebhook(account, 'contacts.updated', { contacts: jsonSafe(items || []) })
|
|
})
|
|
sock.ev.on('contacts.update', (items) => {
|
|
for (const contact of items || []) {
|
|
const id = String(contact.id || '')
|
|
if (id) store.contacts.set(id, { ...(store.contacts.get(id) || {}), ...jsonSafe(contact), id })
|
|
}
|
|
scheduleStoreSave(accountId)
|
|
void deliverWebhook(account, 'contacts.updated', { contacts: jsonSafe(items || []) })
|
|
})
|
|
sock.ev.on('groups.upsert', (items) => {
|
|
for (const group of items || []) store.groups.set(group.id, jsonSafe(group))
|
|
scheduleStoreSave(accountId)
|
|
})
|
|
sock.ev.on('groups.update', async (items) => {
|
|
for (const group of items || []) {
|
|
if (!group.id) continue
|
|
const metadata = await sock.groupMetadata(group.id).catch(() => null)
|
|
if (metadata) store.groups.set(group.id, jsonSafe(metadata))
|
|
}
|
|
scheduleStoreSave(accountId)
|
|
})
|
|
sock.ev.on('messages.upsert', ({ messages = [], type }) => {
|
|
for (const raw of messages) {
|
|
const revokedKey = revokedMessageKey(raw)
|
|
if (revokedKey) {
|
|
const jid = String(revokedKey.remoteJid || raw.key?.remoteJid || '')
|
|
const list = store.messages.get(jid) || []
|
|
const index = list.findIndex((item) => item.id === String(revokedKey.id || ''))
|
|
if (index >= 0) {
|
|
list[index] = { ...list[index], deleted: true, deletedAt: new Date().toISOString() }
|
|
scheduleStoreSave(accountId)
|
|
}
|
|
void deliverWebhook(account, 'message.deleted', { keys: [jsonSafe(revokedKey)], all: false })
|
|
continue
|
|
}
|
|
const message = upsertMessage(accountId, raw)
|
|
if (!message) continue
|
|
const event = message.edited ? 'message.edited' : message.fromMe ? 'message.sent' : 'message.received'
|
|
void deliverWebhook(account, event, { message, upsertType: type })
|
|
}
|
|
})
|
|
sock.ev.on('messages.update', (updates) => {
|
|
const normalizedUpdates = []
|
|
for (const item of updates || []) {
|
|
const key = item?.key || {}
|
|
const revokedKey = revokedMessageKey({ key, message: item.update?.message })
|
|
if (revokedKey) {
|
|
const revokedJid = String(revokedKey.remoteJid || key.remoteJid || '')
|
|
const revokedId = String(revokedKey.id || '')
|
|
const revokedList = store.messages.get(revokedJid) || []
|
|
const revokedIndex = revokedList.findIndex((message) => message.id === revokedId)
|
|
if (revokedIndex >= 0) {
|
|
revokedList[revokedIndex] = {
|
|
...revokedList[revokedIndex],
|
|
deleted: true,
|
|
deletedAt: new Date().toISOString(),
|
|
}
|
|
}
|
|
void deliverWebhook(account, 'message.deleted', {
|
|
keys: [jsonSafe({ ...revokedKey, remoteJid: revokedJid })],
|
|
all: false,
|
|
})
|
|
continue
|
|
}
|
|
const jid = String(key.remoteJid || '')
|
|
const id = String(key.id || '')
|
|
if (!jid || !id) continue
|
|
const list = store.messages.get(jid) || []
|
|
const index = list.findIndex((message) => message.id === id)
|
|
const existing = index >= 0 ? list[index] : null
|
|
let message = existing
|
|
if (item.update?.message && existing?.raw) {
|
|
message = normalizedMessage(accountId, {
|
|
...existing.raw,
|
|
...item.update,
|
|
key,
|
|
message: item.update.message,
|
|
})
|
|
} else if (existing) {
|
|
message = {
|
|
...existing,
|
|
...(item.update?.status == null
|
|
? {}
|
|
: { deliveryStatus: messageDeliveryStatus(item.update.status, existing.fromMe) }),
|
|
raw: jsonSafe({ ...existing.raw, ...item.update, key }),
|
|
}
|
|
}
|
|
if (message && index >= 0) list[index] = message
|
|
normalizedUpdates.push({
|
|
id,
|
|
chatId: jid,
|
|
status: item.update?.status == null
|
|
? undefined
|
|
: messageDeliveryStatus(item.update.status, key.fromMe),
|
|
message,
|
|
update: jsonSafe(item.update || {}),
|
|
})
|
|
}
|
|
scheduleStoreSave(accountId)
|
|
void deliverWebhook(account, 'message.updated', { updates: normalizedUpdates })
|
|
})
|
|
sock.ev.on('messages.delete', (value) => {
|
|
const keys = Array.isArray(value?.keys) ? value.keys : []
|
|
for (const key of keys) {
|
|
const jid = String(key.remoteJid || '')
|
|
const list = store.messages.get(jid) || []
|
|
const index = list.findIndex((message) => message.id === String(key.id || ''))
|
|
if (index >= 0) list[index] = { ...list[index], deleted: true, deletedAt: new Date().toISOString() }
|
|
}
|
|
if (value?.all && value?.jid) store.messages.set(String(value.jid), [])
|
|
scheduleStoreSave(accountId)
|
|
void deliverWebhook(account, 'message.deleted', {
|
|
keys: jsonSafe(keys),
|
|
jid: value?.jid ? String(value.jid) : undefined,
|
|
all: Boolean(value?.all),
|
|
})
|
|
})
|
|
sock.ev.on('presence.update', (value) => {
|
|
void deliverWebhook(account, 'presence.updated', {
|
|
chatId: String(value?.id || ''),
|
|
presences: jsonSafe(value?.presences || {}),
|
|
})
|
|
})
|
|
return accountPublic(account)
|
|
}
|
|
|
|
const app = express()
|
|
app.set('trust proxy', 1)
|
|
app.use(express.json({ limit: '12mb' }))
|
|
app.use((req, res, next) => {
|
|
res.setHeader('x-request-id', req.get('x-request-id') || crypto.randomUUID())
|
|
next()
|
|
})
|
|
app.get('/health/live', (_req, res) => res.json({ status: 'ok', service: 'lingxi-whatsapp-gateway' }))
|
|
app.get('/health/ready', (_req, res) => res.json({
|
|
status: 'ready',
|
|
accounts: accounts.size,
|
|
connected: [...accounts.values()].filter((item) => item.status === 'connected').length,
|
|
}))
|
|
app.use((req, res, next) => {
|
|
const token = String(req.get('authorization') || '').replace(/^Bearer\s+/i, '')
|
|
|| String(req.get('x-gateway-token') || '')
|
|
if (token.length !== gatewayToken.length || !crypto.timingSafeEqual(Buffer.from(token), Buffer.from(gatewayToken))) {
|
|
return res.status(401).json({ code: 401, message: '网关令牌无效' })
|
|
}
|
|
next()
|
|
})
|
|
|
|
app.get('/metrics', async (_req, res) => {
|
|
const jobs = [...outboundJobs.values()]
|
|
const deliveries = [...webhookDeliveries.values()]
|
|
const sentLatencies = jobs
|
|
.filter((item) => item.sentAt && item.createdAt)
|
|
.map((item) => Math.max(0, Date.parse(item.sentAt) - Date.parse(item.createdAt)))
|
|
let chats = 0
|
|
let cachedMessages = 0
|
|
for (const store of stores.values()) {
|
|
chats += store.chats.size
|
|
for (const items of store.messages.values()) cachedMessages += items.length
|
|
}
|
|
const alerts = [
|
|
...[...accounts.values()]
|
|
.filter((item) => item.status !== 'connected')
|
|
.map((item) => ({
|
|
severity: 'warning',
|
|
code: 'account_disconnected',
|
|
accountId: item.id,
|
|
message: `WhatsApp 账号 ${item.name || item.phoneNumber} 未连接`,
|
|
})),
|
|
...jobs
|
|
.filter((item) => item.status === 'dead')
|
|
.slice(-20)
|
|
.map((item) => ({
|
|
severity: 'critical',
|
|
code: 'outbound_dead',
|
|
accountId: item.accountId,
|
|
jobId: item.id,
|
|
message: item.lastError || 'WhatsApp 消息发送任务进入死信',
|
|
})),
|
|
...deliveries
|
|
.filter((item) => item.status === 'dead')
|
|
.slice(-20)
|
|
.map((item) => ({
|
|
severity: 'critical',
|
|
code: 'webhook_dead',
|
|
accountId: item.accountId,
|
|
deliveryId: item.id,
|
|
message: item.lastError || 'WhatsApp Webhook 投递进入死信',
|
|
})),
|
|
]
|
|
res.json({
|
|
code: 0,
|
|
data: {
|
|
accounts: {
|
|
total: accounts.size,
|
|
connected: [...accounts.values()].filter((item) => item.status === 'connected').length,
|
|
disconnected: [...accounts.values()].filter((item) => item.status !== 'connected').length,
|
|
},
|
|
outbound: {
|
|
queued: jobs.filter((item) => ['queued', 'retrying', 'sending'].includes(item.status)).length,
|
|
dead: jobs.filter((item) => item.status === 'dead').length,
|
|
sent: jobs.filter((item) => item.status === 'sent').length,
|
|
riskBackoff: jobs.filter((item) => item.riskBackoff).length,
|
|
averageLatencyMs: sentLatencies.length
|
|
? Math.round(sentLatencies.reduce((sum, value) => sum + value, 0) / sentLatencies.length)
|
|
: 0,
|
|
},
|
|
webhooks: {
|
|
queued: deliveries.filter((item) => ['pending', 'retrying', 'delivering'].includes(item.status)).length,
|
|
dead: deliveries.filter((item) => item.status === 'dead').length,
|
|
delivered: deliveries.filter((item) => item.status === 'delivered').length,
|
|
},
|
|
cache: { chats, messages: cachedMessages },
|
|
alerts,
|
|
generatedAt: new Date().toISOString(),
|
|
},
|
|
})
|
|
})
|
|
app.get('/accounts', (_req, res) => res.json({ code: 0, data: [...accounts.values()].map(accountPublic) }))
|
|
app.post('/accounts', async (req, res) => {
|
|
const phoneNumber = String(req.body.phoneNumber || '').replace(/\D/g, '')
|
|
if (!/^\d{7,15}$/.test(phoneNumber)) return res.status(400).json({ code: 400, message: '请输入包含国家代码的 WhatsApp 手机号' })
|
|
const existing = [...accounts.values()].find((item) => item.phoneNumber === phoneNumber)
|
|
if (existing) return res.status(409).json({ code: 409, message: '该 WhatsApp 手机号已存在', data: accountPublic(existing) })
|
|
const account = {
|
|
id: crypto.randomUUID(),
|
|
name: String(req.body.name || `WhatsApp ${phoneNumber.slice(-4)}`).slice(0, 80),
|
|
phoneNumber,
|
|
ownerRef: String(req.body.ownerRef || '').slice(0, 120),
|
|
status: 'created',
|
|
connectionState: 'idle',
|
|
createdAt: new Date().toISOString(),
|
|
updatedAt: new Date().toISOString(),
|
|
}
|
|
accounts.set(account.id, account)
|
|
await saveAccounts()
|
|
await loadStore(account.id)
|
|
res.status(201).json({ code: 0, data: accountPublic(account) })
|
|
})
|
|
app.get('/accounts/:id', (req, res) => {
|
|
const account = accounts.get(req.params.id)
|
|
if (!account) return res.status(404).json({ code: 404, message: '账号不存在' })
|
|
res.json({ code: 0, data: accountPublic(account) })
|
|
})
|
|
app.patch('/accounts/:id/profile', async (req, res) => {
|
|
const account = accounts.get(req.params.id)
|
|
if (!account) return res.status(404).json({ code: 404, message: '账号不存在' })
|
|
const sock = accountSocket(account.id)
|
|
const name = req.body.name === undefined ? undefined : String(req.body.name || '').trim().slice(0, 80)
|
|
const status = req.body.status === undefined ? undefined : String(req.body.status || '').trim().slice(0, 139)
|
|
if (name !== undefined) {
|
|
if (!name) return res.status(400).json({ code: 400, message: '账号名称不能为空' })
|
|
await sock.updateProfileName(name)
|
|
account.name = name
|
|
if (account.user) account.user.name = name
|
|
}
|
|
if (status !== undefined) await sock.updateProfileStatus(status)
|
|
account.profileStatus = status === undefined ? account.profileStatus : status
|
|
account.profileUpdatedAt = new Date().toISOString()
|
|
await saveAccounts()
|
|
res.json({ code: 0, data: accountPublic(account) })
|
|
})
|
|
const profileUpload = multer({ storage: multer.memoryStorage(), limits: { fileSize: 8 * 1024 * 1024 } })
|
|
app.post('/accounts/:id/profile/picture', profileUpload.single('file'), async (req, res) => {
|
|
const account = accounts.get(req.params.id)
|
|
if (!account) return res.status(404).json({ code: 404, message: '账号不存在' })
|
|
if (!req.file?.mimetype?.startsWith('image/')) {
|
|
return res.status(400).json({ code: 400, message: '请选择有效图片' })
|
|
}
|
|
const sock = accountSocket(account.id)
|
|
if (!sock.user?.id) return res.status(409).json({ code: 409, message: '账号尚未连接' })
|
|
await sock.updateProfilePicture(jidNormalizedUser(sock.user.id), req.file.buffer)
|
|
account.profilePictureUpdatedAt = new Date().toISOString()
|
|
await saveAccounts()
|
|
res.json({ code: 0, data: accountPublic(account) })
|
|
})
|
|
app.post('/accounts/:id/start', async (req, res) => {
|
|
const account = accounts.get(req.params.id)
|
|
if (!account) return res.status(404).json({ code: 404, message: '账号不存在' })
|
|
await connectAccount(account.id)
|
|
res.json({ code: 0, data: accountPublic(account) })
|
|
})
|
|
app.post('/accounts/:id/pairing-code', async (req, res) => {
|
|
const account = accounts.get(req.params.id)
|
|
if (!account) return res.status(404).json({ code: 404, message: '账号不存在' })
|
|
const phoneNumber = String(req.body.phoneNumber || account.phoneNumber || '').replace(/\D/g, '')
|
|
if (!/^\d{7,15}$/.test(phoneNumber)) return res.status(400).json({ code: 400, message: '请输入包含国家代码的 WhatsApp 手机号' })
|
|
|
|
if (account.status === 'logged_out' || (account.lastError && !sessions.has(account.id))) {
|
|
await resetPairingSession(account.id)
|
|
}
|
|
|
|
let lastError
|
|
for (let attempt = 0; attempt < 2; attempt++) {
|
|
if (attempt > 0) await resetPairingSession(account.id)
|
|
await connectAccount(account.id)
|
|
const sock = accountSocket(account.id)
|
|
if (sock.authState.creds.registered) return res.status(409).json({ code: 409, message: '账号已经完成配对' })
|
|
try {
|
|
await waitForPairingReady(account.id, sock)
|
|
const code = await sock.requestPairingCode(phoneNumber)
|
|
account.pairingCode = code
|
|
account.status = 'waiting_pairing'
|
|
account.connectionState = 'pairing'
|
|
delete account.lastError
|
|
account.updatedAt = new Date().toISOString()
|
|
await saveAccounts()
|
|
log.info({ accountId: account.id, attempt: attempt + 1 }, 'WhatsApp pairing code generated')
|
|
return res.json({ code: 0, data: { pairingCode: code, account: accountPublic(account) } })
|
|
} catch (error) {
|
|
lastError = error
|
|
log.warn({ accountId: account.id, attempt: attempt + 1, error }, 'WhatsApp pairing attempt failed')
|
|
if (!isTransientPairingError(error) || attempt === 1) break
|
|
}
|
|
}
|
|
throw Object.assign(new Error('暂时无法连接 WhatsApp,请稍后重试'), {
|
|
status: 503,
|
|
cause: lastError,
|
|
})
|
|
})
|
|
app.get('/accounts/:id/auth/state', (req, res) => {
|
|
const account = accounts.get(req.params.id)
|
|
if (!account) return res.status(404).json({ code: 404, message: '账号不存在' })
|
|
res.json({ code: 0, data: accountPublic(account) })
|
|
})
|
|
app.get('/accounts/:id/devices', (req, res) => {
|
|
const account = accounts.get(req.params.id)
|
|
if (!account) return res.status(404).json({ code: 404, message: '账号不存在' })
|
|
res.json({
|
|
code: 0,
|
|
data: [{
|
|
id: 'gateway-current',
|
|
name: 'Lingxi Crossborder',
|
|
platform: 'WhatsApp linked device',
|
|
current: true,
|
|
status: account.status,
|
|
connectedAt: account.lastConnectedAt,
|
|
user: account.user,
|
|
}],
|
|
})
|
|
})
|
|
app.delete('/accounts/:id/devices/:deviceId', async (req, res) => {
|
|
const account = accounts.get(req.params.id)
|
|
if (!account) return res.status(404).json({ code: 404, message: '账号不存在' })
|
|
if (req.params.deviceId !== 'gateway-current') {
|
|
return res.status(404).json({ code: 404, message: '设备不存在' })
|
|
}
|
|
if (String(req.body.confirm || '') !== account.id) {
|
|
return res.status(400).json({ code: 400, message: '终止设备需要提交账号 ID 进行确认' })
|
|
}
|
|
const sock = sessions.get(account.id)?.sock
|
|
if (sock) await sock.logout()
|
|
account.status = 'logged_out'
|
|
account.connectionState = 'logged_out'
|
|
account.deviceTerminatedAt = new Date().toISOString()
|
|
await saveAccounts()
|
|
res.json({ code: 0, data: { success: true, deviceId: req.params.deviceId } })
|
|
})
|
|
app.put('/accounts/:id/webhook', async (req, res) => {
|
|
const account = accounts.get(req.params.id)
|
|
if (!account) return res.status(404).json({ code: 404, message: '账号不存在' })
|
|
const url = String(req.body.url || '').trim()
|
|
if (url && !/^https?:\/\//i.test(url)) return res.status(400).json({ code: 400, message: 'Webhook URL 无效' })
|
|
account.webhookUrl = url
|
|
const secret = String(req.body.secret || accountWebhookSecret(account))
|
|
account.webhookSecretEncrypted = encryptCredential(secret)
|
|
delete account.webhookSecret
|
|
account.webhookActive = req.body.active !== false
|
|
account.updatedAt = new Date().toISOString()
|
|
await saveAccounts()
|
|
res.json({ code: 0, data: accountPublic(account) })
|
|
})
|
|
app.put('/accounts/:id/proxy', async (req, res) => {
|
|
const account = accounts.get(req.params.id)
|
|
if (!account) return res.status(404).json({ code: 404, message: '账号不存在' })
|
|
const proxyUrl = String(req.body.url || '').trim()
|
|
if (proxyUrl && !/^https?:\/\//i.test(proxyUrl)) {
|
|
return res.status(400).json({ code: 400, message: '代理地址必须使用 http:// 或 https://' })
|
|
}
|
|
if (proxyUrl && req.body.test !== false) {
|
|
const agent = new HttpsProxyAgent(proxyUrl)
|
|
await new Promise((resolve, reject) => {
|
|
const request = https.get('https://web.whatsapp.com/', {
|
|
agent,
|
|
timeout: 12_000,
|
|
headers: { 'user-agent': 'Lingxi-WhatsApp-Gateway/1.0' },
|
|
}, (response) => {
|
|
response.resume()
|
|
if (Number(response.statusCode || 500) >= 500) {
|
|
reject(Object.assign(new Error(`代理测试 HTTP ${response.statusCode}`), { status: 502 }))
|
|
return
|
|
}
|
|
resolve()
|
|
})
|
|
request.on('timeout', () => request.destroy(new Error('代理连接超时')))
|
|
request.on('error', (error) => reject(Object.assign(new Error(`代理不可用:${error.message}`), { status: 502 })))
|
|
})
|
|
}
|
|
account.proxyUrlEncrypted = proxyUrl ? encryptCredential(proxyUrl) : ''
|
|
account.proxyUpdatedAt = new Date().toISOString()
|
|
account.updatedAt = account.proxyUpdatedAt
|
|
await saveAccounts()
|
|
|
|
const session = sessions.get(account.id)
|
|
if (session?.sock) {
|
|
sessions.delete(account.id)
|
|
session.sock.end(new Error('Proxy configuration changed'))
|
|
account.status = 'disconnected'
|
|
account.connectionState = 'reconnecting'
|
|
await connectAccount(account.id)
|
|
}
|
|
res.json({ code: 0, data: accountPublic(account) })
|
|
})
|
|
app.post('/accounts/:id/reauthorize', async (req, res) => {
|
|
const account = accounts.get(req.params.id)
|
|
if (!account) return res.status(404).json({ code: 404, message: '账号不存在' })
|
|
if (String(req.body.confirm || '') !== account.id) {
|
|
return res.status(400).json({ code: 400, message: '重新授权需要提交账号 ID 进行确认' })
|
|
}
|
|
await resetPairingSession(account.id)
|
|
await connectAccount(account.id)
|
|
res.json({ code: 0, data: accountPublic(account) })
|
|
})
|
|
app.get('/accounts/:id/backup', async (req, res) => {
|
|
const account = accounts.get(req.params.id)
|
|
if (!account) return res.status(404).json({ code: 404, message: '账号不存在' })
|
|
const store = await loadStore(account.id)
|
|
const accountJobs = [...outboundJobs.values()].filter((item) => item.accountId === account.id)
|
|
const outboundMedia = {}
|
|
for (const job of accountJobs) {
|
|
if (!job.filePath) continue
|
|
const bytes = await fs.readFile(job.filePath).catch(() => null)
|
|
if (bytes) outboundMedia[job.id] = bytes.toString('base64')
|
|
}
|
|
const snapshot = {
|
|
version: 1,
|
|
accountId: account.id,
|
|
createdAt: new Date().toISOString(),
|
|
account: {
|
|
name: account.name,
|
|
phoneNumber: account.phoneNumber,
|
|
ownerRef: account.ownerRef,
|
|
webhookUrl: account.webhookUrl,
|
|
webhookSecretEncrypted: account.webhookSecretEncrypted,
|
|
webhookActive: account.webhookActive,
|
|
proxyUrlEncrypted: account.proxyUrlEncrypted,
|
|
},
|
|
authFiles: await readAuthFiles(account.id),
|
|
store: {
|
|
chats: [...store.chats.values()],
|
|
contacts: [...store.contacts.values()],
|
|
messages: [...store.messages.entries()],
|
|
groups: [...store.groups.values()],
|
|
},
|
|
outboundJobs: accountJobs,
|
|
outboundMedia,
|
|
webhookDeliveries: [...webhookDeliveries.values()].filter((item) => item.accountId === account.id),
|
|
}
|
|
res.json({
|
|
code: 0,
|
|
data: {
|
|
version: snapshot.version,
|
|
accountId: account.id,
|
|
createdAt: snapshot.createdAt,
|
|
blob: encryptCredential(JSON.stringify(snapshot, BufferJSON.replacer)),
|
|
},
|
|
})
|
|
})
|
|
app.post('/accounts/:id/backup/restore', async (req, res) => {
|
|
const account = accounts.get(req.params.id)
|
|
if (!account) return res.status(404).json({ code: 404, message: '账号不存在' })
|
|
if (String(req.body.confirm || '') !== account.id) {
|
|
return res.status(400).json({ code: 400, message: '恢复备份需要提交账号 ID 进行确认' })
|
|
}
|
|
let snapshot
|
|
try {
|
|
snapshot = JSON.parse(decryptCredential(String(req.body.blob || '')), BufferJSON.reviver)
|
|
} catch {
|
|
return res.status(400).json({ code: 400, message: '备份文件无效或加密密钥不匹配' })
|
|
}
|
|
if (snapshot?.version !== 1 || snapshot?.accountId !== account.id || !snapshot?.authFiles || !snapshot?.store) {
|
|
return res.status(400).json({ code: 400, message: '备份内容与当前账号不匹配' })
|
|
}
|
|
|
|
const session = sessions.get(account.id)
|
|
sessions.delete(account.id)
|
|
if (session?.sock) session.sock.end(new Error('Backup restore'))
|
|
const directory = path.join(authDir, account.id)
|
|
await fs.rm(directory, { recursive: true, force: true })
|
|
await fs.mkdir(directory, { recursive: true, mode: 0o700 })
|
|
for (const [name, content] of Object.entries(snapshot.authFiles)) {
|
|
if (!/^[a-zA-Z0-9_.-]+\.json$/.test(name)) continue
|
|
await fs.writeFile(path.join(directory, name), String(content), { mode: 0o600 })
|
|
}
|
|
await atomicJson(path.join(storeDir, `${account.id}.json`), snapshot.store)
|
|
stores.delete(account.id)
|
|
await loadStore(account.id)
|
|
for (const [key, value] of Object.entries(snapshot.account || {})) {
|
|
if (value !== undefined) account[key] = value
|
|
}
|
|
account.status = 'disconnected'
|
|
account.connectionState = 'reconnecting'
|
|
account.restoredAt = new Date().toISOString()
|
|
for (const [id, job] of outboundJobs.entries()) {
|
|
if (job.accountId === account.id) outboundJobs.delete(id)
|
|
}
|
|
for (const job of snapshot.outboundJobs || []) outboundJobs.set(job.id, job)
|
|
for (const job of snapshot.outboundJobs || []) {
|
|
const base64 = snapshot.outboundMedia?.[job.id]
|
|
if (!base64) continue
|
|
job.filePath = path.join(outboundMediaDir, `${job.id}.bin`)
|
|
await fs.writeFile(job.filePath, Buffer.from(base64, 'base64'), { mode: 0o600 })
|
|
}
|
|
for (const [id, delivery] of webhookDeliveries.entries()) {
|
|
if (delivery.accountId === account.id) webhookDeliveries.delete(id)
|
|
}
|
|
for (const delivery of snapshot.webhookDeliveries || []) webhookDeliveries.set(delivery.id, delivery)
|
|
await Promise.all([saveAccounts(), queueOutboundJobsSave(), queueWebhookDeliveriesSave()])
|
|
await connectAccount(account.id)
|
|
res.json({ code: 0, data: { restored: true, account: accountPublic(account) } })
|
|
})
|
|
app.get('/accounts/:id/webhook-deliveries', async (req, res) => {
|
|
if (!accounts.has(req.params.id)) return res.status(404).json({ code: 404, message: '账号不存在' })
|
|
const status = String(req.query.status || '')
|
|
const limit = Math.max(1, Math.min(200, Number(req.query.limit || 50)))
|
|
const list = [...webhookDeliveries.values()]
|
|
.filter((item) => item.accountId === req.params.id && (!status || item.status === status))
|
|
.sort((a, b) => Date.parse(b.createdAt || '') - Date.parse(a.createdAt || ''))
|
|
.slice(0, limit)
|
|
.map(({ payload: _payload, ...item }) => item)
|
|
res.json({ code: 0, data: list })
|
|
})
|
|
app.post('/accounts/:id/webhook-deliveries/:deliveryId/replay', async (req, res) => {
|
|
if (!accounts.has(req.params.id)) return res.status(404).json({ code: 404, message: '账号不存在' })
|
|
const delivery = webhookDeliveries.get(req.params.deliveryId)
|
|
if (!delivery || delivery.accountId !== req.params.id) {
|
|
return res.status(404).json({ code: 404, message: 'Webhook 投递记录不存在' })
|
|
}
|
|
delivery.status = 'pending'
|
|
delivery.attempts = 0
|
|
delivery.replayedAt = new Date().toISOString()
|
|
delivery.updatedAt = delivery.replayedAt
|
|
delete delivery.deadAt
|
|
delete delivery.deliveredAt
|
|
delete delivery.lastError
|
|
delete delivery.nextAttemptAt
|
|
await saveWebhookDeliveries()
|
|
scheduleWebhookDelivery(delivery.id)
|
|
const { payload: _payload, ...safe } = delivery
|
|
res.json({ code: 0, data: safe })
|
|
})
|
|
app.post('/accounts/:id/logout', async (req, res) => {
|
|
const account = accounts.get(req.params.id)
|
|
if (!account) return res.status(404).json({ code: 404, message: '账号不存在' })
|
|
const sock = sessions.get(account.id)?.sock
|
|
if (sock) await sock.logout()
|
|
account.status = 'logged_out'
|
|
account.connectionState = 'logged_out'
|
|
await saveAccounts()
|
|
res.json({ code: 0, data: accountPublic(account) })
|
|
})
|
|
app.delete('/accounts/:id', async (req, res) => {
|
|
const account = accounts.get(req.params.id)
|
|
if (!account) return res.status(404).json({ code: 404, message: '账号不存在' })
|
|
const sock = sessions.get(account.id)?.sock
|
|
if (sock) await sock.end(new Error('account removed'))
|
|
sessions.delete(account.id)
|
|
accounts.delete(account.id)
|
|
stores.delete(account.id)
|
|
await Promise.all([
|
|
fs.rm(path.join(authDir, account.id), { recursive: true, force: true }),
|
|
fs.rm(path.join(storeDir, `${account.id}.json`), { force: true }),
|
|
saveAccounts(),
|
|
])
|
|
res.json({ code: 0, data: { success: true } })
|
|
})
|
|
|
|
app.get('/accounts/:id/chats', async (req, res) => {
|
|
const store = await loadStore(req.params.id)
|
|
const list = [...store.chats.values()]
|
|
.filter((item) => item.id && !String(item.id).endsWith('@broadcast'))
|
|
.sort((a, b) => Number(b.timestamp || 0) - Number(a.timestamp || 0))
|
|
res.json({ code: 0, data: list })
|
|
})
|
|
app.get('/accounts/:id/chats/:jid/history', async (req, res) => {
|
|
const store = await loadStore(req.params.id)
|
|
const limit = Math.max(1, Math.min(2000, Number(req.query.limit || 100)))
|
|
const list = store.messages.get(req.params.jid) || []
|
|
const before = String(req.query.before || req.query.cursor || '')
|
|
const after = String(req.query.after || '')
|
|
let start = 0
|
|
let end = list.length
|
|
if (before) {
|
|
const index = list.findIndex((item) => item.id === before)
|
|
if (index >= 0) end = index
|
|
}
|
|
if (after) {
|
|
const index = list.findIndex((item) => item.id === after)
|
|
if (index >= 0) start = index + 1
|
|
}
|
|
const page = after
|
|
? list.slice(start, start + limit)
|
|
: list.slice(Math.max(start, end - limit), end)
|
|
const firstIndex = page.length ? list.findIndex((item) => item.id === page[0].id) : -1
|
|
const lastIndex = page.length ? list.findIndex((item) => item.id === page.at(-1).id) : -1
|
|
res.json({
|
|
code: 0,
|
|
data: {
|
|
messages: page,
|
|
beforeCursor: firstIndex > 0 ? page[0].id : null,
|
|
afterCursor: lastIndex >= 0 && lastIndex < list.length - 1 ? page.at(-1).id : null,
|
|
hasMoreBefore: firstIndex > 0,
|
|
hasMoreAfter: lastIndex >= 0 && lastIndex < list.length - 1,
|
|
totalCached: list.length,
|
|
},
|
|
})
|
|
})
|
|
app.post('/accounts/:id/chats/:jid/history/sync', async (req, res) => {
|
|
const sock = accountSocket(req.params.id)
|
|
const store = await loadStore(req.params.id)
|
|
const jid = req.params.jid
|
|
const pages = Math.max(1, Math.min(50, Number(req.body.pages || 2)))
|
|
const count = Math.max(1, Math.min(50, Number(req.body.count || 50)))
|
|
const initialList = store.messages.get(jid) || []
|
|
const initialCount = initialList.length
|
|
let requestedPages = 0
|
|
|
|
for (let page = 0; page < pages; page++) {
|
|
const before = store.messages.get(jid) || []
|
|
const oldest = before[0]
|
|
if (!oldest?.raw?.key || !oldest.timestamp) break
|
|
const beforeCount = before.length
|
|
const beforeOldestId = oldest.id
|
|
await sock.fetchMessageHistory(count, oldest.raw.key, oldest.timestamp)
|
|
requestedPages++
|
|
|
|
let changed = false
|
|
for (let attempt = 0; attempt < 20; attempt++) {
|
|
await new Promise((resolve) => setTimeout(resolve, 400))
|
|
const current = store.messages.get(jid) || []
|
|
if (current.length > beforeCount || current[0]?.id !== beforeOldestId) {
|
|
changed = true
|
|
break
|
|
}
|
|
}
|
|
if (!changed) break
|
|
}
|
|
|
|
const currentCount = (store.messages.get(jid) || []).length
|
|
res.json({
|
|
code: 0,
|
|
data: {
|
|
requestedPages,
|
|
messages: currentCount,
|
|
added: Math.max(0, currentCount - initialCount),
|
|
},
|
|
})
|
|
})
|
|
app.get('/accounts/:id/chats/:jid/identity', async (req, res) => {
|
|
const sock = accountSocket(req.params.id)
|
|
const store = await loadStore(req.params.id)
|
|
const jid = req.params.jid
|
|
if (!jid.endsWith('@g.us') && !jid.endsWith('@newsletter')) {
|
|
void sock.presenceSubscribe(jid).catch(() => undefined)
|
|
}
|
|
let phoneJid = jid
|
|
if (jid.endsWith('@lid')) {
|
|
try {
|
|
phoneJid = await sock.signalRepository?.lidMapping?.getPNForLID?.(jid) || jid
|
|
} catch {
|
|
phoneJid = jid
|
|
}
|
|
}
|
|
phoneJid = jidNormalizedUser(phoneJid)
|
|
const contact = store.contacts.get(jid) || store.contacts.get(phoneJid) || {}
|
|
const group = jid.endsWith('@g.us')
|
|
? await sock.groupMetadata(jid).catch(() => store.groups.get(jid))
|
|
: null
|
|
const includeProfile = req.query.includeProfile !== 'false'
|
|
const avatarUrl = includeProfile
|
|
? await sock.profilePictureUrl(jid, 'image')
|
|
.catch(() => phoneJid !== jid ? sock.profilePictureUrl(phoneJid, 'image').catch(() => '') : '')
|
|
: ''
|
|
res.json({
|
|
code: 0,
|
|
data: {
|
|
id: jid,
|
|
canonicalJid: phoneJid.endsWith('@s.whatsapp.net') ? phoneJid : jid,
|
|
name: group?.subject || contact.name || contact.notify || contact.verifiedName || jid.split('@')[0],
|
|
phone: phoneJid.endsWith('@s.whatsapp.net') ? phoneJid.split('@')[0] : '',
|
|
avatarUrl,
|
|
chatType: group ? 'group' : jid.endsWith('@newsletter') ? 'channel' : 'private',
|
|
participants: group?.participants || [],
|
|
description: group?.desc || '',
|
|
},
|
|
})
|
|
})
|
|
app.post('/accounts/:id/contacts/check', async (req, res) => {
|
|
const sock = accountSocket(req.params.id)
|
|
const numbers = (Array.isArray(req.body.numbers) ? req.body.numbers : [req.body.phoneNumber])
|
|
.map((item) => String(item || '').replace(/\D/g, ''))
|
|
.filter(Boolean)
|
|
const result = await sock.onWhatsApp(...numbers)
|
|
res.json({ code: 0, data: result })
|
|
})
|
|
app.get('/accounts/:id/contacts', async (req, res) => {
|
|
const store = await loadStore(req.params.id)
|
|
const query = String(req.query.query || '').trim().toLowerCase()
|
|
const cursor = Math.max(0, Number(req.query.cursor || 0))
|
|
const limit = Math.max(1, Math.min(200, Number(req.query.limit || 50)))
|
|
const contacts = [...store.contacts.values()]
|
|
.map((contact) => ({
|
|
...jsonSafe(contact),
|
|
id: String(contact.id || ''),
|
|
name: contact.name || contact.notify || contact.verifiedName || '',
|
|
phone: String(contact.id || '').endsWith('@s.whatsapp.net')
|
|
? String(contact.id).split('@')[0]
|
|
: '',
|
|
}))
|
|
.filter((contact) => !query || [
|
|
contact.id,
|
|
contact.name,
|
|
contact.phone,
|
|
contact.verifiedName,
|
|
contact.notify,
|
|
].some((value) => String(value || '').toLowerCase().includes(query)))
|
|
.sort((a, b) => String(a.name || a.id).localeCompare(String(b.name || b.id)))
|
|
const items = contacts.slice(cursor, cursor + limit)
|
|
res.json({
|
|
code: 0,
|
|
data: {
|
|
items,
|
|
nextCursor: cursor + items.length < contacts.length ? String(cursor + items.length) : null,
|
|
total: contacts.length,
|
|
},
|
|
})
|
|
})
|
|
|
|
app.post('/accounts/:id/messages/send', async (req, res) => {
|
|
const account = accounts.get(req.params.id)
|
|
if (!account) return res.status(404).json({ code: 404, message: '账号不存在' })
|
|
const jid = String(req.body.jid || '')
|
|
const text = String(req.body.text || '')
|
|
if (!jid || !text.trim()) return res.status(400).json({ code: 400, message: 'jid 和消息正文必填' })
|
|
const idempotencyKey = String(
|
|
req.body.idempotencyKey
|
|
|| req.get('idempotency-key')
|
|
|| crypto.randomUUID(),
|
|
).slice(0, 160)
|
|
const existing = [...outboundJobs.values()].find((item) =>
|
|
item.accountId === account.id && item.idempotencyKey === idempotencyKey,
|
|
)
|
|
if (existing) {
|
|
res.json({ code: 0, data: existing.result || queuedOutboundMessage(existing) })
|
|
return
|
|
}
|
|
const job = {
|
|
id: crypto.randomUUID(),
|
|
idempotencyKey,
|
|
clientMessageId: String(req.body.clientMessageId || crypto.randomBytes(10).toString('hex').toUpperCase()),
|
|
accountId: account.id,
|
|
jid,
|
|
text,
|
|
quotedMessageId: req.body.quotedMessageId ? String(req.body.quotedMessageId) : undefined,
|
|
status: 'queued',
|
|
attempts: 0,
|
|
createdAt: new Date().toISOString(),
|
|
updatedAt: new Date().toISOString(),
|
|
}
|
|
outboundJobs.set(job.id, job)
|
|
await queueOutboundJobsSave()
|
|
const result = await processOutboundJob(job.id)
|
|
res.json({ code: 0, data: result || queuedOutboundMessage(job) })
|
|
})
|
|
app.post('/accounts/:id/messages/action', async (req, res) => {
|
|
const sock = accountSocket(req.params.id)
|
|
const jid = String(req.body.jid || '')
|
|
const action = String(req.body.action || '')
|
|
if (!jid || !action) return res.status(400).json({ code: 400, message: 'jid 和 action 必填' })
|
|
|
|
const targetId = String(req.body.messageId || '')
|
|
const target = targetId ? storedMessage(req.params.id, jid, targetId) : null
|
|
const targetKey = target?.raw?.key
|
|
let result
|
|
if (action === 'react') {
|
|
if (!targetKey) return res.status(404).json({ code: 404, message: '目标消息不存在' })
|
|
result = await sock.sendMessage(jid, {
|
|
react: { text: String(req.body.emoji || ''), key: targetKey },
|
|
})
|
|
} else if (action === 'edit') {
|
|
if (!targetKey) return res.status(404).json({ code: 404, message: '目标消息不存在' })
|
|
const text = String(req.body.text || '').trim()
|
|
if (!text) return res.status(400).json({ code: 400, message: '编辑后的消息不能为空' })
|
|
result = await sock.sendMessage(jid, { text, edit: targetKey })
|
|
} else if (action === 'delete') {
|
|
if (!targetKey) return res.status(404).json({ code: 404, message: '目标消息不存在' })
|
|
result = await sock.sendMessage(jid, { delete: targetKey })
|
|
} else if (action === 'forward') {
|
|
if (!target?.raw) return res.status(404).json({ code: 404, message: '目标消息不存在' })
|
|
const destinationJid = String(req.body.destinationJid || jid)
|
|
result = await sock.sendMessage(destinationJid, { forward: target.raw, force: true })
|
|
} else if (action === 'poll') {
|
|
const name = String(req.body.name || '').trim()
|
|
const values = (Array.isArray(req.body.values) ? req.body.values : [])
|
|
.map((item) => String(item || '').trim())
|
|
.filter(Boolean)
|
|
if (!name || values.length < 2) {
|
|
return res.status(400).json({ code: 400, message: '投票标题和至少两个选项必填' })
|
|
}
|
|
result = await sock.sendMessage(jid, {
|
|
poll: {
|
|
name,
|
|
values,
|
|
selectableCount: Math.max(1, Math.min(values.length, Number(req.body.selectableCount || 1))),
|
|
},
|
|
})
|
|
} else if (action === 'location') {
|
|
const latitude = Number(req.body.latitude)
|
|
const longitude = Number(req.body.longitude)
|
|
if (!Number.isFinite(latitude) || !Number.isFinite(longitude)) {
|
|
return res.status(400).json({ code: 400, message: '经纬度格式不正确' })
|
|
}
|
|
result = await sock.sendMessage(jid, {
|
|
location: {
|
|
degreesLatitude: latitude,
|
|
degreesLongitude: longitude,
|
|
name: String(req.body.name || ''),
|
|
address: String(req.body.address || ''),
|
|
},
|
|
})
|
|
} else if (action === 'contact') {
|
|
const displayName = String(req.body.displayName || '').trim()
|
|
const phone = String(req.body.phone || '').replace(/[^\d+]/g, '')
|
|
if (!displayName || !phone) {
|
|
return res.status(400).json({ code: 400, message: '联系人姓名和手机号必填' })
|
|
}
|
|
const digits = phone.replace(/\D/g, '')
|
|
const vcard = [
|
|
'BEGIN:VCARD',
|
|
'VERSION:3.0',
|
|
`FN:${displayName.replace(/[\r\n:;]/g, ' ')}`,
|
|
`TEL;type=CELL;type=VOICE;waid=${digits}:${phone.startsWith('+') ? phone : `+${phone}`}`,
|
|
'END:VCARD',
|
|
].join('\n')
|
|
result = await sock.sendMessage(jid, {
|
|
contacts: { displayName, contacts: [{ displayName, vcard }] },
|
|
})
|
|
} else if (action === 'disappearing') {
|
|
const seconds = req.body.seconds === false ? false : Number(req.body.seconds || 0)
|
|
const allowed = [false, 0, 86400, 604800, 7776000]
|
|
if (!allowed.includes(seconds)) {
|
|
return res.status(400).json({ code: 400, message: '临时消息仅支持关闭、24 小时、7 天或 90 天' })
|
|
}
|
|
result = await sock.sendMessage(jid, { disappearingMessagesInChat: seconds || false })
|
|
} else {
|
|
return res.status(400).json({ code: 400, message: '不支持的消息操作' })
|
|
}
|
|
|
|
const message = result?.message ? upsertMessage(req.params.id, result) : null
|
|
res.json({ code: 0, data: message || jsonSafe(result || { success: true }) })
|
|
})
|
|
app.get('/accounts/:id/outbound-jobs', async (req, res) => {
|
|
if (!accounts.has(req.params.id)) return res.status(404).json({ code: 404, message: '账号不存在' })
|
|
const status = String(req.query.status || '')
|
|
const limit = Math.max(1, Math.min(200, Number(req.query.limit || 50)))
|
|
const list = [...outboundJobs.values()]
|
|
.filter((item) => item.accountId === req.params.id && (!status || item.status === status))
|
|
.sort((a, b) => Date.parse(b.createdAt || '') - Date.parse(a.createdAt || ''))
|
|
.slice(0, limit)
|
|
.map(({ text: _text, caption: _caption, result: _result, filePath: _filePath, ...item }) => item)
|
|
res.json({ code: 0, data: list })
|
|
})
|
|
app.post('/accounts/:id/outbound-jobs/:jobId/retry', async (req, res) => {
|
|
const job = outboundJobs.get(req.params.jobId)
|
|
if (!job || job.accountId !== req.params.id) {
|
|
return res.status(404).json({ code: 404, message: '发送任务不存在' })
|
|
}
|
|
job.status = 'queued'
|
|
job.attempts = 0
|
|
job.updatedAt = new Date().toISOString()
|
|
delete job.deadAt
|
|
delete job.lastError
|
|
delete job.nextAttemptAt
|
|
await queueOutboundJobsSave()
|
|
const result = await processOutboundJob(job.id)
|
|
res.json({ code: 0, data: result || queuedOutboundMessage(job) })
|
|
})
|
|
|
|
const upload = multer({ storage: multer.memoryStorage(), limits: { fileSize: 50 * 1024 * 1024, files: 10 } })
|
|
app.post('/accounts/:id/messages/media', upload.single('file'), async (req, res) => {
|
|
const account = accounts.get(req.params.id)
|
|
if (!account) return res.status(404).json({ code: 404, message: '账号不存在' })
|
|
const jid = String(req.body.jid || '')
|
|
if (!jid || !req.file) return res.status(400).json({ code: 400, message: 'jid 和文件必填' })
|
|
const mime = req.file.mimetype || 'application/octet-stream'
|
|
const caption = String(req.body.caption || '')
|
|
const kind = String(req.body.kind || '')
|
|
if (kind === 'sticker') {
|
|
if (mime !== 'image/webp') {
|
|
return res.status(415).json({ code: 415, message: 'WhatsApp 贴纸必须为 WebP 格式' })
|
|
}
|
|
}
|
|
const idempotencyKey = String(
|
|
req.body.idempotencyKey
|
|
|| req.get('idempotency-key')
|
|
|| crypto.randomUUID(),
|
|
).slice(0, 160)
|
|
const existing = [...outboundJobs.values()].find((item) =>
|
|
item.accountId === account.id && item.idempotencyKey === idempotencyKey,
|
|
)
|
|
if (existing) {
|
|
res.json({ code: 0, data: existing.result || queuedOutboundMessage(existing) })
|
|
return
|
|
}
|
|
const type = kind === 'sticker'
|
|
? 'sticker'
|
|
: mime.startsWith('image/')
|
|
? 'image'
|
|
: mime.startsWith('video/')
|
|
? 'video'
|
|
: mime.startsWith('audio/')
|
|
? 'audio'
|
|
: 'file'
|
|
const job = {
|
|
id: crypto.randomUUID(),
|
|
idempotencyKey,
|
|
clientMessageId: String(req.body.clientMessageId || crypto.randomBytes(10).toString('hex').toUpperCase()),
|
|
accountId: account.id,
|
|
jid,
|
|
type,
|
|
mimetype: mime,
|
|
fileName: req.file.originalname,
|
|
caption,
|
|
ptt: req.body.ptt === 'true',
|
|
status: 'queued',
|
|
attempts: 0,
|
|
createdAt: new Date().toISOString(),
|
|
updatedAt: new Date().toISOString(),
|
|
}
|
|
job.filePath = path.join(outboundMediaDir, `${job.id}.bin`)
|
|
await fs.writeFile(job.filePath, req.file.buffer, { mode: 0o600 })
|
|
outboundJobs.set(job.id, job)
|
|
await queueOutboundJobsSave()
|
|
const result = await processOutboundJob(job.id)
|
|
res.json({ code: 0, data: result || queuedOutboundMessage(job) })
|
|
})
|
|
app.post('/accounts/:id/messages/album', upload.array('files', 10), async (req, res) => {
|
|
const sock = accountSocket(req.params.id)
|
|
const jid = String(req.body.jid || '')
|
|
const files = Array.isArray(req.files) ? req.files : []
|
|
if (!jid || files.length < 2) {
|
|
return res.status(400).json({ code: 400, message: '相册至少需要两张图片或视频' })
|
|
}
|
|
const unsupported = files.find((file) =>
|
|
!file.mimetype.startsWith('image/') && !file.mimetype.startsWith('video/'),
|
|
)
|
|
if (unsupported) {
|
|
return res.status(415).json({ code: 415, message: 'WhatsApp 相册只支持图片和视频' })
|
|
}
|
|
const imageCount = files.filter((file) => file.mimetype.startsWith('image/')).length
|
|
const videoCount = files.length - imageCount
|
|
const parent = await sock.sendMessage(jid, {
|
|
album: {
|
|
expectedImageCount: imageCount || undefined,
|
|
expectedVideoCount: videoCount || undefined,
|
|
},
|
|
})
|
|
const sent = []
|
|
for (const [index, file] of files.entries()) {
|
|
const caption = index === 0 ? String(req.body.caption || '') : ''
|
|
const content = file.mimetype.startsWith('image/')
|
|
? { image: file.buffer, caption, mimetype: file.mimetype, albumParentKey: parent.key }
|
|
: { video: file.buffer, caption, mimetype: file.mimetype, albumParentKey: parent.key }
|
|
const result = await sock.sendMessage(jid, content)
|
|
const normalized = upsertMessage(req.params.id, result)
|
|
if (normalized) sent.push(normalized)
|
|
}
|
|
res.json({ code: 0, data: { parent: jsonSafe(parent.key), messages: sent } })
|
|
})
|
|
app.get('/accounts/:id/messages/:messageId/media', async (req, res) => {
|
|
const store = await loadStore(req.params.id)
|
|
const message = [...store.messages.values()].flat().find((item) => item.id === req.params.messageId)
|
|
if (!message?.raw) return res.status(404).json({ code: 404, message: '媒体消息不存在' })
|
|
if (!['image', 'audio', 'video', 'file', 'sticker'].includes(message.type)) {
|
|
return res.status(404).json({ code: 404, message: '该消息不是媒体消息' })
|
|
}
|
|
const cacheKey = crypto.createHash('sha256')
|
|
.update(`${req.params.id}:${req.params.messageId}`)
|
|
.digest('hex')
|
|
const cacheFile = path.join(mediaCacheDir, `${cacheKey}.bin`)
|
|
const cacheMetaFile = path.join(mediaCacheDir, `${cacheKey}.json`)
|
|
const cacheMeta = await readJson(cacheMetaFile, null)
|
|
let buffer
|
|
if (cacheMeta?.expiresAt && Date.parse(cacheMeta.expiresAt) > Date.now()) {
|
|
buffer = await fs.readFile(cacheFile).catch(() => null)
|
|
}
|
|
if (!buffer) {
|
|
buffer = await downloadMediaMessage(message.raw, 'buffer', {}, {
|
|
logger: log,
|
|
reuploadRequest: accountSocket(req.params.id).updateMediaMessage,
|
|
})
|
|
await fs.writeFile(cacheFile, buffer, { mode: 0o600 })
|
|
await atomicJson(cacheMetaFile, {
|
|
accountId: req.params.id,
|
|
messageId: req.params.messageId,
|
|
size: buffer.length,
|
|
cachedAt: new Date().toISOString(),
|
|
expiresAt: new Date(Date.now() + 7 * 86400_000).toISOString(),
|
|
})
|
|
}
|
|
res.json({
|
|
code: 0,
|
|
data: {
|
|
base64: buffer.toString('base64'),
|
|
mimetype: message.mimetype || 'application/octet-stream',
|
|
fileName: message.fileName || `${message.id}.bin`,
|
|
},
|
|
})
|
|
})
|
|
app.post('/accounts/:id/chats/:jid/read', async (req, res) => {
|
|
const sock = accountSocket(req.params.id)
|
|
const store = await loadStore(req.params.id)
|
|
const list = store.messages.get(req.params.jid) || []
|
|
const keys = list.filter((item) => !item.fromMe).slice(-50).map((item) => item.raw.key)
|
|
if (keys.length) await sock.readMessages(keys)
|
|
const chat = store.chats.get(req.params.jid)
|
|
if (chat) chat.unreadCount = 0
|
|
scheduleStoreSave(req.params.id)
|
|
res.json({ code: 0, data: { success: true, count: keys.length } })
|
|
})
|
|
app.post('/accounts/:id/chats/:jid/presence', async (req, res) => {
|
|
const sock = accountSocket(req.params.id)
|
|
const presence = ['composing', 'recording', 'paused', 'available', 'unavailable'].includes(req.body.presence)
|
|
? req.body.presence
|
|
: 'composing'
|
|
await sock.sendPresenceUpdate(presence, req.params.jid)
|
|
res.json({ code: 0, data: { success: true } })
|
|
})
|
|
app.post('/accounts/:id/chats/:jid/action', async (req, res) => {
|
|
const sock = accountSocket(req.params.id)
|
|
const store = await loadStore(req.params.id)
|
|
const jid = req.params.jid
|
|
const action = String(req.body.action || '')
|
|
if (action === 'archive' || action === 'unarchive') {
|
|
await sock.chatModify({ archive: action === 'archive', lastMessages: [] }, jid)
|
|
} else if (action === 'pin' || action === 'unpin') {
|
|
await sock.chatModify({ pin: action === 'pin' }, jid)
|
|
} else if (action === 'mute' || action === 'unmute') {
|
|
await sock.chatModify({ mute: action === 'mute' ? Number(req.body.until || Date.now() + 8 * 3600_000) : null }, jid)
|
|
} else if (action === 'mark-unread') {
|
|
await sock.chatModify({ markRead: false, lastMessages: [] }, jid)
|
|
} else if (action === 'clear') {
|
|
const list = store.messages.get(jid) || []
|
|
const lastMessages = list.slice(-1).map((item) => ({
|
|
key: item.raw?.key,
|
|
messageTimestamp: item.timestamp,
|
|
})).filter((item) => item.key)
|
|
await sock.chatModify({ clear: true, lastMessages }, jid)
|
|
store.messages.set(jid, [])
|
|
} else if (action === 'delete') {
|
|
await sock.chatModify({ delete: true, lastMessages: [] }, jid)
|
|
store.chats.delete(jid)
|
|
store.messages.delete(jid)
|
|
} else if (action === 'block' || action === 'unblock') {
|
|
await sock.updateBlockStatus(jid, action === 'block' ? 'block' : 'unblock')
|
|
} else if (action === 'leave' && jid.endsWith('@g.us')) {
|
|
await sock.groupLeave(jid)
|
|
} else {
|
|
return res.status(400).json({ code: 400, message: '不支持的会话操作' })
|
|
}
|
|
scheduleStoreSave(req.params.id)
|
|
res.json({ code: 0, data: { success: true, action } })
|
|
})
|
|
app.post('/accounts/:id/groups', async (req, res) => {
|
|
const sock = accountSocket(req.params.id)
|
|
const subject = String(req.body.subject || '').trim()
|
|
const participants = (req.body.participants || []).map((item) => {
|
|
const value = String(item || '').replace(/\D/g, '')
|
|
return `${value}@s.whatsapp.net`
|
|
})
|
|
if (!subject || !participants.length) return res.status(400).json({ code: 400, message: '群名称和成员必填' })
|
|
const group = await sock.groupCreate(subject, participants)
|
|
res.json({ code: 0, data: jsonSafe(group) })
|
|
})
|
|
app.post('/accounts/:id/groups/:jid/action', async (req, res) => {
|
|
const sock = accountSocket(req.params.id)
|
|
const jid = req.params.jid
|
|
const action = String(req.body.action || '')
|
|
let result
|
|
if (['add', 'remove', 'promote', 'demote'].includes(action)) {
|
|
const participants = (req.body.participants || []).map((item) => String(item).includes('@')
|
|
? String(item)
|
|
: `${String(item).replace(/\D/g, '')}@s.whatsapp.net`)
|
|
result = await sock.groupParticipantsUpdate(jid, participants, action)
|
|
} else if (action === 'subject') result = await sock.groupUpdateSubject(jid, String(req.body.subject || ''))
|
|
else if (action === 'description') result = await sock.groupUpdateDescription(jid, String(req.body.description || ''))
|
|
else if (action === 'invite-code') result = await sock.groupInviteCode(jid)
|
|
else if (action === 'revoke-invite') result = await sock.groupRevokeInvite(jid)
|
|
else return res.status(400).json({ code: 400, message: '不支持的群组操作' })
|
|
res.json({ code: 0, data: jsonSafe(result) })
|
|
})
|
|
app.post('/accounts/:id/groups/join', async (req, res) => {
|
|
const sock = accountSocket(req.params.id)
|
|
const code = String(req.body.code || req.body.inviteLink || '').split('/').pop()
|
|
if (!code) return res.status(400).json({ code: 400, message: '邀请链接或代码必填' })
|
|
const jid = await sock.groupAcceptInvite(code)
|
|
res.json({ code: 0, data: { jid } })
|
|
})
|
|
|
|
app.use((error, req, res, _next) => {
|
|
const status = Number(error.status || error.output?.statusCode || 500)
|
|
log.error({ error, requestId: res.getHeader('x-request-id'), path: req.path }, 'request failed')
|
|
res.status(status).json({ code: status, message: error.message || 'WhatsApp 网关内部错误' })
|
|
})
|
|
|
|
for (const account of await readJson(accountsFile, [])) {
|
|
accounts.set(account.id, account)
|
|
await loadStore(account.id)
|
|
if (account.status !== 'logged_out') {
|
|
void connectAccount(account.id).catch((error) => log.error({ error, accountId: account.id }, 'startup connect failed'))
|
|
}
|
|
}
|
|
|
|
for (const delivery of webhookDeliveries.values()) {
|
|
if (['pending', 'retrying', 'delivering'].includes(delivery.status)) {
|
|
if (delivery.status === 'delivering') delivery.status = 'retrying'
|
|
const delay = Math.max(0, Date.parse(delivery.nextAttemptAt || '') - Date.now())
|
|
scheduleWebhookDelivery(delivery.id, delay)
|
|
}
|
|
}
|
|
for (const job of outboundJobs.values()) {
|
|
if (['queued', 'retrying', 'sending'].includes(job.status)) {
|
|
if (job.status === 'sending') job.status = 'retrying'
|
|
const delay = Math.max(0, Date.parse(job.nextAttemptAt || '') - Date.now())
|
|
scheduleOutboundJob(job.id, delay)
|
|
}
|
|
}
|
|
|
|
app.listen(port, '0.0.0.0', () => log.info({ port }, '灵犀 WhatsApp Gateway listening'))
|