Skip to content

Commit

Permalink
feat(whatsapp): support arbitrary bots adapter
Browse files Browse the repository at this point in the history
  • Loading branch information
shigma committed Aug 11, 2023
1 parent 30d383b commit 4142bda
Show file tree
Hide file tree
Showing 6 changed files with 178 additions and 168 deletions.
113 changes: 113 additions & 0 deletions adapters/whatsapp/src/adapter.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
import { Adapter, Context, Logger, Quester, Schema } from '@satorijs/satori'
import { Internal } from './internal'
import { WhatsAppBot } from './bot'
import { WebhookBody } from './types'
import { decodeMessage } from './utils'
import internal from 'stream'
import crypto from 'crypto'

export class WhatsAppAdapter extends Adapter<WhatsAppBot> {
static reusable = true

public bots: WhatsAppBot[] = []
public logger = new Logger('whatsapp')

constructor(private ctx: Context, public config: WhatsAppAdapter.Config) {
super()

const http = ctx.http.extend({
...config,
headers: {
Authorization: `Bearer ${config.systemToken}`,
},
})
const internal = new Internal(http)

ctx.on('ready', async () => {
const data = await internal.getPhoneNumbers(config.id)
for (const item of data) {
const bot = new WhatsAppBot(ctx, {
selfId: item.id,
})
bot.adapter = this
bot.internal = internal
this.bots.push(bot)
bot.online()
}
})

// https://developers.facebook.com/docs/graph-api/webhooks/getting-started
// https://developers.facebook.com/docs/graph-api/webhooks/getting-started/webhooks-for-whatsapp/
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', this.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 phone_number_id = entry.changes[0].value.metadata.phone_number_id
const bot = this.bots.find((bot) => bot.selfId === phone_number_id)
const session = await decodeMessage(bot, entry)
if (session.length) session.forEach(bot.dispatch.bind(bot))
this.logger.debug('handling bot: %s', bot.sid)
this.logger.debug(require('util').inspect(session, false, null, true))
}
})

ctx.router.get('/whatsapp', async (ctx) => {
this.logger.debug(require('util').inspect(ctx.query, false, null, true))
const verifyToken = ctx.query['hub.verify_token']
const challenge = ctx.query['hub.challenge']
if (verifyToken !== this.config.verifyToken) return ctx.status = 403
ctx.body = challenge
ctx.status = 200
})

ctx.router.get('/whatsapp/assets/:self_id/:media_id', async (ctx) => {
const mediaId = ctx.params.media_id
const selfId = ctx.params.self_id
const bot = this.bots.find((bot) => bot.selfId === selfId)
if (!bot) return ctx.status = 404

const fetched = await bot.http.get<{ url: string }>('/' + mediaId)
this.logger.debug(fetched.url)
const resp = await bot.ctx.http.axios<internal.Readable>({
url: fetched.url,
method: 'GET',
responseType: 'stream',
})
ctx.type = resp.headers['content-type']
ctx.set('cache-control', resp.headers['cache-control'])
ctx.response.body = resp.data
ctx.status = 200
})
}
}

export namespace WhatsAppAdapter {
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)
}
47 changes: 8 additions & 39 deletions adapters/whatsapp/src/bot.ts
Original file line number Diff line number Diff line change
@@ -1,50 +1,19 @@
import { Bot, Context, Quester, Schema } from '@satorijs/satori'
import { Bot, Context, Quester } from '@satorijs/satori'
import { WhatsAppMessageEncoder } from './message'
import { WhatsAppBusiness } from '.'
import { Internal } from './internal'

