Skip to content

Commit

Permalink
Merge remote-tracking branch 'origin/whatsapp'
Browse files Browse the repository at this point in the history
  • Loading branch information
shigma committed Aug 11, 2023
2 parents 6e3e037 + bc5fce7 commit 30d383b
Show file tree
Hide file tree
Showing 8 changed files with 516 additions and 0 deletions.
33 changes: 33 additions & 0 deletions adapters/whatsapp/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
{
"name": "@satorijs/adapter-whatsapp",
"description": "WhatsApp Adapter for Satorijs",
"version": "1.0.0",
"main": "lib/index.js",
"typings": "lib/index.d.ts",
"files": [
"lib"
],
"author": "LittleC <i@ltlec.com>",
"license": "MIT",
"repository": {
"type": "git",
"url": "git+https://github.com/satorijs/satori.git",
"directory": "adapters/whatsapp"
},
"bugs": {
"url": "https://github.com/satorijs/satori/issues"
},
"homepage": "https://koishi.chat/plugins/adapter/whatsapp.html",
"keywords": [
"bot",
"whatsapp",
"adapter",
"chatbot",
"satori"
],
"peerDependencies": {
"@satorijs/satori": "^2.4.0"
},
"dependencies": {},
"devDependencies": {}
}
50 changes: 50 additions & 0 deletions adapters/whatsapp/src/bot.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import { Bot, Context, Quester, Schema } from '@satorijs/satori'
import { WhatsAppMessageEncoder } from './message'
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)
this.http = ctx.http.extend({
...config,
headers: {
Authorization: `Bearer ${config.systemToken}`,
},
})
}

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

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,
},
})
}
}

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: 77 additions & 0 deletions adapters/whatsapp/src/http.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
import { Adapter, Context, Logger } from '@satorijs/satori'
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')

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/
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 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))
}
})
bot.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 !== bot.config.verifyToken) return ctx.status = 403
ctx.body = challenge
ctx.status = 200
})
bot.ctx.router.get('/whatsapp/assets/:self_id/:media_id', async (ctx) => {
const mediaId = ctx.params.media_id
const selfId = ctx.params.self_id
const localBot = this.bots.find((bot) => bot.selfId === selfId)
if (!localBot) return ctx.status = 404

const fetched = await localBot.http.get<{
url: string
}>('/' + mediaId)
this.logger.debug(fetched.url)
const resp = await localBot.ctx.http.axios<internal.Readable>({
url: fetched.url,
method: 'GET',
responseType: 'stream',
headers: {
Authorization: `Bearer ${localBot.config.systemToken}`,
},
})
ctx.type = resp.headers['content-type']
ctx.set('cache-control', resp.headers['cache-control'])
ctx.response.body = resp.data
ctx.status = 200
})
bot.online()
}
}
56 changes: 56 additions & 0 deletions adapters/whatsapp/src/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +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 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
125 changes: 125 additions & 0 deletions adapters/whatsapp/src/message.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
import { Dict, h, MessageEncoder } from '@satorijs/satori'
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(', ')

export class WhatsAppMessageEncoder extends MessageEncoder<WhatsAppBot> {
private buffer = ''
quoteId: string = null

async flush(): Promise<void> {
await this.flushTextMessage()
}

async flushTextMessage() {
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 }[]
}>(`/${this.bot.selfId}/messages`, {
messaging_product: 'whatsapp',
to: this.channelId,
recipient_type: 'individual',
type,
[type]: data,
...(this.quoteId ? {
context: {
message_id: this.quoteId,
},
} : {}),
})

for (const msg of messages) {
const session = this.bot.session()
session.type = 'message'
session.messageId = msg.id
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)
}
}

// https://developers.facebook.com/docs/whatsapp/cloud-api/reference/media#upload-media
async uploadMedia(attrs: Dict) {
const { filename, data, mime } = await this.bot.ctx.http.file(attrs.url, attrs)

if (!SUPPORTED_MEDIA.includes(mime)) {
this.bot.ctx.logger('whatsapp').warn(`Unsupported media type: ${mime}`)
return
}

const form = new FormData()
const value = process.env.KOISHI_ENV === 'browser'
? new Blob([data], { type: mime })
: Buffer.from(data)
form.append('file', value, attrs.file || filename)
form.append('type', mime)
form.append('messaging_product', 'whatsapp')

const r = await this.bot.http.post<{
id: string
}>(`/${this.bot.selfId}/media`, form, {
headers: form.getHeaders(),
})
return r.id
}

async visit(element: h): Promise<void> {
const { type, attrs, children } = element
if (type === 'text') {
this.buffer += attrs.content
} else if ((
type === 'image' || type === 'audio' || type === 'video'
) && attrs.url) {
// https://developers.facebook.com/docs/whatsapp/cloud-api/reference/media#supported-media-types
const id = await this.uploadMedia(attrs)
if (!id) return
await this.sendMessage(type, { id })
} else if (type === 'file') {
const id = await this.uploadMedia(attrs)
if (!id) return
await this.sendMessage('document', { 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)
}
}
}
Loading

0 comments on commit 30d383b

Please sign in to comment.