The Aircall Labs team builds applications on top of the Aircall Public API to enrich customer experience.
The first released integration is a Weather app, Aircall Caller Insights will automatically display the weather, location and temperature based on callers' phone number.
We decided to open source its codebase, and to share every secret with developers.
Before getting started
Guide
As you can see, this page is split in two. Code explanation will be written here, in this left white pane. On the right side of the page is located a user interface allowing you to navigate in the Weather app codebase.
Technical stack used
The Weather app logic relies on a NodeJS web server.
We used Express as a web framework, axios to send HTTP requests, winston as a logger and dotenv to load environment variables from a .env
file.
As our main stack runs on AWS, we decided to use Docker and run the app with ECS. This part won't be covered in this guide.
As for the database, the only need we have is to store Company's authentication info (one API token and one webhook token). We decided to go with DynamoDB as it's fast, flexible and very easy to use!
Source tree
We followed a pretty straightforward source tree organization:
-
~/scripts/
: some scripts used to init database -
~/src/
: where all the source code of the app is-
./controllers/
: handling incoming HTTP requests' logic -
./libs/
: everything that interacts with outside tools (like OpenWeatherMap) -
./models/
: Company, Insight Cards and Regions models -
./models/
: JavaScript helpers -
./public/
: HTML, CSS and images -
./utils/
: exports basic functions
-
-
~/tests/
: tests written with Jest
Install
Check the README.md
file in the codebase and follow the steps described.
You'll need to:
- Define all the ENV variables in a
.env
file - Get Aircall's OAuth credentials
- Install Docker & DynamoDB
- Run the app with Docker
Once the install is done and the stack is running, your webserver will be available on http://localhost:5000
.
Public URL
Before deploying your app in production, you might want to test it locally. We recommend using ngrok to generate a public URL that you'll be able to submit to Aircall!
Once ngrok is installed, you can easily get your app behind a custom public uri:
./ngrok http 5000 --subdomain=my-weather-app-custom-url
We'll use this Public URL to implement the OAuth logic later.
Web server
App entry point
The src/server.js
file is the main entry point of the app. That's where we'll define the NodeJS web server. In this app, we use Express as a web framework.
We need bodyParser middleware to handle req.body
properties in POST
requests.
Using the express.static
middleware line 18 allows us to serve HTML and CSS files, located in the public/
folder. More information in express' documentation.
Define public endpoints
The src/routes.js
file is where all public endpoints are defined.
-
[GET] /
renders a basic JSON file. Via Main Controller. -
[GET] /health
renders a blank page, with a 200 HTTP Code. Via Main Controller. -
[GET] /oauth/install
redirects to Aircall's authorize URL (see the OAuth section below). Via OAuth Controller. -
[GET] /oauth/callback
is called by Aircall once the admin has authorized the application scope (see the OAuth section below). Via OAuth Controller. -
[GET] /oauth/success
redirects to Aircall' success page. Via OAuth Controller. -
[POST] /webhooks
will be called by Aircall, everytime a new Webhook event is emmited. Via Webhook Controller. -
[GET|POST|PUT] *
takes any other request to a 404 page.
We'll see in the following chapters how those endpoints are implemented.
OAuth
Implementing OAuth with Aircall has been documented in our API References and in the following tutorial:
โGet started with Aircall OAuthBuilding an app for Aircall users? Learn how the Aircall OAuth flow works!
Here's the ultimate flow we want to implement:
And it will look like this in Aircall's Dashboard:
The install_uri
endpoint
In this section, we are going to implement steps 2 and 3 of the diagram above.
As seen in the public endpoint section above, the install_uri
is defined in the src/routes.js
file, line 35.
Thanks to express, we now have the following endpoint available publicly:
[GET] https://my-weather-app-custom-url.ngrok.io/oauth/install
This endpoint uses the install
method, defined and exported from the OAuth Controller line 18.
Now that we have defined our GET
endpoint (step 2), we need to redirect any incoming requests to Aircall's consent page (step 3). The _install
method defines the full Aircall URL before redirecting the request to it.
Once interpolated, the URL will look like the following:
https://dashboard.aircall.io/oauth/authorize?client_id=YOUR_OAUTH_ID&redirect_uri=YOUR_PUBLIC_ENDPOINT/oauth/callback&response_type=code&scope=public_api
The redirect_uri
endpoint
In this section, we are going to implement step 5 of the diagram above.
Just as the install_uri
, the redirect_uri
is defined in the src/routes.js
file, line 41.
The following endpoint is then available publicly:
[GET] https://my-weather-app-custom-url.ngrok.io/oauth/callback
This endpoint uses the callback
method, defined and exported from the OAuth Controller line 35.
Aircall will request this endpoint with a GET
request and a code
will be sent along in the URL. The Company.createFromOauth
method, line 57, then exchanges the temporary OAuth authorization code
for a Public API access_token
. We will explain how that works in the next section.
Exchange OAuth authorization code
In this section, we are going to implement steps 6 and 8 of the diagram above.
The goal of this step is to exchange an OAuth authorization code
given by Aircall during the OAuth process into a Public API access_token
.
The createFromOauth
method is an async function, doing three things:
-
CompanyClass.exchangeOauthCode(oauthCode)
:
Exchange the OAuth Code (line 88) -
CompanyClass.createAircallWebhook(accessToken)
:
Automatically create an Aircall Webhook, used to send Insight Cards (line 91) -
new CompanyClass()
:
Storing in DynamoDB the Company's information (line 95)
1. CompanyClass.exchangeOauthCode(oauthCode)
To convert an OAuth authorization code
into an access_token
, we'll use the following POST
endpoint, documented in the API References:
[POST] https://api.aircall.io/v1/oauth/token
Following the API References, a payload needs to be attached to this POST
request with:
-
code
: the OAuth authorization code -
redirect_uri
: the/oauth/callback
URL built in previous step -
client_id
: given by Aircall -
client_secret
: given by Aircall -
grant_type
: should always beauthorization_code
Once the request is sent, Aircall will give us back the access_token
. This token can be used to make API calls to Aircall and retrieve users' information.
2. CompanyClass.createAircallWebhook(accessToken)
Once we have a Public API access_token
, we can use it to make our first Public API request. This createAircallWebhook
method automatically creates a Webhook, listening to call.created
Webhook events:
[POST] https://api.aircall.io/v1/webhook
HTTP headers:
{ Authorization: `Bearer ${accessToken}` }
Body:
{
custom_name: 'Aircall Weather',
url: 'https://my-weather-app-custom-url.ngrok.io/webhooks',
events: ['call.created'],
}
As seen in the API References, this POST
request needs to be authenticated and sent with three body params.
To authenticate a request, we use the
accessToken
that we retrieved before and pass it as a Bearer Token.-
As for the three body params:
-
custom_name
: optional but useful to identify which webhook is ours later; -
url
: the URL to which Aircall will send Webhook events to. We'll define that POST endpoint later in this guide; -
events
: we'll just need thecall.created
webhook event (see documentation here).
-
Once the request is sent, we can retrieve the webhook unique token
. We will use it later to identify from which Aircall account an event is sent from.
3. new CompanyClass()
We now have both the Public API accessToken
and our Webhook token
.
- The Public API
accessToken
will be used to send Insight Card API requests. - The Webhook
token
will be used to identify from which Aircall company a Webhook event is sent from.
With new CompanyClass()
, a new instance of CompanyClass
is created, with the apiToken
and the webhookToken
.
The save()
function, line 33, stores in DynamoDB those information.
Webhooks
The OAuth flow is handled. A webhook is automatically created at the end of it, listening to call.created
events.
Now, each time a inbound or outbound call will happen on the user Aircall account, Aircall will send a POST
request to our server, on the /webhooks
endpoint. This endpoint is declared in the routes.js
file, line 54:
Webhook controller
The index
method handles any incoming POST
request on the /webhooks
endpoint.
Line 21, we set the HTTP code to 202 to avoid Webhooks' automatic deactivation.
Read more on automatic deactivation of Webhooks in Aircall API References.
Once we checked that the event sent by Aircall is included in the list of events authorized (line 29), the startFlow
method is called and a JSON file is rendered.
startFlow()
The startFlow()
function is where the magic happens.
It authenticates request, fetching from the DynamoDB database the associated company:
Extract location information from the end-user's number:
Fetch weather information from OpenWeatherMap's API:
Once location has been extracted and weather data has been requested, we can send an Insight Card. Jump to the following section of this guide!
Insight Card
Read more about Insight Cards in our API References or in our dedicated tutorial!
If a region and its weather information is successfully extracted from a Webhook call.created
event, an Insight Card object is created and sent.
Sending an Insight Card to Aircall consists in executing the following request:
[POST] https://api.aircall.io/v1/calls/:id/insight_cards
Body:
{
"contents": [
{
"type": "title",
"text": "Weather"
},
{
"type": "shortText",
"label": "City",
"text": "New York City",
"link": "https://en.wikipedia.org/w/index.php?search=New York City"
},
{
"type": "shortText",
"label": "Temperature",
"text": "80โ",
},
{
"type": "shortText",
"label": "Clouds",
"text": "โ
๏ธ"
}
]
}
We are creating Insight Cards via a JavaScript object:
Once the Insight Card is created, we can then send it via Aircall Public API, by just passing the apiToken
retrieved from the database earlier:
Conclusion
In this guide, we implemented two main flows of the Aircall Public API:
- OAuth, allowing users to install the Weather app.
- Webhooks, sending events to our web server each time a call is started.
We didn't cover the HTML files, our usage of Docker and Docker compose, tests implementation, DyanmoDB, the phone number to region matching and much more. Be curious and navigate the code base!
Enjoy,
The Aircall Labs team.
-
Folders
-
scripts
- createDBTables.js
-
src
-
controllers
- main_controller.js
- oauth_controller.js
- webhooks_controller.js
-
libs
- openweathermap.js
-
models
-
region
- australia.js
- belgium.js
- brazil.js
- canada.js
- france.js
- generic.js
- germany.js
- index.js
- ireland.js
- italy.js
- mexico.js
- morocco.js
- netherlands.js
- spain.js
- sweden.js
- switzerland.js
- united_kingdom.js
- usa.js
- company.js
- insight_card.js
-
-
modules
- api_requester.js
- db.js
- env.js
- logger.js
-
public
- error.html
- install.html
- style.css
-
utils
- strings.js
- routes.js
- server.js
-
-
tests
-
models
- region.test.js
-
utils
- strings.test.js
-
- .env_template
- docker-compose-ci.yml
- docker-compose.yml
- Dockerfile
- package.json
- README.md
FROM node:12-alpine as base
WORKDIR /app
COPY package.json .
FROM base as dependencies
RUN npm install --production
########## RELEASE ##########
FROM base as release
COPY --from=dependencies /app/node_modules node_modules
COPY . .
LABEL "com.datadoghq.ad.logs"='[{"source": "nodejs", "service": "weather"}]'
EXPOSE 5000
CMD ["node", "-r", "dotenv/config", "src/server.js"]
########## TEST ##########
FROM dependencies as test
COPY . .
RUN npm install --development
---
version: '3.7'
services:
api:
build:
context: .
labels:
app_version: "${CIRCLE_SHA1:-local}"
image: weather
env_file: .env
tty: true
stdin_open: true
ports:
- "5000:5000"
depends_on: [dynamodb]
command: |
npm start
dynamodb:
image: amazon/dynamodb-local
hostname: dynamodb-local
container_name: dynamodb-local
command: ["-jar", "DynamoDBLocal.jar", "-sharedDb", "-inMemory"]
ports:
- "8000:8000"
/**
* String helpers
*/
/**
* Capitalize a string
*/
let _capitalize = (text) => {
if (!text || typeof text !== 'string') {
return '';
}
return `${text[0].toUpperCase()}${text.slice(1, text.length)}`;
};
module.exports = {
capitalize: _capitalize,
};
/**
* Main router file
* Declare each endpoint and redirect requests to the right controller
*/
const mainCtrl = require('./controllers/main_controller'),
oauthCtrl = require('./controllers/oauth_controller'),
webhooksCtrl = require('./controllers/webhooks_controller'),
Logger = require('./modules/logger');
const _init = (app) => {
if (!app) {
throw '[Router] app is not defined';
}
// Log each request
app.use(logRequest);
/**
* [GET] /
* Render the main page
*/
app.get('/', mainCtrl.index);
/**
* [GET] /health
* Healthcheck endpoint
*/
app.get('/health', mainCtrl.health);
/**
* [GET] /oauth/install
* Install page, redirects to Aircall authorize URL
*/
app.get('/oauth/install', oauthCtrl.install);
/**
* [GET] /oauth/callback
* Called by Aircall once the admin has authorized the application scope
*/
app.get('/oauth/callback', oauthCtrl.callback);
/**
* [GET] /oauth/success
* Success page, redirect to Aircall' success
*/
app.get('/oauth/success', oauthCtrl.success);
/**
* [POST] /webhooks
* Fetch weather information and post an Insight Card
* Called by Aircall each time a webhook event is posted
*/
app.post('/webhooks', webhooksCtrl.index);
/**
* [GET/POST/PUT] Handle 404
*/
app.get('*', render404);
app.post('*', render404);
app.put('*', render404);
};
/**
* Render a HTTP 404 code with error message
*/
const render404 = (req, res) => {
Logger.warn(`[routes][${req.method}][${req.url}] - 404 not found`);
res.status(404).json({
error: 'route not found',
});
};
/**
* Log request's info
*/
const logRequest = (req, res, next) => {
// Remove sensitive information from the URL before logging it
const cleanedURL = !!req.url && req.url.split('?')[0];
// Avoid logging each `[GET] /health` requests
if (cleanedURL !== '/health') {
Logger.info(`[Router][${req.method}]` + cleanedURL, {
http_method: req.method,
url: cleanedURL,
});
}
next();
};
module.exports = {
init: _init,
};
/**
* Winston logger module
* https://github.com/winstonjs/winston
*/
const { createLogger, format, transports } = require('winston');
let _logger = null;
// Prettify the console output in LOCAL
let consoleOptions =
process.env.LOCAL === 'true'
? { format: format.combine(format.colorize(), format.simple()) }
: {};
let maxLoggingLevel = process.env.DEBUG === 'true' ? 'info' : 'warn';
module.exports =
_logger ||
createLogger({
level: maxLoggingLevel,
exitOnError: false,
format: format.json(),
transports: [new transports.Console(consoleOptions)],
});
/**
* Env module
* Fetch information from process.env
*/
const Logger = require('./../modules/logger');
/**
* Fetch varName from process.env
* Logs an error if varName is not defined (and hideLog is false)
*/
const _fetch = (varName, hideLog) => {
const value = process.env[varName];
if (!value && !hideLog) {
Logger.error(`[Env] ${varName} is not defined.`);
}
return value || '';
};
module.exports = {
fetch: _fetch,
};
/**
* Database (DynamoDB)
* Handles db connection
*/
const AWS = require('aws-sdk'),
Env = require('./env'),
Logger = require('./logger.js');
let dynamoClient = null;
/**
* Init dynamo client with the proper aws config
*/
const _init = () => {
if (!dynamoClient) {
let awsConfig = { region: Env.fetch('AWS_DEFAULT_REGION') };
// AWS_DYNAMO_ENDPOINT needs to be defined only when working in local
if (!!Env.fetch('AWS_DYNAMO_ENDPOINT', true)) {
awsConfig['endpoint'] = Env.fetch('AWS_DYNAMO_ENDPOINT');
}
AWS.config.update(awsConfig);
dynamoClient = new AWS.DynamoDB.DocumentClient();
}
return dynamoClient;
};
module.exports = {
init: _init,
};
/**
* Aircall API requester
* Sends HTTPS requests to Aircall's API
*/
const axios = require('axios'),
Logger = require('./logger'),
Env = require('./env');
/**
* Sends a POST request to Aircall Public API
*/
const _post = async (path, headers, body) => {
const apiUrl = Env.fetch('AIRCALL_API_URL');
if (!path) {
throw new Error('[ApiRequester][_post] path is not defined.');
}
headers = headers || {};
// Aircall's API works better with `application/json` type
headers['Content-Type'] = 'application/json';
const url = apiUrl + path;
let httpOptions = { headers };
try {
let payload = await axios.post(url, body, httpOptions);
return payload.data;
} catch (e) {
throw new Error(`[ApiRequester][_post][${url}] ${e.message}`);
}
};
module.exports = {
POST: _post,
};
/**
* Main file, setup the app server
* Entry point of the Weather app
*/
const express = require('express'),
bodyParser = require('body-parser'),
Logger = require('./modules/logger'),
path = require('path'),
router = require('./routes');
const app = express();
app.use(bodyParser.urlencoded({ extended: false }));
app.use(bodyParser.json());
// Define public assets folder
app.use(express.static(path.join(__dirname, 'public')));
const PORT = process.env.PORT || 5000;
app.listen(PORT, () => {
Logger.info('[server] server started');
Logger.info('[server] visit http://localhost:' + PORT);
});
// Init routes module
router.init(app);
/**
* Main Controller
*/
const path = require('path'),
Logger = require('./../modules/logger');
/**
* [GET] /
* Render main page
*/
const _index = (req, res) => {
res.json({
error: null,
data: {
message: 'Hello! ๐ค',
},
});
};
/**
* [GET] /health
* Render an empty 200 page
* Used by cron to check if server is up
*/
const _health = (req, res) => {
res.status(200).json({ status: 'available' });
};
module.exports = {
index: _index,
health: _health,
};
/**
* OAuth Controller
*
* Read this tutorial for more information:
* https://developer.aircall.io/tutorials/how-aircall-oauth-flow-works/
*/
const path = require('path'),
Logger = require('./../modules/logger'),
Env = require('./../modules/env'),
Requester = require('./../modules/api_requester'),
Company = require('./../models/company');
/**
* [GET] /oauth/install
* Redirect request to Aircall's consent page
* Second step of Aircall OAuth flow: https://developer.aircall.io/api-references/#oauth-flow
*/
const _install = (req, res) => {
const weatherRedirectUri = `${Env.fetch('WEATHER_URL')}/oauth/callback`;
const oauthUrl = Env.fetch('AIRCALL_OAUTH_AUTHORIZE_URL');
const oauthId = Env.fetch('AIRCALL_OAUTH_ID');
// Define Aircall's consent page url
const url = `${oauthUrl}?client_id=${oauthId}&redirect_uri=${weatherRedirectUri}&response_type=code&scope=public_api`;
res.redirect(url);
};
/**
* [GET] /oauth/callback
* Requested by Aircall once the user has authorized the application
* Aircall must send a `code` query params
* Fifth step of Aircall OAuth flow: https://developer.aircall.io/api-references/#oauth-flow
*/
const _callback = async (req, res) => {
const errorPath = path.join(__dirname, '..', 'public', 'error.html');
if (!req.query) {
Logger.error('[OauthCtrl][_callback] No query params provided');
res.sendFile(errorPath);
return;
}
if (!!req.query.error) {
Logger.error(
'[OauthCtrl][_callback] Error:' + JSON.stringify(req.query.error)
);
res.sendFile(errorPath);
return;
}
if (!req.query.code) {
Logger.error('[OauthCtrl][_callback] No authorization code provided.');
res.sendFile(errorPath);
return;
}
try {
// Create and save a Company from `code` sent by Aircall
await Company.createFromOauth(req.query.code);
const filePath = path.join(__dirname, '..', 'public', 'install.html');
res.sendFile(filePath);
} catch (e) {
Logger.error(e.message);
res.sendFile(errorPath);
}
};
/**
* [GET] /oauth/success
*/
const _success = (req, res) => {
const oauthSuccessUrl = Env.fetch('AIRCALL_OAUTH_SUCCESS_URL');
res.redirect(oauthSuccessUrl);
};
module.exports = {
install: _install,
callback: _callback,
success: _success,
};
/**
* Webhooks Controller
*
* Read this tutorial for more information:
* https://developer.aircall.io/tutorials/send-insights-to-agents/
*/
const Logger = require('./../modules/logger'),
OpenWeather = require('./../libs/openweathermap'),
Company = require('./../models/company'),
InsightCard = require('./../models/insight_card'),
Region = require('./../models/region');
const WEBHOOK_EVENTS_HANDLED = ['call.created'];
/**
* [POST] /webhooks
* Endpoint request by Aircall each time a Webhook event is fired
*/
const _index = (req, res) => {
// Avoid Aircall's webhook deactivation by setting HTTP Code to 202 Accepted
res.status(202);
if (!req.body || !req.body.event) {
res.json({ error: 'Body or event fields not defined' });
return;
}
// Only handle specifc Aircall events
if (!!WEBHOOK_EVENTS_HANDLED.includes(req.body.event)) {
if (!!req.body.data && !!req.body.data.id) {
// Asynchronously triggers the flow
Logger.info(
`[WebhooksCtrl][_index] Webhook for call ${req.body.data.id} received.`
);
startFlow(req.body);
res.json({
message: `Processing request for callId ${req.body.data.id}...`,
});
} else {
res.json({
error: 'data or data.id not defined',
});
}
} else {
res.json({
error: `'${req.body.event}' event not handled`,
});
}
};
/*********************************************************/
/* PRIVATE FUNCTIONS */
/*********************************************************/
/**
* Handles webhook payload logic:
* 1. Authenticate request
* 2. Extract location information from number
* 3. Get weather
* 4. Send Insight Card
*/
const startFlow = async (webhookPayload) => {
const callData = !!webhookPayload && webhookPayload.data;
if (!callData) {
Logger.warn('[WebhooksCtrl][startFlow] callData undefined');
return;
}
try {
// 1. Authenticate request
let companyData = await Company.getFromWebhookToken(webhookPayload.token);
if (!companyData) {
return;
}
// 2. Extract location information from number
let region = new Region(callData.raw_digits);
let location = region.get();
if (!location) {
return;
}
// 3. Get weather
let weatherData = await OpenWeather.getCityWeather(
location.city,
location.country
);
if (!weatherData) {
return;
}
// 4. Build & send Insight Card
let card = new InsightCard(location, weatherData);
card.send(callData.id, companyData.ApiToken);
} catch (e) {
Logger.error(e.message);
}
};
module.exports = {
index: _index,
};
/**
* Company model
*
* Stores the following fields in DynamoDB
* - createdAt: UTC string of when the object was created
* - apiToken: string used to send API requests to Aircall
* - webhookToken: token sent in every Webhook payload Aircall sends
*/
const Env = require('./../modules/env'),
Logger = require('./../modules/logger'),
Requester = require('./../modules/api_requester'),
dynamoClient = require('./../modules/db').init();
const DB_TABLE_NAME = Env.fetch('DYNAMODB_TABLE') || 'Companies';
/**
* Company JS class
*/
class CompanyClass {
createdAt = null;
apiToken = null;
webhookToken = null;
constructor(apiToken, webhookToken) {
this.createdAt = new Date().toUTCString();
this.apiToken = apiToken;
this.webhookToken = webhookToken;
}
/**
* Save a company instance in DynamoDB
*/
async save() {
let params = {
TableName: DB_TABLE_NAME,
Item: {
CreatedAt: this.createdAt,
ApiToken: this.apiToken,
WebhookToken: this.webhookToken,
},
};
try {
await dynamoClient.put(params).promise();
} catch (e) {
throw new Error('[Company][save] Error:' + e.message);
}
}
/**
* Static methods
*/
/**
* Retrieve Company in DyanoDB
*/
static async getFromWebhookToken(webhookToken) {
const params = {
TableName: DB_TABLE_NAME,
KeyConditionExpression: 'WebhookToken = :i',
ExpressionAttributeValues: {
':i': webhookToken,
},
};
let company = null;
try {
const { Items, Count } = await dynamoClient.query(params).promise();
if (Count > 1) {
Logger.warn(
`[Company][getFromWebhookToken] More than 1 company found with webhookToken ${webhookToken}`
);
}
company = Items[0];
} catch (e) {
throw new Error('[Company][getFromWebhookToken] Error:' + e.message);
}
return company;
}
/**
* Create a company by:
* 1. creating an Aircall access token
* 2. creating an Aircall Webhook
*/
static async createFromOauth(oauthCode) {
// 1. Create Aircall accessToken
let accessToken = await CompanyClass.exchangeOauthCode(oauthCode);
// 2. Create Aircall Webhook
let webhookToken = await CompanyClass.createAircallWebhook(accessToken);
// 3. Save Company object
let company = new CompanyClass(accessToken, webhookToken);
await company.save();
return company;
}
/**
* Create an Access Token to be used with Aircall's API
*/
static async exchangeOauthCode(oauthCode) {
if (!oauthCode) {
throw new Error('[Company][exchangeOauthCode] oauthCode is undefined');
}
const body = {
code: oauthCode,
redirect_uri: `${Env.fetch('WEATHER_URL')}/oauth/callback`,
client_id: Env.fetch('AIRCALL_OAUTH_ID'),
client_secret: Env.fetch('AIRCALL_OAUTH_SECRET'),
grant_type: 'authorization_code',
};
try {
let payload = await Requester.POST(`/v1/oauth/token`, null, body);
return payload.access_token;
} catch (e) {
Logger.error(e.message);
}
}
/**
* Create an Aircall Webhook
* and return the Webhook token
*/
static async createAircallWebhook(accessToken) {
if (!accessToken) {
throw new Error(
'[Company][createAircallWebhook] accessToken is undefined'
);
}
const httpHeaders = { Authorization: `Bearer ${accessToken}` };
const body = {
custom_name: 'Aircall Weather',
url: `${Env.fetch('WEATHER_URL')}/webhooks`,
events: ['call.created'],
};
try {
let payload = await Requester.POST(`/v1/webhooks`, httpHeaders, body);
return payload.webhook.token;
} catch (e) {
Logger.error(e.message);
}
}
}
module.exports = CompanyClass;
/**
* Insight Card model
*
* Check API documentation
* https://developer.aircall.io/api-references/#insight-cards
*/
const Env = require('./../modules/env'),
Logger = require('./../modules/logger'),
Requester = require('./../modules/api_requester');
/**
* InsightCard JS class
*/
class InsightCardClass {
contents = [];
/**
* Build the JSON `contents` object that has to be sent in Insight Cards
*/
constructor(locationData, weatherData) {
this.contents = [];
// City/Region
if (!!locationData && locationData.city) {
this.contents.push({
type: 'shortText',
label: 'City',
text: locationData.city,
link: `https://en.wikipedia.org/w/index.php?search=${locationData.city}`,
});
}
// Weather Data
if (!!weatherData) {
if (!!weatherData.temps) {
this.contents.push({
type: 'shortText',
label: 'Temperature',
text: weatherData.temps,
});
}
if (!!weatherData.description) {
this.contents.push({
type: 'shortText',
label: weatherData.description || 'Description',
text: weatherData.emoji,
});
}
}
if (this.contents.length > 0) {
this.contents.push({
type: 'title',
text: 'Weather',
});
}
}
/**
* Send an Insight Card request to Aircall asynchronously
*/
send(callId, apiToken) {
if (!this.contents.length) {
return;
}
const path = `/v1/calls/${callId}/insight_cards`;
const headers = { Authorization: `Bearer ${apiToken}` };
Logger.info(
`[InsightCard][send] Sending insight card for call ${callId}`,
this.contents
);
try {
Requester.POST(path, headers, { contents: this.contents });
} catch (e) {
Logger.error(e.message);
}
}
}
module.exports = InsightCardClass;
/**
* GenericCountry class implementing get functions
* Each country must extend this class
*
* - name: full name of the country
* - type: can be city, region, region_capital
* - codes: a JSON object with prefixes as keys and city/region as values:
* { "+33105": "Ile de France",... }
*/
class GenericCountry {
name = null;
type = null;
codes = {};
getName() {
return this.name;
}
getType() {
return this.type;
}
getCodes() {
return this.codes;
}
}
module.exports = GenericCountry;
const GenericCountry = require('./generic.js');
class FranceCountry extends GenericCountry {
name = 'Ireland';
type = 'city';
codes = {
'+3531': 'Dublin',
'+35321': 'Cork',
...
'+35399': 'Kilronan',
};
}
module.exports = FranceCountry;
const GenericCountry = require('./generic.js');
class BelgiumCountry extends GenericCountry {
name = 'Belgium';
type = 'region';
codes = {
'+3210': 'Wavre',
'+3211': 'Hasselt',
...
'+329': 'Gand',
};
}
module.exports = BelgiumCountry;
const GenericCountry = require('./generic.js');
class BrazilCountry extends GenericCountry {
name = 'Brazil';
type = 'region_capital';
codes = {
'+5511': 'Sao Paulo',
'+5521': 'Rio de Janeiro',
...
'+5599': 'Maranhao',
};
}
module.exports = BrazilCountry;
const GenericCountry = require('./generic.js');
class USACountry extends GenericCountry {
name = 'USA';
type = 'region_capital';
codes = {
'+1201': 'Jersey City,NJ',
'+1202': 'District of Columbia',
'+1203': 'Bridgeport,CT',
...
'+1989': 'Saginaw,MI',
};
}
module.exports = USACountry;
const GenericCountry = require('./generic.js');
class SpainCountry extends GenericCountry {
name = 'Spain';
type = 'city';
codes = {
'+3481': 'Madrid',
'+34820': 'Avila',
'+34821': 'Segovia',
...
'+34988': 'Orense',
};
}
module.exports = SpainCountry;
const GenericCountry = require('./generic.js');
class SwedenCountry extends GenericCountry {
name = 'Sweden';
type = 'city';
codes = {
'+4611': 'Norrkรถping',
'+46120': 'ร
tvidaberg',
...
'+46981': 'Vittangi',
};
}
module.exports = SwedenCountry;
const GenericCountry = require('./generic.js');
class AustraliaCountry extends GenericCountry {
name = 'Australia';
type = 'city';
codes = {
'+61233': 'Gosford',
...
'+61899': 'Geraldton',
};
}
module.exports = AustraliaCountry;
/**
* Parse a phone number and gets the city/country out of it
*/
const libPhoneNumber = require('libphonenumber-js/max'),
Logger = require('../../modules/logger.js');
// Require all available countries
const countryClasses = {
AU: require('./australia.js'),
BE: require('./belgium.js'),
BR: require('./brazil.js'),
CA: require('./canada.js'),
CH: require('./switzerland.js'),
DE: require('./germany.js'),
ES: require('./spain.js'),
FR: require('./france.js'),
GB: require('./united_kingdom.js'),
IE: require('./ireland.js'),
IT: require('./italy.js'),
MA: require('./morocco.js'),
MX: require('./mexico.js'),
NL: require('./netherlands.js'),
SE: require('./sweden.js'),
US: require('./usa.js'),
};
class RegionClass {
phoneNumber = null;
country = null;
constructor(phoneNumberString) {
this.phoneNumber =
!!phoneNumberString &&
libPhoneNumber.parsePhoneNumberFromString(phoneNumberString);
if (!this.phoneNumber) {
Logger.warn(
`[RegionClass][constructor] info of the following phoneNumber could not be extracted ${phoneNumberString}`
);
return;
}
if (!this.phoneNumber.country) {
Logger.warn(
`[RegionClass][constructor] countryCode is undefined for ${this.anonymizedPhoneNumber()}`
);
return;
}
this.country =
!!countryClasses[this.phoneNumber.country] &&
new countryClasses[this.phoneNumber.country]();
}
/**
* Returns an object with city and country names
*/
get() {
if (!this.phoneNumber || !this.country) {
return;
}
let phoneNumberString = this.phoneNumber.number;
let city = null;
let codes = this.country.getCodes();
while (!city && !!phoneNumberString.length) {
if (!!codes[phoneNumberString]) {
city = codes[phoneNumberString];
}
phoneNumberString = phoneNumberString.slice(0, -1);
}
let region = null;
if (!city) {
Logger.warn(
`[RegionClass][get] region not found for number ${this.anonymizedPhoneNumber()} - type is ${this.phoneNumber.getType()}.`
);
return;
}
Logger.info(
`[RegionClass][get] region found for number ${this.anonymizedPhoneNumber()}: ${city}`
);
return {
city,
country: this.country.getName(),
};
}
/**
* Replace the last 4 digits of the number by XXXX
*/
anonymizedPhoneNumber() {
if (!this.phoneNumber || !this.phoneNumber.number) {
return '';
}
let phoneNumberString = this.phoneNumber.number || '';
return phoneNumberString.length > 4
? phoneNumberString.slice(0, phoneNumberString.length - 4) + 'XXXX'
: phoneNumberString;
}
}
module.exports = RegionClass;
const GenericCountry = require('./generic.js');
class MexicoCountry extends GenericCountry {
name = 'Mexico';
type = 'city';
codes = {
'+5255': 'Mexico City',
'+5233': 'Guadalajara',
...
'+52747': 'Zumpango Del Rio',
};
}
module.exports = MexicoCountry;
const GenericCountry = require('./generic.js');
class UKCountry extends GenericCountry {
name = 'United Kingdom';
type = 'city';
codes = {
'+44113': 'Leeds',
'+44114': 'Sheffield',
...
'+4499': 'Southend-on-Sea',
};
}
module.exports = UKCountry;
const GenericCountry = require('./generic.js');
class ItalyCountry extends GenericCountry {
name = 'Italy';
type = 'city';
codes = {
'+3910': 'Genova',
'+3911': 'Torino',
...
'+3999': 'Taranto',
};
}
module.exports = ItalyCountry;
const GenericCountry = require('./generic.js');
class NetherlandsCountry extends GenericCountry {
name = 'Netherlands';
type = 'city';
codes = {
'+3110': 'Rotterdam',
'+31111': 'Zierikzee',
...
'+3179': 'Zoetermeer',
};
}
module.exports = NetherlandsCountry;
const GenericCountry = require('./generic.js');
class GermanyCountry extends GenericCountry {
name = 'Germany';
type = 'region';
codes = {
'+49201': 'Essen',
'+49202': 'Wuppertal',
...
'+499978': 'Schonthal Oberpfalz',
};
}
module.exports = GermanyCountry;
const GenericCountry = require('./generic.js');
class BrazilCountry extends GenericCountry {
name = 'Canada';
type = 'region_capital';
codes = {
'+1204': 'Winnipeg',
'+1226': 'Brantford',
...
'+1905': 'Brampton',
};
}
module.exports = BrazilCountry;
const GenericCountry = require('./generic.js');
class FranceCountry extends GenericCountry {
name = 'France';
type = 'city';
codes = {
'+33105': 'Paris',
'+3313021': 'Versailles',
...
'+339769': 'Reunion',
};
}
module.exports = FranceCountry;
const GenericCountry = require('./generic.js');
class SwitzerlandCountry extends GenericCountry {
name = 'Switzerland';
type = 'region_city';
codes = {
'+4121': 'Lausanne',
'+4122': 'Geneve',
...
'+4191': 'Bellinzona',
};
}
module.exports = SwitzerlandCountry;
const GenericCountry = require('./generic.js');
class MoroccoCountry extends GenericCountry {
name = 'Morocco';
type = 'city';
codes = {
'+212520': 'Casablanca',
'+2125243': 'Marrakech',
...
'+2125399': 'Al Hoceima',
};
}
module.exports = MoroccoCountry;
<!DOCTYPE html>
<html>
<head>
<meta http-equiv="Content-Type" content="text/html; charset=utf-8" />
<title>Aircall Weather | Install</title>
<link rel="stylesheet" href="/style.css" />
<link rel="icon" type="image/png" sizes="96x96" href="/favicon-96x96.png">
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png">
<link rel="icon" type="image/png" sizes="16x16" href="/favicon-16x16.png">
</head>
<body>
<div class="content">
<div class="content-block">
<header>
<img src="/logo.svg" class="logo" />
<div>
<h1>Weather</h1>
<small>by aircall</small>
</div>
</header>
<p class="center">Uh oh! Something went wrong...</p>
<p class="center">
Please try to install this app again in a few minutes.
</p>
</div>
</div>
</body>
</html>
<!DOCTYPE html>
<html>
<head>
<meta http-equiv="Content-Type" content="text/html; charset=utf-8" />
<title>Aircall Weather | Install</title>
<link rel="stylesheet" href="/style.css" />
<link rel="icon" type="image/png" sizes="96x96" href="/favicon-96x96.png">
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png">
<link rel="icon" type="image/png" sizes="16x16" href="/favicon-16x16.png">
</head>
<body>
<div class="content">
<div class="content-block">
<header>
<img src="/logo.svg" class="logo" />
<div>
<h1>Weather</h1>
<small>by aircall</small>
</div>
<div class="header-success">
Installed!
</div>
</header>
<img src="/phone.png" class="phone" />
<p>
The <strong>Weather app</strong> is now installed on your Aircall phone!
</p>
<p>
It will fetch weather information from Australia, Belgium, Brazil,
Canada, France, Germany, Great Britain, Ireland, Itlay, Mexico, Morocco, Netherlands,
Spain, Sweden, Switzerland and USA.
</p>
<p>
<strong>Next step:</strong> configure the Numbers you want your integration to be linked on in your Aircall Dashboard! Only agents with the associated numbers will see weather insights.
</p>
<button onclick="onClose()">
Link your Numbers now
</button>
<p class="note">
<strong>Note:</strong> the Weather app will do its best to match a
city/region with each of your contacts' phone number. If a phone
number can't be geo-located (like mobile, toll-free...), the Weather
card won't be displayed during the call. Available countries are
listed above and below!
</p>
<p class="countries">
๐ฆ๐บ ๐ง๐ช ๐ง๐ท ๐จ๐ฆ ๐ซ๐ท ๐ฉ๐ช ๐ฌ๐ง ๐ฎ๐ช ๐ฎ๐น ๐ฒ๐ฝ ๐ฒ๐ฆ ๐ณ๐ฑ ๐ช๐ธ ๐ธ๐ช ๐จ๐ญ ๐บ๐ธ
</p>
</div>
</div>
<script type="text/javascript">
const onClose = () => {
!!window.close && window.close();
window.location.href = '/oauth/success';
};
</script>
</body>
</html>
html,
body,
div,
span,
h1,
h2,
h3,
h4,
h5,
h6,
p,
blockquote,
pre,
a,
code,
img,
strong,
button,
ul,
li {
margin: 0;
padding: 0;
border: 0;
font-size: 100%;
font: inherit;
vertical-align: baseline;
}
body {
margin: 0;
padding: 0;
font-family: 'Fellix', -apple-system, Helvetica Neue, Arial, sans-serif;
font-size: 14px;
line-height: 1;
}
html,
body {
height: 100%;
color: #666d73;
}
::-moz-selection {
background: rgba(48, 113, 201, 0.1);
}
::selection {
background: rgba(48, 113, 201, 0.1);
}
.content {
display: flex;
flex-direction: column;
height: 100%;
align-items: center;
}
.content-block {
display: flex;
flex-direction: column;
justify-content: center;
margin-top: 10px;
background: white;
border-radius: 4px;
width: 80%;
max-width: 500px;
}
h1 {
font-size: 28px;
font-weight: 600;
color: #111;
}
header {
display: flex;
align-items: center;
margin-bottom: 10px;
user-select: none;
}
header div {
display: flex;
flex-direction: column;
justify-content: center;
flex: 1;
}
header img.logo {
width: 40px;
margin-right: 20px;
}
header small {
margin-left: 0.4em;
font-size: 0.7em;
}
header .header-success {
text-align: right;
font-size: 1.2em;
font-weight: 600;
color: #307fe2;
}
p {
text-align: justify;
margin-top: 10px;
line-height: 1.4;
}
p.center {
text-align: center;
}
p.note {
font-size: 0.8em;
color: #aaa;
}
p.countries {
text-align: center;
opacity: 0.8;
}
strong {
font-weight: 700;
}
img.phone {
min-height: 100px;
width: 130px;
margin: 0 auto;
user-select: none;
}
button {
display: block;
background-color: #307fe2;
margin: 20px auto;
padding: 20px;
border-radius: 4px;
font-weight: 600;
color: white;
text-decoration: none;
transition: background-color 0.1s ease-in-out;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.3);
user-select: none;
cursor: pointer;
}
button:hover {
background-color: #3071c9;
}
@font-face {
font-family: 'Fellix';
src: url('https://cdn.aircall.io/fonts/Fellix-Thin.otf');
font-weight: 100;
font-style: normal;
}
@font-face {
font-family: 'Fellix';
src: url('https://cdn.aircall.io/fonts/Fellix-Light.otf');
font-weight: 300;
font-style: normal;
}
@font-face {
font-family: 'Fellix';
src: url('https://cdn.aircall.io/fonts/Fellix-Regular.otf');
font-weight: 500;
font-style: normal;
}
@font-face {
font-family: 'Fellix';
src: url('https://cdn.aircall.io/fonts/Fellix-Medium.otf');
font-weight: 600;
font-style: normal;
}
@font-face {
font-family: 'Fellix';
src: url('https://cdn.aircall.io/fonts/Fellix-SemiBold.otf');
font-weight: 700;
font-style: normal;
}
@font-face {
font-family: 'Fellix';
src: url('https://cdn.aircall.io/fonts/Fellix-Bold.otf');
font-weight: 900;
font-style: normal;
}
/**
* OpenWeatherMap API
* Fetching weather information
*/
const axios = require('axios'),
Logger = require('./../modules/logger'),
Env = require('./../modules/env'),
StringUtils = require('./../utils/strings');
const IMPERIAL_COUNTRIES = ['usa'];
const WEATHER_EMOJIS = {
thunderstorm: 'โ',
drizzle: '๐ง',
rain: '๐ง',
snow: '๐จ',
mist: '๐ง',
smoke: 'โ๏ธ',
haze: 'โ๏ธ',
dust: 'โ๏ธ',
fog: 'โ๏ธ',
sand: 'โ๏ธ',
ash: 'โ๏ธ',
squall: 'โ๏ธ๐จ',
tornado: '๐ช',
clear: 'โ๏ธ',
clouds: 'โ๏ธ',
};
/**
* Takes a city and a country
* Returns location information
*/
const _getCityWeather = async (city, country) => {
if (!city) {
throw new Error('[OpenWeatherMap][_getCityWeather] city is not defined.');
}
const apiUrl = Env.fetch('OPEN_WEATHER_API_URL');
const apiKey = Env.fetch('OPEN_WEATHER_API_KEY');
let unit = getTemperatureUnit(country);
let location = `${city}`;
if (!!country) {
location = `${location},${country}`;
}
const url = `${apiUrl}/weather?q=${encodeURI(location)}&units=${unit}`;
Logger.info(
`[OpenWeatherMap][_getCityWeather] requesting temperature for ${location}`
);
try {
let payload = await axios.get(`${url}&APPID=${apiKey}`);
const unitSymbol = unit === 'imperial' ? 'โ' : 'โ';
const temperature =
!!payload &&
!!payload.data &&
!!payload.data.main &&
!!payload.data.main &&
`${Math.round(payload.data.main.temp)}${unitSymbol}`;
const weather =
!!payload &&
!!payload.data &&
!!payload.data.weather &&
!!payload.data.weather.length > 0 &&
!!payload.data.weather[0] &&
payload.data.weather[0];
Logger.info(
`[OpenWeatherMap][_getCityWeather] temperature found ${location}`
);
return {
temps: temperature,
emoji: getWeatherEmoji(weather.main),
description: StringUtils.capitalize(weather.description),
};
} catch (e) {
throw new Error(
`[OpenWeatherMap][_getCityWeather] Error for URL ${url}: ${e.message}`
);
}
return null;
};
/*********************************************************/
/* PRIVATE FUNCTIONS */
/*********************************************************/
/**
* Fetch emoji from WEATHER_EMOJIS
*/
const getWeatherEmoji = (main) => {
if (!main) {
return;
}
return WEATHER_EMOJIS[main.toLowerCase()]
? WEATHER_EMOJIS[main.toLowerCase()]
: main;
};
/**
* Get temperature unit
*/
const getTemperatureUnit = (country) => {
if (!!country && !!IMPERIAL_COUNTRIES.includes(country.toLowerCase())) {
return 'imperial';
}
return 'metric';
};
module.exports = {
getCityWeather: _getCityWeather,
};
/**
* Test the Strings util
*/
const StringUtils = require('../../src/utils/strings');
describe('Capitalize', () => {
test('empty string', () => {
let result = StringUtils.capitalize('');
expect(result).toBe('');
});
test('when null', () => {
let result = StringUtils.capitalize();
expect(result).toBe('');
});
test('when not a string', () => {
let result = StringUtils.capitalize({ hello: 'world' });
expect(result).toBe('');
});
test('all lowercase', () => {
let result = StringUtils.capitalize('hello world');
expect(result).toBe('Hello world');
});
test('all uppercase', () => {
let result = StringUtils.capitalize('HELLO WORLD');
expect(result).toBe('HELLO WORLD');
});
test('already capitalized', () => {
let result = StringUtils.capitalize('Hello world');
expect(result).toBe('Hello world');
});
});
/**
* Test the Region model
*/
const Region = require('../../src/models/region');
/**
* Not defined
*/
describe('Country: not defined', () => {
test('no number given', () => {
let region = new Region();
expect(region.get()).toBe(undefined);
});
test('empty string given', () => {
let region = new Region('');
expect(region.get()).toBe(undefined);
});
test('alpha-string given', () => {
let region = new Region('this is not a number');
expect(region.get()).toBe(undefined);
});
test('A-Z string given', () => {
let region = new Region('this is not a number');
expect(region.get()).toBe(undefined);
});
});
/**
* Australia
*/
describe('Country: Australia', () => {
const cities = [
{ phoneNumber: '+61284172226', name: 'Sydney' },
{ phoneNumber: '+61738443236', name: 'Brisbane' },
{ phoneNumber: '+61 8 9444 8212', name: 'Perth' },
];
cities.forEach((city) => {
test(`testing ${city.name}`, () => {
let region = new Region(city.phoneNumber);
expect(region.get()).toEqual({ city: city.name, country: 'Australia' });
});
});
test('mobile number', () => {
let region = new Region('+61 413 538 474');
expect(region.get()).toBe(undefined);
});
});
/**
* Belgium
*/
describe('Country: Belgium', () => {
const cities = [
{ phoneNumber: '+32 2 223 73 77', name: 'Bruxelles' },
{ phoneNumber: '+32 9 223 55 55', name: 'Gand' },
{ phoneNumber: '+32 494 61 75 79', name: 'Liege' },
];
cities.forEach((city) => {
test(`testing ${city.name}`, () => {
let region = new Region(city.phoneNumber);
expect(region.get()).toEqual({ city: city.name, country: 'Belgium' });
});
});
});
/**
* Brazil
*/
describe('Country: Brazil', () => {
const cities = [
{ phoneNumber: '+55 11 3503-3333', name: 'Sao Paulo' },
{ phoneNumber: '+55 21 2523-3787', name: 'Rio de Janeiro' },
{ phoneNumber: '+55 61 2028-5000', name: 'Distrito Federal' },
];
cities.forEach((city) => {
test(`testing ${city.name}`, () => {
let region = new Region(city.phoneNumber);
expect(region.get()).toEqual({ city: city.name, country: 'Brazil' });
});
});
});
/**
* Canada
*/
describe('Country: Canada', () => {
const cities = [
{ phoneNumber: '+1 514-937-7754', name: 'Montreal' },
{ phoneNumber: '+1 604-666-6655', name: 'Abbotsford' },
{ phoneNumber: '+1 416-901-4724', name: 'Toronto' },
];
cities.forEach((city) => {
test(`testing ${city.name}`, () => {
let region = new Region(city.phoneNumber);
expect(region.get()).toEqual({ city: city.name, country: 'Canada' });
});
});
});
/**
* France
*/
describe('Country: France', () => {
const cities = [
{ phoneNumber: '+33176360036', name: 'Bobigny' },
{ phoneNumber: '+33563721234', name: 'Castres' },
];
cities.forEach((city) => {
test(`testing ${city.name}`, () => {
let region = new Region(city.phoneNumber);
expect(region.get()).toEqual({ city: city.name, country: 'France' });
});
});
test('mobile number', () => {
let region = new Region('+33645135390');
expect(region.get()).toBe(undefined);
});
test('toll-free number', () => {
let region = new Region('+33800123432');
expect(region.get()).toBe(undefined);
});
});
/**
* Germany
*/
describe('Country: Germany', () => {
const cities = [
{ phoneNumber: '+49 30 2835293', name: 'Berlin' },
{ phoneNumber: '+49 511 1699680', name: 'Hannover' },
{ phoneNumber: '+49 521 98818467', name: 'Bielefeld' },
];
cities.forEach((city) => {
test(`testing ${city.name}`, () => {
let region = new Region(city.phoneNumber);
expect(region.get()).toEqual({ city: city.name, country: 'Germany' });
});
});
});
/**
* Ireland
*/
describe('Country: Ireland', () => {
const cities = [
{ phoneNumber: '+353 21 431 6118', name: 'Cork' },
{ phoneNumber: '+353 1 455 6633', name: 'Dublin' },
{ phoneNumber: '+353 91 526 003', name: 'Galway' },
];
cities.forEach((city) => {
test(`testing ${city.name}`, () => {
let region = new Region(city.phoneNumber);
expect(region.get()).toEqual({ city: city.name, country: 'Ireland' });
});
});
});
/**
* Italy
*/
describe('Country: Italy', () => {
const cities = [
{ phoneNumber: '+39 377 087 7360', name: 'Codogno' },
{ phoneNumber: '+39 393 825 3361', name: 'Monza' },
{ phoneNumber: '+39 351 644 4007', name: 'Bergamo' },
];
cities.forEach((city) => {
test(`testing ${city.name}`, () => {
let region = new Region(city.phoneNumber);
expect(region.get()).toEqual({ city: city.name, country: 'Italy' });
});
});
});
/**
* Mexico
*/
describe('Country: Mexico', () => {
const cities = [
{ phoneNumber: '+52 55 5521 2048', name: 'Mexico City' },
{ phoneNumber: '+52 998 886 9891', name: 'Puerto Morelos' },
{ phoneNumber: '+52 662 212 2700', name: 'San Pedro El Saucito' },
];
cities.forEach((city) => {
test(`testing ${city.name}`, () => {
let region = new Region(city.phoneNumber);
expect(region.get()).toEqual({ city: city.name, country: 'Mexico' });
});
});
});
/**
* Morocco
*/
describe('Country: Morocco', () => {
const cities = [
{ phoneNumber: '+212 5376-33333', name: 'Rabat' },
{ phoneNumber: '+212 5243-81609', name: 'Marrakech' },
];
cities.forEach((city) => {
test(`testing ${city.name}`, () => {
let region = new Region(city.phoneNumber);
expect(region.get()).toEqual({ city: city.name, country: 'Morocco' });
});
});
});
/**
* Netherlands
*/
describe('Country: Netherlands', () => {
const cities = [
{ phoneNumber: '+31 10 414 4188', name: 'Rotterdam' },
{ phoneNumber: '+31 20 320 5700', name: 'Amsterdam' },
{ phoneNumber: '+31 70 388 2367', name: 'The Hague' },
];
cities.forEach((city) => {
test(`testing ${city.name}`, () => {
let region = new Region(city.phoneNumber);
expect(region.get()).toEqual({ city: city.name, country: 'Netherlands' });
});
});
});
/**
* Spain
*/
describe('Country: Spain', () => {
const cities = [
{ phoneNumber: '+34 915 32 02 59', name: 'Madrid' },
{ phoneNumber: '+34 933 19 70 03', name: 'Barcelona' },
{ phoneNumber: '+34 963 91 06 68', name: 'Valencia' },
];
cities.forEach((city) => {
test(`testing ${city.name}`, () => {
let region = new Region(city.phoneNumber);
expect(region.get()).toEqual({ city: city.name, country: 'Spain' });
});
});
});
/**
* Switzerland
*/
describe('Country: Switzerland', () => {
const cities = [
{ phoneNumber: '+41 31 312 33 00', name: 'Berne' },
{ phoneNumber: '+41 21 625 01 45', name: 'Lausanne' },
{ phoneNumber: '+41 44 225 33 00', name: 'Zurich' },
];
cities.forEach((city) => {
test(`testing ${city.name}`, () => {
let region = new Region(city.phoneNumber);
expect(region.get()).toEqual({ city: city.name, country: 'Switzerland' });
});
});
});
/**
* UK
*/
describe('Country: UK', () => {
const cities = [
{ phoneNumber: '+44 20 7268 6565', name: 'London' },
{ phoneNumber: '+44 1603 305300', name: 'Norwich' },
{ phoneNumber: '+44 1603 305300', name: 'Norwich' },
{ phoneNumber: '+44 131 624 8624', name: 'Edinburgh' },
];
cities.forEach((city) => {
test(`testing ${city.name}`, () => {
let region = new Region(city.phoneNumber);
expect(region.get()).toEqual({
city: city.name,
country: 'United Kingdom',
});
});
});
});
/**
* Sweden
*/
describe('Country: Sweden', () => {
const cities = [
{ phoneNumber: '+46 8 446 870 01', name: 'Stockholm' },
{ phoneNumber: '+46 40 630 92 80', name: 'Malmรถ' },
{ phoneNumber: '+46 46 841 00 59', name: 'Lund' },
];
cities.forEach((city) => {
test(`testing ${city.name}`, () => {
let region = new Region(city.phoneNumber);
expect(region.get()).toEqual({ city: city.name, country: 'Sweden' });
});
});
});
/**
* USA
*/
describe('Country: USA', () => {
const cities = [
{ phoneNumber: '+1 (212) 254-2246', name: 'New York,NY' },
{ phoneNumber: '+1 (415) 487-2600', name: 'San Francisco,CA' },
{ phoneNumber: '+1 (303) 830-6839', name: 'Denver,CO' },
{ phoneNumber: '+1 (713) 581-2337', name: 'Houston,TX' },
];
cities.forEach((city) => {
test(`testing ${city.name}`, () => {
let region = new Region(city.phoneNumber);
expect(region.get()).toEqual({ city: city.name, country: 'USA' });
});
});
});
/**
* Create all DynamoDB tables
*/
const AWS = require('aws-sdk'),
Env = require('./../src/modules/env');
AWS.config.update({
region: Env.fetch('AWS_DEFAULT_REGION'),
endpoint: Env.fetch('AWS_DYNAMO_ENDPOINT'),
});
let table = Env.fetch('DYNAMODB_TABLE') || 'Companies';
let dynamodb = new AWS.DynamoDB();
let params = {
TableName: 'Companies',
KeySchema: [{ AttributeName: 'WebhookToken', KeyType: 'HASH' }],
AttributeDefinitions: [{ AttributeName: 'WebhookToken', AttributeType: 'S' }],
ProvisionedThroughput: {
ReadCapacityUnits: 10,
WriteCapacityUnits: 10,
},
};
dynamodb.createTable(params, function (err, data) {
if (err) {
console.error(
'Unable to create table. Error JSON:',
JSON.stringify(err, null, 2)
);
} else {
console.log(
'Created table. Table description JSON:',
JSON.stringify(data, null, 2)
);
}
});
{
"name": "aircall-weather",
"version": "0.0.3",
"description": "Display customer's location information",
"author": "Xavier Durand ",
"main": "src/server.js",
"scripts": {
"start": "node_modules/nodemon/bin/nodemon.js -r dotenv/config src/server.js",
"test": "jest --silent"
},
"dependencies": {
"aws-sdk": "^2.688.0",
"axios": "^0.21.3",
"body-parser": "^1.19.0",
"dotenv": "^8.2.0",
"express": "^4.17.1",
"libphonenumber-js": "^1.7.53",
"winston": "^3.3.2"
},
"devDependencies": {
"husky": "^4.2.5",
"jest": "^26.0.1",
"lint-staged": "^10.2.11",
"nodemon": "^2.0.4",
"prettier": "2.0.5"
},
"husky": {
"hooks": {
"pre-commit": "lint-staged"
}
},
"lint-staged": {
"*.{js,css,md}": "prettier --single-quote --write"
}
}
PORT=5000
DEBUG=false
LOCAL=false
WEATHER_URL="http://localhost:5000"
AIRCALL_API_URL="https://api.aircall.io"
AIRCALL_OAUTH_AUTHORIZE_URL="https://dashboard.aircall.io/oauth/authorize"
AIRCALL_OAUTH_SUCCESS_URL="https://dashboard.aircall.io/oauth/success"
AIRCALL_OAUTH_ID="abc123"
AIRCALL_OAUTH_SECRET="123abc"
OPEN_WEATHER_API_URL="https://api.openweathermap.org/data/2.5"
OPEN_WEATHER_API_KEY="ZZZ"
AWS_DEFAULT_REGION="us-east-1"
AWS_DYNAMO_ENDPOINT="http://dynamodb-local:8000"
DYNAMODB_TABLE="Companies"
---
version: '3.7'
services:
api:
build:
context: .
labels:
app_version: "${CIRCLE_SHA1}"
target: test
image: weather
command: |
npm test
# ๐ค Aircall Weather
An **Aircall Creative Lab** app, sending weather information to Aircall users during calls.
It is written in [**NodeJS**](https://nodejs.org/en/) (v12.0.0),
stores data in a [**DynamoDB**](https://aws.amazon.com/dynamodb/) database,
and runs in a [**Docker**](https://www.docker.com/) container.
## Development
### ๐ ENV variables
Make sure all ENV variables are defined in your `.env` file.
A template is available:
```bash
cp .env_template .env
```
You will need:
- OAuth credentials `AIRCALL_OAUTH_ID` and `AIRCALL_OAUTH_SECRET`. Please reach out to marketplace@aircall.io and we'll give you some!
- `OPEN_WEATHER_API_KEY`: get your free API key [here](https://openweathermap.org/price).
### ๐ณ Install docker
This app is Docker compatible, both for local development and production environment.
Install the latest version of Docker CE for your OS:
- [macOS](https://docs.docker.com/docker-for-mac/install/)
- [Windows](https://docs.docker.com/docker-for-windows/install/)
- [Linux Ubuntu](https://docs.docker.com/install/linux/docker-ce/ubuntu/)
To have the full setup of Maestro with all the dependencies, use `docker-compose`.
Docker provides instructions [here](https://docs.docker.com/compose/install/).
### โ๏ธ DynamoDB
This project requires you to have [DynamoDB](https://aws.amazon.com/dynamodb/) installed locally.
AWS provides instructions [here](https://docs.aws.amazon.com/amazondynamodb/latest/developerguide/DynamoDBLocal.html).
Once you downloaded this Docker's image [dynamodb-local](https://hub.docker.com/r/amazon/dynamodb-local)
and you launched it, you will need to locally create the `Company` table:
```bash
node -r dotenv/config scripts/createDBTables.js
```
### ๐ Run the app
```bash
docker-compose up --build
```
## Test
We use [Jest](https://jestjs.io/) as a testing framework. Launch the following command to run tests:
```bash
npm test
```
## Production
Aircall uses Docker in Production.
First, you'll have to build the Docker image:
```bash
docker build -t aircall-weather .
```
Once done, you can then run it with the following command:
```bash
docker run -p 5000:5000 -d aircall-weather
```
In production, the `DEBUG` environment variable must be set to `false`.
## MISC
### Phone number to region matching
This database was manually built out of the [ITU website](https://www.itu.int/oth/T0202.aspx?lang=en&parent=T0202),
representing each available country in a JavaScript class, located in the `./src/models/region/` directory.