mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-17 13:13:35 +08:00
Add a WhatsApp adapter backed by Baileys linked-device auth, plus desktop QR binding UI, server config endpoints, sidecar startup wiring, tests, and documentation. Constraint: Uses WhatsApp Web linked-device auth, not Meta WhatsApp Business Cloud API. Tested: - cd adapters && bun run check:adapters - bun test src/server/__tests__/adapters.test.ts - cd desktop && bun run check:desktop - bun run check:native - bun run check:persistence-upgrade - bun run check:docs Not-tested: - Live WhatsApp QR pairing, because no WhatsApp account/device was exercised here. - bun run check:server, because the existing src/server/__tests__/conversations.test.ts timeout still fails independently. Confidence: medium Scope-risk: moderate
126 lines
4.1 KiB
TypeScript
126 lines
4.1 KiB
TypeScript
import * as path from 'node:path'
|
|
import {
|
|
downloadMediaMessage,
|
|
normalizeMessageContent,
|
|
type WAMessage,
|
|
type proto,
|
|
} from '@whiskeysockets/baileys'
|
|
import type { WhatsAppSocket } from './session.js'
|
|
import { AttachmentStore } from '../common/attachment/attachment-store.js'
|
|
import type { LocalAttachment } from '../common/attachment/attachment-types.js'
|
|
|
|
function unwrapMessage(message: proto.IMessage | undefined): proto.IMessage | undefined {
|
|
return normalizeMessageContent(message)
|
|
}
|
|
|
|
function resolveMediaMime(message: proto.IMessage): string | undefined {
|
|
return message.imageMessage?.mimetype
|
|
?? message.videoMessage?.mimetype
|
|
?? message.documentMessage?.mimetype
|
|
?? message.audioMessage?.mimetype
|
|
?? message.stickerMessage?.mimetype
|
|
?? (message.audioMessage ? 'audio/ogg; codecs=opus' : undefined)
|
|
?? (message.imageMessage ? 'image/jpeg' : undefined)
|
|
?? (message.videoMessage ? 'video/mp4' : undefined)
|
|
?? (message.stickerMessage ? 'image/webp' : undefined)
|
|
}
|
|
|
|
function resolveMediaName(message: proto.IMessage, mimeType: string): string {
|
|
const explicit = message.documentMessage?.fileName
|
|
if (explicit?.trim()) return path.basename(explicit)
|
|
if (message.imageMessage) return `whatsapp-image.${extensionForMime(mimeType, 'jpg')}`
|
|
if (message.videoMessage) return `whatsapp-video.${extensionForMime(mimeType, 'mp4')}`
|
|
if (message.audioMessage) return `whatsapp-audio.${extensionForMime(mimeType, 'ogg')}`
|
|
if (message.stickerMessage) return `whatsapp-sticker.${extensionForMime(mimeType, 'webp')}`
|
|
return `whatsapp-file.${extensionForMime(mimeType, 'bin')}`
|
|
}
|
|
|
|
function extensionForMime(mimeType: string, fallback: string): string {
|
|
const normalized = mimeType.split(';')[0]!.trim().toLowerCase()
|
|
switch (normalized) {
|
|
case 'image/jpeg':
|
|
return 'jpg'
|
|
case 'image/png':
|
|
return 'png'
|
|
case 'image/gif':
|
|
return 'gif'
|
|
case 'image/webp':
|
|
return 'webp'
|
|
case 'video/mp4':
|
|
return 'mp4'
|
|
case 'audio/ogg':
|
|
return 'ogg'
|
|
case 'audio/mpeg':
|
|
return 'mp3'
|
|
case 'application/pdf':
|
|
return 'pdf'
|
|
default:
|
|
return fallback
|
|
}
|
|
}
|
|
|
|
export class WhatsAppMediaService {
|
|
constructor(
|
|
private sock: WhatsAppSocket,
|
|
private store: AttachmentStore,
|
|
) {}
|
|
|
|
async downloadMessageMedia(message: proto.IWebMessageInfo, sessionId: string): Promise<LocalAttachment | null> {
|
|
const content = unwrapMessage(message.message as proto.IMessage | undefined)
|
|
if (!content) return null
|
|
const hasMedia = Boolean(
|
|
content.imageMessage ||
|
|
content.videoMessage ||
|
|
content.documentMessage ||
|
|
content.audioMessage ||
|
|
content.stickerMessage,
|
|
)
|
|
if (!hasMedia) return null
|
|
|
|
const mimeType = resolveMediaMime(content) ?? 'application/octet-stream'
|
|
const buffer = await downloadMediaMessage(
|
|
message as WAMessage,
|
|
'buffer',
|
|
{},
|
|
{
|
|
reuploadRequest: this.sock.updateMediaMessage,
|
|
logger: this.sock.logger,
|
|
},
|
|
) as Buffer
|
|
const kind = mimeType.startsWith('image/') ? 'image' : 'file'
|
|
const name = resolveMediaName(content, mimeType)
|
|
const target = this.store.resolvePath('whatsapp', sessionId, name)
|
|
await this.store.write(target, buffer)
|
|
return {
|
|
kind,
|
|
name,
|
|
path: target,
|
|
size: buffer.length,
|
|
mimeType,
|
|
buffer,
|
|
}
|
|
}
|
|
|
|
async sendMedia(jid: string, buffer: Buffer, mimeType: string, caption?: string, fileName?: string): Promise<void> {
|
|
if (mimeType.startsWith('image/')) {
|
|
await this.sock.sendMessage(jid, { image: buffer, caption, mimetype: mimeType })
|
|
return
|
|
}
|
|
if (mimeType.startsWith('video/')) {
|
|
await this.sock.sendMessage(jid, { video: buffer, caption, mimetype: mimeType })
|
|
return
|
|
}
|
|
if (mimeType.startsWith('audio/')) {
|
|
const effective = mimeType === 'audio/ogg' ? 'audio/ogg; codecs=opus' : mimeType
|
|
await this.sock.sendMessage(jid, { audio: buffer, ptt: true, mimetype: effective })
|
|
return
|
|
}
|
|
await this.sock.sendMessage(jid, {
|
|
document: buffer,
|
|
fileName: fileName || 'file',
|
|
caption,
|
|
mimetype: mimeType || 'application/octet-stream',
|
|
})
|
|
}
|
|
}
|