Skip to content

Commit

Permalink
feat(adapter-whatsapp): support multiple bots
Browse files Browse the repository at this point in the history
  • Loading branch information
XxLittleCxX committed Jul 18, 2023
1 parent b64615f commit e057269
Show file tree
Hide file tree
Showing 5 changed files with 117 additions and 45 deletions.
32 changes: 7 additions & 25 deletions adapters/whatsapp/src/bot.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,13 @@
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<WhatsAppBot.Config> {
static MessageEncoder = WhatsAppMessageEncoder
public http: Quester

constructor(ctx: Context, config: WhatsAppBot.Config) {
super(ctx, config)
ctx.plugin(HttpServer, this)
this.http = ctx.http.extend({
...config,
headers: {
Expand All @@ -18,20 +17,7 @@ export class WhatsAppBot extends Bot<WhatsAppBot.Config> {
}

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<void> {
Expand All @@ -50,18 +36,14 @@ export class WhatsAppBot extends Bot<WhatsAppBot.Config> {
}

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<Config> = 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)
}

Expand Down
25 changes: 20 additions & 5 deletions adapters/whatsapp/src/http.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<WhatsAppBot> {
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))
}
})
Expand Down
52 changes: 51 additions & 1 deletion adapters/whatsapp/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,56 @@
import { Bot, Context, Quester, Schema } from '@satorijs/satori'

Check failure on line 1 in adapters/whatsapp/src/index.ts

View workflow job for this annotation

GitHub Actions / lint

'Bot' is defined but never used
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<Config> = 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
45 changes: 32 additions & 13 deletions adapters/whatsapp/src/message.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand All @@ -8,22 +8,19 @@ const SUPPORTED_MEDIA = 'audio/aac, audio/mp4, audio/mpeg, audio/amr, audio/ogg,
export class WhatsAppMessageEncoder extends MessageEncoder<WhatsAppBot> {
private buffer = ''
quoteId: string = null
logger: Logger
prepare(): Promise<void> {
this.logger = this.bot.ctx.logger('whatsapp')
}

async flush(): Promise<void> {
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<T extends SendMessage['type']>(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 }[]
Expand All @@ -44,7 +41,15 @@ export class WhatsAppMessageEncoder extends MessageEncoder<WhatsAppBot> {
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)
}
Expand All @@ -55,7 +60,7 @@ export class WhatsAppMessageEncoder extends MessageEncoder<WhatsAppBot> {
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
}

Expand Down Expand Up @@ -85,22 +90,36 @@ export class WhatsAppMessageEncoder extends MessageEncoder<WhatsAppBot> {
// 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)
}
}
}
8 changes: 7 additions & 1 deletion adapters/whatsapp/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down

0 comments on commit e057269

Please sign in to comment.