ts/src/webex/index.ts (153 lines of code) (raw):
import express, {RequestHandler} from 'express';
import {load} from 'ts-dotenv';
import bodyParser from 'body-parser';
import axios from 'axios';
import {CreateMessageRequest, MessageDetails, Webhook} from './api-types';
import {createHmac, randomBytes} from 'crypto';
import {JsonObject} from 'type-fest';
import createClientFromEnv from '../util/client-from-env';
import {structToJson} from '../util/struct';
const DF_WEBHOOK_NAME = 'dialogflow-webhook';
const env = load({
PORT: Number,
WEBEX_ACCESS_TOKEN: String,
WEBHOOK_URL: String,
});
const generateSecret = () => randomBytes(32).toString('hex');
const {client, type} = createClientFromEnv();
const webexAPI = axios.create({
baseURL: 'https://webexapis.com',
headers: {
Authorization: `Bearer ${env.WEBEX_ACCESS_TOKEN}`,
},
});
const webhookAuth =
(secret: string): RequestHandler =>
(req, res, next) => {
const givenSignature = req.headers['x-spark-signature'];
const calculatedSignature = createHmac('sha1', secret)
.update(req.body)
.digest('hex');
if (givenSignature !== calculatedSignature) {
res.type('text/plain').status(403).send('Invalid Webex signature').end();
} else {
next();
}
};
const fetchWebhooks = async (): Promise<Webhook[]> =>
(await webexAPI.get<{items: Webhook[]}>('v1/webhooks')).data.items;
const updateWebhook = async (webhook: Webhook): Promise<Webhook> => {
console.log(`updating existing webhook: ${webhook.id}`);
const secret = webhook.secret ?? generateSecret();
const {data} = await webexAPI.put<Webhook>(`v1/webhooks/${webhook.id}`, {
name: DF_WEBHOOK_NAME,
targetUrl: env.WEBHOOK_URL,
status: 'active',
secret,
});
// secret returned by api is incorrect for this endpoint
return {...data, secret};
};
const createWebhook = async (): Promise<Webhook> => {
console.log('creating new webhook');
const {data} = await webexAPI.post<Webhook>('v1/webhooks', {
name: DF_WEBHOOK_NAME,
targetUrl: env.WEBHOOK_URL,
resource: 'messages',
event: 'created',
secret: generateSecret(),
});
return data;
};
const sendMessage = async (message: CreateMessageRequest) => {
await webexAPI.post('v1/messages', message);
};
const getWebexResponses = async (
text: string,
sessionID: string,
payload: JsonObject
): Promise<CreateMessageRequest[]> => {
switch (type) {
case 'CX': {
const dfResponses = await client.detectIntentSimple(
text,
sessionID,
'WEBEX',
// include payload for webhooks
payload
);
return (
dfResponses?.flatMap(
(message): CreateMessageRequest | CreateMessageRequest[] =>
// use text responses or convert payload for rich response as fallback
message.text?.text?.flatMap(text => ({text})) ??
(message.payload
? (structToJson(message.payload) as CreateMessageRequest)
: [])
) ?? []
);
}
case 'ES': {
const dfResponses = await client.detectIntentSimple(
text,
sessionID,
undefined,
// include payload for webhooks
payload
);
return (
dfResponses?.flatMap(
(message): CreateMessageRequest | CreateMessageRequest[] =>
// use text responses or convert payload for rich response as fallback
message.text?.text?.flatMap(text => ({text})) ??
(message.payload?.fields?.webex
? (structToJson(message.payload).webex as CreateMessageRequest)
: [])
) ?? []
);
}
}
};
(async () => {
const webhooks = await fetchWebhooks();
const existingWebhook = webhooks.find(({name}) => name === DF_WEBHOOK_NAME);
const webhook = existingWebhook
? await updateWebhook(existingWebhook)
: await createWebhook();
const app = express();
// authenticate incoming request
app.use(bodyParser.text({type: 'application/json'}));
app.use(webhookAuth(webhook.secret));
// incoming message webhook
app.post('/', async (req, res, next) => {
try {
const {data} = JSON.parse(req.body);
// don't reply to ourself
if (data.personEmail.includes('webex.bot')) {
res.status(200).end();
return;
}
const {data: messageData} = await webexAPI.get<MessageDetails>(
`/v1/messages/${data.id}`
);
const text = messageData.text ?? messageData.markdown ?? messageData.html;
if (!text) {
res.status(500).end();
return;
}
const parentID = messageData.parentId ?? messageData.id;
const sessionID =
messageData.roomType === 'direct' ? messageData.roomId : parentID;
const responses = await getWebexResponses(text, sessionID, messageData);
for (const response of responses) {
response.roomId = messageData.roomId;
// reply in thread if outside of direct message
if (messageData.roomType !== 'direct') {
response.parentId = parentID;
}
await sendMessage(response);
}
res.status(200).end();
} catch (e) {
next(e);
}
});
const server = app.listen(env.PORT);
process.on('SIGTERM', () => {
console.log('Shutting down Webex integration');
server.close(() => {
console.log('Webex integration server closed');
});
});
})();