export class WhatsAppBot extends Bot<WhatsAppBot.Config> {
export class WhatsAppBot extends Bot {
static MessageEncoder = WhatsAppMessageEncoder

public internal: Internal
public http: Quester

constructor(ctx: Context, config: WhatsAppBot.Config) {
constructor(ctx: Context, config: Bot.Config) {
super(ctx, config)
this.http = ctx.http.extend({
...config,
headers: {
Authorization: `Bearer ${config.systemToken}`,
},
})
}

async initialize() {
this.selfId = this.config.phoneNumber
this.platform = 'whatsapp'
}

async createReaction(channelId: string, messageId: string, emoji: string): Promise<void> {
// https://developers.facebook.com/docs/whatsapp/cloud-api/guides/send-messages#reaction-messages
await this.http.post(`/${this.selfId}/messages`, {
messaging_product: 'whatsapp',
to: channelId,
recipient_type: 'individual',
type: 'reaction',
reaction: {
message_id: messageId,
emoji,
},
})
await this.internal.messageReaction(this.selfId, channelId, messageId, emoji)
}
}

export namespace WhatsAppBot {
export interface Config extends WhatsAppBusiness.Config, Bot.Config {
phoneNumber: string
}
export const Config: Schema<Config> = Schema.intersect([
Schema.object({
phoneNumber: Schema.string().description('手机号').required(),
}),
WhatsAppBusiness.Config,
] as const)
}

WhatsAppBot.prototype.platform = 'whatsapp'
77 changes: 0 additions & 77 deletions adapters/whatsapp/src/http.ts

This file was deleted.

55 changes: 4 additions & 51 deletions adapters/whatsapp/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,56 +1,9 @@
import { Bot, Context, Quester, Schema } from '@satorijs/satori'
import { WhatsAppBot } from './bot'
import { HttpServer } from './http'
import { WhatsAppAdapter } from './adapter'

export * from './http'
export default WhatsAppAdapter

export * from './adapter'
export * from './bot'
export * from './types'
export * from './utils'
export * from './message'

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
32 changes: 32 additions & 0 deletions adapters/whatsapp/src/internal.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import { Quester } from '@satorijs/satori'

interface PhoneNumber {
verified_name: string
code_verification_status: string
display_phone_number: string
quality_rating: string
id: string
}

export class Internal {
constructor(public http: Quester) {}

async getPhoneNumbers(id: string) {
const { data } = await this.http.get<{ data: PhoneNumber[] }>(`/${id}/phone_numbers`)
return data
}

async messageReaction(selfId: string, channelId: string, messageId: string, emoji: string) {
// https://developers.facebook.com/docs/whatsapp/cloud-api/guides/send-messages#reaction-messages
await this.http.post(`/${selfId}/messages`, {
messaging_product: 'whatsapp',
to: channelId,
recipient_type: 'individual',
type: 'reaction',
reaction: {
message_id: messageId,
emoji,
},
})
}
}
22 changes: 21 additions & 1 deletion adapters/whatsapp/src/message.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,27 @@ import { WhatsAppBot } from './bot'
import FormData from 'form-data'
import { SendMessage } from './types'

const SUPPORTED_MEDIA = 'audio/aac, audio/mp4, audio/mpeg, audio/amr, audio/ogg, audio/opus, application/vnd.ms-powerpoint, application/msword, application/vnd.openxmlformats-officedocument.wordprocessingml.document, application/vnd.openxmlformats-officedocument.presentationml.presentation, application/vnd.openxmlformats-officedocument.spreadsheetml.sheet, application/pdf, text/plain, application/vnd.ms-excel, image/jpeg, image/png, image/webp, video/mp4, video/3gpp'.split(', ')
const SUPPORTED_MEDIA = [
'audio/aac',
'audio/mp4',
'audio/mpeg',
'audio/amr',
'audio/ogg',
'audio/opus',
'application/vnd.ms-powerpoint',
'application/msword',
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
'application/vnd.openxmlformats-officedocument.presentationml.presentation',
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
'application/pdf',
'text/plain',
'application/vnd.ms-excel',
'image/jpeg',
'image/png',
'image/webp',
'video/mp4',
'video/3gpp',
]

export class WhatsAppMessageEncoder extends MessageEncoder<WhatsAppBot> {
private buffer = ''
Expand Down

0 comments on commit 4142bda

Please sign in to comment.