Skip to content

Commit

Permalink
feat(vonage): vonage v1 (#338)
Browse files Browse the repository at this point in the history
* bump messaging client

* init

* security

* messages

* fix

* reamde

* image

* single-choice

* carousel

* audio file video

* location

* interactive messages

* fix

* template

* media template
  • Loading branch information
samuelmasse committed Feb 4, 2022
1 parent ae0155a commit c8436d2
Show file tree
Hide file tree
Showing 26 changed files with 587 additions and 1 deletion.
2 changes: 2 additions & 0 deletions packages/channels/example/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { SmoochChannel } from '../src/smooch/channel'
import { TeamsChannel } from '../src/teams/channel'
import { TelegramChannel } from '../src/telegram/channel'
import { TwilioChannel } from '../src/twilio/channel'
import { VonageChannel } from '../src/vonage/channel'
import payloads from './payloads.json'

export class App {
Expand All @@ -20,6 +21,7 @@ export class App {
await this.setupChannel('teams', new TeamsChannel())
await this.setupChannel('telegram', new TelegramChannel())
await this.setupChannel('twilio', new TwilioChannel())
await this.setupChannel('vonage', new VonageChannel())
}

async setupChannel(name: string, channel: Channel) {
Expand Down
1 change: 1 addition & 0 deletions packages/channels/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@
"cli-color": "^2.0.1",
"express": "^4.17.2",
"joi": "^17.6.0",
"jsonwebtoken": "^8.5.1",
"lodash": "^4.17.21",
"lru-cache": "^6.0.0",
"ms": "^2.1.3",
Expand Down
1 change: 1 addition & 0 deletions packages/channels/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,3 +6,4 @@ export * from './smooch/channel'
export * from './teams/channel'
export * from './telegram/channel'
export * from './twilio/channel'
export * from './vonage/channel'
29 changes: 29 additions & 0 deletions packages/channels/src/vonage/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
### Sending

| Channels | Vonage |
| -------- | :----: |
| Text ||
| Image ||
| Choice ||
| Dropdown ||
| Card ||
| Carousel ||
| File ||
| Audio ||
| Video ||
| Location ||

### Receiving

| Channels | Vonage |
| ------------- | :----: |
| Text ||
| Quick Reply ||
| Postback ||
| Say Something ||
| Voice ||
| Image ||
| File ||
| Audio ||
| Video ||
| Location ||
73 changes: 73 additions & 0 deletions packages/channels/src/vonage/api.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
import crypto from 'crypto'
import express, { NextFunction, Response } from 'express'
import jwt from 'jsonwebtoken'
import { ChannelApi, ChannelApiManager, ChannelApiRequest } from '../base/api'
import { VonageService } from './service'

export class VonageApi extends ChannelApi<VonageService> {
async setup(router: ChannelApiManager) {
router.use('/vonage', express.json())

router.post('/vonage', this.verifyRequestSignature.bind(this))
router.post('/vonage', this.handleRequest.bind(this))
}

private async verifyRequestSignature(req: ChannelApiRequest, res: Response, next: NextFunction) {
const { config } = this.service.get(req.scope)

const body = req.body
const [scheme, token] = (req.headers.authorization || '').split(' ')

if (body.channel !== 'whatsapp' || scheme.toLowerCase() !== 'bearer' || !token) {
return res.sendStatus(401)
}

try {
const decoded = jwt.verify(token, config.signatureSecret, { algorithms: ['HS256'] }) as {
api_key: string
payload_hash: string
}

if (
decoded.api_key === config.apiKey &&
crypto.createHash('sha256').update(JSON.stringify(body)).digest('hex') === decoded.payload_hash
) {
next()
} else {
return res.sendStatus(403)
}
} catch (e) {
return res.sendStatus(403)
}
}

private async handleRequest(req: ChannelApiRequest, res: Response) {
if (req.body.status) {
return res.sendStatus(200)
}

const endpoint = { identity: req.body.to, sender: req.body.from, thread: '*' }

if (req.body.reply) {
const [_, payload] = req.body.reply.id.split('::')

await this.service.receive(req.scope, endpoint, {
type: 'quick_reply',
text: req.body.reply.title,
payload
})
} else {
const text = req.body.text

const index = Number(text)
const content = this.service.handleIndexResponse(req.scope, index, endpoint.identity, endpoint.sender) || {
type: 'text',
text
}

await this.service.receive(req.scope, endpoint, content)
}

res.sendStatus(200)
}
}
23 changes: 23 additions & 0 deletions packages/channels/src/vonage/channel.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { ChannelTemplate } from '../base/channel'
import { VonageApi } from './api'
import { VonageConfig, VonageConfigSchema } from './config'
import { VonageService } from './service'
import { VonageStream } from './stream'

export class VonageChannel extends ChannelTemplate<VonageConfig, VonageService, VonageApi, VonageStream> {
get meta() {
return {
id: 'd6073ed2-5603-4f5b-bcef-0a4bc75ef113',
name: 'vonage',
version: '1.0.0',
schema: VonageConfigSchema,
initiable: false,
lazy: true
}
}

constructor() {
const service = new VonageService()
super(service, new VonageApi(service), new VonageStream(service))
}
}
16 changes: 16 additions & 0 deletions packages/channels/src/vonage/config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import Joi from 'joi'
import { ChannelConfig } from '../base/config'

export interface VonageConfig extends ChannelConfig {
apiKey: string
apiSecret: string
signatureSecret: string
useTestingApi?: boolean
}

export const VonageConfigSchema = {
apiKey: Joi.string().required(),
apiSecret: Joi.string().required(),
signatureSecret: Joi.string().required(),
useTestingApi: Joi.boolean().optional()
}
7 changes: 7 additions & 0 deletions packages/channels/src/vonage/context.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import { ChannelContext, IndexChoiceOption } from '../base/context'
import { VonageState } from './service'

export type VonageContext = ChannelContext<VonageState> & {
messages: any[]
prepareIndexResponse(scope: string, identity: string, sender: string, options: IndexChoiceOption[]): void
}
9 changes: 9 additions & 0 deletions packages/channels/src/vonage/renderers/audio.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import { AudioRenderer } from '../../base/renderers/audio'
import { AudioContent } from '../../content/types'
import { VonageContext } from '../context'

export class VonageAudioRenderer extends AudioRenderer {
renderAudio(context: VonageContext, payload: AudioContent) {
context.messages.push({ message_type: 'audio', audio: { url: payload.audio } })
}
}
68 changes: 68 additions & 0 deletions packages/channels/src/vonage/renderers/carousel.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
import { IndexChoiceOption, IndexChoiceType } from '../../base/context'
import { CarouselRenderer, CarouselContext } from '../../base/renderers/carousel'
import { ActionOpenURL, ActionPostback, ActionSaySomething, CardContent, CarouselContent } from '../../content/types'
import { VonageContext } from '../context'

type Context = CarouselContext<VonageContext> & {
options: IndexChoiceOption[]
allOptions: IndexChoiceOption[]
index: number
}

export class VonageCarouselRenderer extends CarouselRenderer {
startRender(context: Context, carousel: CarouselContent) {
context.allOptions = []
}

startRenderCard(context: Context, card: CardContent) {
context.options = []
}

renderButtonUrl(context: Context, button: ActionOpenURL) {
context.options.push({
type: IndexChoiceType.OpenUrl,
title: `${button.title} : ${button.url}`
})
}

renderButtonPostback(context: Context, button: ActionPostback) {
context.options.push({ type: IndexChoiceType.PostBack, title: button.title, value: button.payload })
}

renderButtonSay(context: Context, button: ActionSaySomething) {
context.options.push({
type: IndexChoiceType.SaySomething,
title: button.title,
value: button.text
})
}

endRenderCard(context: Context, card: CardContent) {
const body = `*${card.title}*\n\n${`${card.subtitle}\n\n` || ''}${context.options
.map(({ title }, idx) => `*(${idx + context.allOptions.length + 1})* ${title}`)
.join('\n')}`

if (card.image) {
context.channel.messages.push({
message_type: 'image',
image: {
url: card.image,
caption: body
}
})
} else {
context.channel.messages.push({ message_type: 'text', text: body })
}

context.allOptions.push(...context.options)
}

endRender(context: Context, carousel: CarouselContent) {
context.channel.prepareIndexResponse(
context.channel.scope,
context.channel.identity,
context.channel.sender,
context.allOptions
)
}
}
63 changes: 63 additions & 0 deletions packages/channels/src/vonage/renderers/choices.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import { IndexChoiceType } from '../../base/context'
import { ChoicesRenderer } from '../../base/renderers/choices'
import { ChoiceContent } from '../../content/types'
import { VonageContext } from '../context'

export class VonageChoicesRenderer extends ChoicesRenderer {
renderChoice(context: VonageContext, payload: ChoiceContent) {
if (payload.choices.length <= 3) {
context.messages[0] = {
message_type: 'custom',
custom: {
type: 'interactive',
interactive: {
type: 'button',
body: {
text: payload.text
},
action: {
buttons: payload.choices.map((x, i) => ({
type: 'reply',
reply: { id: `slot-${i}::${x.value}`, title: x.title }
}))
}
}
}
}
} else if (payload.choices.length <= 10) {
context.messages[0] = {
message_type: 'custom',
custom: {
type: 'interactive',
interactive: {
type: 'list',
body: {
text: payload.text
},
action: {
button: payload.text,
sections: [
{
rows: payload.choices.map((x, i) => ({ id: `slot-${i}::${x.value}`, title: x.title }))
}
]
}
}
}
}
} else {
const message = context.messages[0]

message.text = `${message.text}\n\n${payload.choices
.map(({ title }, idx) => `*(${idx + 1})* ${title}`)
.join('\n')}`

context.prepareIndexResponse(
context.scope,
context.identity,
context.sender,
payload.choices.map((x) => ({ type: IndexChoiceType.QuickReply, ...x }))
)
}
}
}
45 changes: 45 additions & 0 deletions packages/channels/src/vonage/renderers/dropdown.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import { ChannelRenderer } from '../../base/renderer'
import { VonageContext } from '../context'

export class VonageDropdownRenderer implements ChannelRenderer<VonageContext> {
get priority(): number {
return -1
}

handles(context: VonageContext): boolean {
return !!context.payload.options?.length
}

render(context: VonageContext): void {
const payload = context.payload // as DropdownContent

if (payload.options.length <= 10) {
context.messages[0] = {
message_type: 'custom',
custom: {
type: 'interactive',
interactive: {
type: 'list',
body: {
text: payload.message
},
action: {
button: payload.buttonText,
sections: [
{
rows: payload.options.map((x: any, i: any) => ({ id: `slot-${i}::${x.value}`, title: x.label }))
}
]
}
}
}
}
} else {
context.payload = {
type: 'single-choice',
text: payload.message,
choices: payload.options.map((x: any) => ({ title: x.label, value: x.value }))
}
}
}
}
9 changes: 9 additions & 0 deletions packages/channels/src/vonage/renderers/file.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import { FileRenderer } from '../../base/renderers/file'
import { FileContent } from '../../content/types'
import { VonageContext } from '../context'

export class VonageFileRenderer extends FileRenderer {
renderFile(context: VonageContext, payload: FileContent) {
context.messages.push({ message_type: 'file', file: { url: payload.file, caption: payload.title } })
}
}
15 changes: 15 additions & 0 deletions packages/channels/src/vonage/renderers/image.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import { ImageRenderer } from '../../base/renderers/image'
import { ImageContent } from '../../content/types'
import { VonageContext } from '../context'

export class VonageImageRenderer extends ImageRenderer {
renderImage(context: VonageContext, payload: ImageContent) {
context.messages.push({
message_type: 'image',
image: {
url: payload.image,
caption: payload.title
}
})
}
}

0 comments on commit c8436d2

Please sign in to comment.