diff --git a/adapters/whatsapp/src/bot.ts b/adapters/whatsapp/src/bot.ts index deb8d1fa..f2de6ca3 100644 --- a/adapters/whatsapp/src/bot.ts +++ b/adapters/whatsapp/src/bot.ts @@ -1,6 +1,6 @@ -import { Bot, Context, Logger, Quester, Schema, Universal } from '@satorijs/satori' +import { Bot, Context, Quester, Schema } from '@satorijs/satori' import { WhatsAppMessageEncoder } from './message' -import { HttpServer } from './http' +import { WhatsAppBusiness } from '.' export class WhatsAppBot extends Bot { static MessageEncoder = WhatsAppMessageEncoder @@ -8,7 +8,6 @@ export class WhatsAppBot extends Bot { constructor(ctx: Context, config: WhatsAppBot.Config) { super(ctx, config) - ctx.plugin(HttpServer, this) this.http = ctx.http.extend({ ...config, headers: { @@ -18,20 +17,7 @@ export class WhatsAppBot extends Bot { } async initialize() { - const { data } = await this.http<{ - data: { - verified_name: string - code_verification_status: string - display_phone_number: string - quality_rating: string - id: string - }[] - }>('GET', `/v17.0/${this.config.id}/phone_numbers`) - this.ctx.logger('whatsapp').debug(require('util').inspect(data, false, null, true)) - if (data.length) { - this.selfId = data[0].id - this.username = data[0].verified_name - } + this.selfId = this.config.phoneNumber } async createReaction(channelId: string, messageId: string, emoji: string): Promise { @@ -50,18 +36,14 @@ export class WhatsAppBot extends Bot { } export namespace WhatsAppBot { - export interface Config extends Bot.Config, Quester.Config { - systemToken: string - verifyToken: string - id: string + export interface Config extends WhatsAppBusiness.Config, Bot.Config { + phoneNumber: string } export const Config: Schema = Schema.intersect([ Schema.object({ - systemToken: Schema.string(), - verifyToken: Schema.string().required(), - id: Schema.string().description('WhatsApp Business Account ID').required(), + phoneNumber: Schema.string().description('手机号').required(), }), - Quester.createConfig('https://graph.facebook.com'), + WhatsAppBusiness.Config, ] as const) } diff --git a/adapters/whatsapp/src/http.ts b/adapters/whatsapp/src/http.ts index b6e620e0..f7ef61d6 100644 --- a/adapters/whatsapp/src/http.ts +++ b/adapters/whatsapp/src/http.ts @@ -3,26 +3,41 @@ import { WhatsAppBot } from './bot' import { WebhookBody } from './types' import { decodeMessage } from './utils' import internal from 'stream' +import crypto from 'crypto' export class HttpServer extends Adapter.Server { logger = new Logger('whatsapp') - constructor(ctx: Context, bot: WhatsAppBot) { - super() + + fork(ctx: Context, bot: WhatsAppBot) { + super.fork(ctx, bot) + return bot.initialize() } async start(bot: WhatsAppBot) { // https://developers.facebook.com/docs/graph-api/webhooks/getting-started // https://developers.facebook.com/docs/graph-api/webhooks/getting-started/webhooks-for-whatsapp/ - await bot.initialize() bot.ctx.router.post('/whatsapp', async (ctx) => { + const receivedSignature = ctx.get('X-Hub-Signature-256').split('sha256=')[1] + + const payload = JSON.stringify(ctx.request.body) + + const generatedSignature = crypto + .createHmac('sha256', bot.config.secret) + .update(payload) + .digest('hex') + if (receivedSignature !== generatedSignature) return ctx.status = 403 + const parsed = ctx.request.body as WebhookBody this.logger.debug(require('util').inspect(parsed, false, null, true)) ctx.body = 'ok' ctx.status = 200 if (parsed.object !== 'whatsapp_business_account') return for (const entry of parsed.entry) { - const session = await decodeMessage(bot, entry) - if (session.length) session.forEach(bot.dispatch.bind(bot)) + const phone_number_id = entry.changes[0].value.metadata.phone_number_id + const localBot = this.bots.find((bot) => bot.selfId === phone_number_id) + const session = await decodeMessage(localBot, entry) + if (session.length) session.forEach(localBot.dispatch.bind(localBot)) + this.logger.debug('handling bot: %s', localBot.sid) this.logger.debug(require('util').inspect(session, false, null, true)) } }) diff --git a/adapters/whatsapp/src/index.ts b/adapters/whatsapp/src/index.ts index 1a1a4feb..9706cfff 100644 --- a/adapters/whatsapp/src/index.ts +++ b/adapters/whatsapp/src/index.ts @@ -1,6 +1,56 @@ +import { Bot, Context, Quester, Schema } from '@satorijs/satori' import { WhatsAppBot } from './bot' +import { HttpServer } from './http' export * from './http' export * from './bot' +export * from './types' +export * from './utils' +export * from './message' -export default WhatsAppBot +export async function WhatsAppBusiness(ctx: Context, config: WhatsAppBusiness.Config) { + const http: Quester = ctx.http.extend({ + ...config, + headers: { + Authorization: `Bearer ${config.systemToken}`, + }, + }) + const { data } = await http<{ + data: { + verified_name: string + code_verification_status: string + display_phone_number: string + quality_rating: string + id: string + }[] + }>('GET', `/${config.id}/phone_numbers`) + ctx.logger('whatsapp').debug(require('util').inspect(data, false, null, true)) + const httpServer = new HttpServer() + for (const item of data) { + const bot = new WhatsAppBot(ctx, { + ...config, + phoneNumber: item.id, + }) + httpServer.fork(ctx, bot) + } +} + +export namespace WhatsAppBusiness { + export interface Config extends Quester.Config { + systemToken: string + verifyToken: string + id: string + secret: string + } + export const Config: Schema = Schema.intersect([ + Schema.object({ + secret: Schema.string().role('secret').description('App Secret').required(), + systemToken: Schema.string().role('secret').description('System User Token').required(), + verifyToken: Schema.string().required(), + id: Schema.string().description('WhatsApp Business Account ID').required(), + }), + Quester.createConfig('https://graph.facebook.com'), + ] as const) +} + +export default WhatsAppBusiness diff --git a/adapters/whatsapp/src/message.ts b/adapters/whatsapp/src/message.ts index 69e20785..ff477860 100644 --- a/adapters/whatsapp/src/message.ts +++ b/adapters/whatsapp/src/message.ts @@ -1,4 +1,4 @@ -import { Dict, h, Logger, MessageEncoder } from '@satorijs/satori' +import { Dict, h, MessageEncoder } from '@satorijs/satori' import { WhatsAppBot } from './bot' import FormData from 'form-data' import { SendMessage } from './types' @@ -8,22 +8,19 @@ const SUPPORTED_MEDIA = 'audio/aac, audio/mp4, audio/mpeg, audio/amr, audio/ogg, export class WhatsAppMessageEncoder extends MessageEncoder { private buffer = '' quoteId: string = null - logger: Logger - prepare(): Promise { - this.logger = this.bot.ctx.logger('whatsapp') - } async flush(): Promise { await this.flushTextMessage() } async flushTextMessage() { - await this.sendMessage('text', { body: this.buffer }) + await this.sendMessage('text', { body: this.buffer, preview_url: this.options.linkPreview }) this.buffer = '' } async sendMessage(type: T, data: Dict) { if (type === 'text' && !this.buffer.length) return + if (type !== 'text' && this.buffer.length) await this.flushTextMessage() // https://developers.facebook.com/docs/whatsapp/api/messages/text const { messages } = await this.bot.http.post<{ messages: { id: string }[] @@ -44,7 +41,15 @@ export class WhatsAppMessageEncoder extends MessageEncoder { const session = this.bot.session() session.type = 'message' session.messageId = msg.id - // @TODO session body + session.channelId = this.channelId + session.guildId = this.channelId + session.isDirect = true + session.userId = this.bot.selfId + session.author = { + userId: this.bot.selfId, + username: this.bot.username, + } + session.timestamp = Date.now() session.app.emit(session, 'send', session) this.results.push(session) } @@ -55,7 +60,7 @@ export class WhatsAppMessageEncoder extends MessageEncoder { const { filename, data, mime } = await this.bot.ctx.http.file(attrs.url, attrs) if (!SUPPORTED_MEDIA.includes(mime)) { - this.logger.warn(`Unsupported media type: ${mime}`) + this.bot.ctx.logger('whatsapp').warn(`Unsupported media type: ${mime}`) return } @@ -85,22 +90,36 @@ export class WhatsAppMessageEncoder extends MessageEncoder { // https://developers.facebook.com/docs/whatsapp/cloud-api/reference/media#supported-media-types const id = await this.uploadMedia(attrs) if (!id) return - await this.flushTextMessage() await this.sendMessage(type, { id }) } else if (type === 'file') { const id = await this.uploadMedia(attrs) if (!id) return - await this.flushTextMessage() await this.sendMessage('document', { id }) - } else if (type === 'face' && attrs.id) { - await this.flushTextMessage() - await this.sendMessage('sticker', { id: attrs.id }) + } else if (type === 'face') { + if (attrs.platform && attrs.platform !== this.bot.platform) { + return this.render(children) + } else { + await this.sendMessage('sticker', { id: attrs.id }) + } + } else if (type === 'p') { + await this.render(children) + this.buffer += '\n' + } else if (type === 'a') { + await this.render(children) + this.buffer += ` (${attrs.href}) ` + } else if (type === 'at') { + if (attrs.id) { + this.buffer += `@${attrs.id}` + } } else if (type === 'message') { await this.flush() await this.render(children) await this.flush() + this.quoteId = null } else if (type === 'quote') { this.quoteId = attrs.id + } else { + await this.render(children) } } } diff --git a/adapters/whatsapp/src/types.ts b/adapters/whatsapp/src/types.ts index 1376bfa6..9ea7f283 100644 --- a/adapters/whatsapp/src/types.ts +++ b/adapters/whatsapp/src/types.ts @@ -77,7 +77,13 @@ export interface SendMessageBase { to: string } -export type SendMessage = SendTextMessage | SendMediaMessage<'image'> | SendMediaMessage<'audio'> | SendMediaMessage<'video'> | SendMediaMessage<'document'> | SendMediaMessage<'sticker'> +export type SendMessage = + SendTextMessage | + SendMediaMessage<'image'> | + SendMediaMessage<'audio'> | + SendMediaMessage<'video'> | + SendMediaMessage<'document'> | + SendMediaMessage<'sticker'> export interface SendTextMessage extends SendMessageBase { type: 'text'