diff --git a/.gitignore b/.gitignore index f97065a..a3e0771 100644 --- a/.gitignore +++ b/.gitignore @@ -10,3 +10,5 @@ npm-debug.log # Docker Dockerfile docker-compose.yml +package-lock.json +dist diff --git a/README.md b/README.md index 84db678..e844cfc 100644 --- a/README.md +++ b/README.md @@ -4,3 +4,4 @@ These are sample clients for Facebook's [Webhooks](https://developers.facebook.c 1. [Heroku](heroku) - A sample client that receives Webhook events. 1. [Hubot](hubot) - A script that messages a chat room when a Facebook Page post is published using Webhooks. +1. [Typescript](typescript-server) - A typescript sample client that receives Webhook events. diff --git a/typescript-server/README.md b/typescript-server/README.md new file mode 100644 index 0000000..7f51183 --- /dev/null +++ b/typescript-server/README.md @@ -0,0 +1,59 @@ +# Graph API Webhook TypeScript Sample + +This is a sample client for Facebook's [Webhooks](https://developers.facebook.com/docs/graph-api/webhooks/) product and Instagram's [Subscriptions API](https://www.instagram.com/developer/subscriptions/), powered by [Node.js](https://nodejs.org/en) and written in TypeScript. + +## Setup + +### Prerequisites +Ensure you have Node.js installed. +Create a `.env` file in the `typescript-server` directory with the following content: +```env +APP_SECRET=your_app_secret +TOKEN=your_verify_token +FROM_PHONE_NUMBER_ID=your_phone_number_id +WHATSAPP_BEARER_TOKEN=your_app__bearer_token +``` + +### Instalation +1. Navigate to the `typescript-server` directory: + ```bash + cd typescript-server + ``` +2. Install the dependencies: + ```node + npm install + ``` + +### Running the Server +1. Start the server in development mode: + ```node + npm run dev + ``` +2. Alternatively, build and start the server: + ```node + npm start + ``` + +### Facebook Webhooks + +1. Refer to Facebook's [Webhooks sample app documentation](https://developers.facebook.com/docs/graph-api/webhooks/sample-apps) to see how to use this app. +2. Set up your Facebook application's Graph API Webhooks subscription using `https:///facebook` as the callback URL. + +### Instagram Subscription API +1. Register an [Instagram API client](https://instagram.com/developer/clients/manage/). + +2. Set up your client's [subscription](https://www.instagram.com/developer/subscriptions/) using `https:///instagram` as the callback URL. + +### Threads Webhooks +1. Refer to [Threads' Webhooks Documentation](https://developers.facebook.com/docs/threads/webhooks) and set up Threads Webhooks product as a sub use case under the Threads API main use case. +2. Set up your webhooks callback URL as `https:///threads`. + +## Endpoints +`POST /facebook` - Handles Facebook webhook events.
+`POST /instagram` - Handles Instagram webhook events.
+`POST /threads` - Handles Threads webhook events.
+`POST /whatsapp` - Handles WhatsApp webhook events.
+`POST /message` - Sends a WhatsApp message. + +## License +This project is licensed under the MIT License. \ No newline at end of file diff --git a/typescript-server/package.json b/typescript-server/package.json new file mode 100644 index 0000000..0a75f49 --- /dev/null +++ b/typescript-server/package.json @@ -0,0 +1,24 @@ +{ + "name": "typescript-server", + "version": "1.0.0", + "type": "module", + "main": "index.js", + "scripts": { + "dev": "tsx watch src/index.ts", + "start": "tsc && node --env-file .env ./dist/index.js" + }, + "keywords": [], + "author": "", + "license": "ISC", + "description": "", + "devDependencies": { + "@types/node": "^22.10.2", + "tsx": "^4.19.2", + "typescript": "^5.7.2" + }, + "dependencies": { + "axios": "^1.7.9", + "dotenv": "^16.4.7", + "fastify": "^5.2.0" + } +} diff --git a/typescript-server/src/index.ts b/typescript-server/src/index.ts new file mode 100644 index 0000000..548a717 --- /dev/null +++ b/typescript-server/src/index.ts @@ -0,0 +1,30 @@ +import 'dotenv/config'; +import fastify from 'fastify'; + +import { subscribe } from './routes/subscribe.js'; +import { facebook } from './routes/facebook-event.js'; +import { instagram } from './routes/instagram-event.js'; +import { threads } from './routes/threads-event.js'; +import { whatsapp } from './routes/whatsapp-event.js'; +import { sendText } from './routes/whatsapp-message.js'; + +const app = fastify({ logger: false }); + +app.register(subscribe); +app.register(facebook); +app.register(instagram); +app.register(threads); +app.register(whatsapp); +app.register(sendText); + +async function start() { + try { + await app.listen({ port: 3000 }); + console.log(`Server is running at http://localhost:3000`); + } catch (err) { + console.error(err); + process.exit(1); + } +}; + +start(); \ No newline at end of file diff --git a/typescript-server/src/routes/facebook-event.ts b/typescript-server/src/routes/facebook-event.ts new file mode 100644 index 0000000..6e679e8 --- /dev/null +++ b/typescript-server/src/routes/facebook-event.ts @@ -0,0 +1,20 @@ +import { FastifyInstance } from 'fastify'; +import { xhub } from '../utils/validate-x-hub.js'; + +export async function facebook(app: FastifyInstance) { + app.post('/facebook', async (request, reply) => { + try { + if (!xhub(request)) { + reply.status(401).send('Invalid X-Hub Signature'); + return; + } + + const body = request.body; + console.log(JSON.stringify(body, null, 2)); + + } catch (error) { + console.error(error); + reply.status(500).send('Internal Server Error'); + } + }); +} \ No newline at end of file diff --git a/typescript-server/src/routes/instagram-event.ts b/typescript-server/src/routes/instagram-event.ts new file mode 100644 index 0000000..4514024 --- /dev/null +++ b/typescript-server/src/routes/instagram-event.ts @@ -0,0 +1,8 @@ +import { FastifyInstance } from 'fastify'; + +export async function instagram(app: FastifyInstance) { + app.post('/instagram', async (request, reply) => { + const body = request.body; + console.log(JSON.stringify(body, null, 2)); + }); +} \ No newline at end of file diff --git a/typescript-server/src/routes/subscribe.ts b/typescript-server/src/routes/subscribe.ts new file mode 100644 index 0000000..107757b --- /dev/null +++ b/typescript-server/src/routes/subscribe.ts @@ -0,0 +1,37 @@ +import { FastifyInstance, FastifyReply, FastifyRequest } from "fastify"; + +export async function subscribe(app: FastifyInstance) { + const handler = async (request: FastifyRequest, reply: FastifyReply) => { + const query = request.query as { [key: string]: string }; + const mode = query['hub.mode']; + const token = query['hub.verify_token']; + const challenge = query['hub.challenge']; + + if (mode === 'subscribe' && token === process.env.TOKEN) { + console.log('Webhook verified'); + reply.status(200).send(challenge); + } else { + console.error('Failed to verify webhook'); + reply.status(403).send('Failed to verify webhook'); + } + }; + + const schema = { + schema: { + querystring: { + type: 'object', + required: ['hub.mode', 'hub.verify_token', 'hub.challenge'], + properties: { + 'hub.mode': { type: 'string' }, + 'hub.verify_token': { type: 'string' }, + 'hub.challenge': { type: 'string' }, + }, + }, + }, + }; + + app.get('/facebook', schema, handler); + app.get('/instagram', schema, handler); + app.get('/threads', schema, handler); + app.get('/whatsapp', schema, handler); +}; \ No newline at end of file diff --git a/typescript-server/src/routes/threads-event.ts b/typescript-server/src/routes/threads-event.ts new file mode 100644 index 0000000..cd846ea --- /dev/null +++ b/typescript-server/src/routes/threads-event.ts @@ -0,0 +1,8 @@ +import { FastifyInstance } from 'fastify'; + +export async function threads(app: FastifyInstance) { + app.post('/threads', async (request, reply) => { + const body = request.body; + console.log(JSON.stringify(body, null, 2)); + }); +} \ No newline at end of file diff --git a/typescript-server/src/routes/whatsapp-event.ts b/typescript-server/src/routes/whatsapp-event.ts new file mode 100644 index 0000000..efc7a84 --- /dev/null +++ b/typescript-server/src/routes/whatsapp-event.ts @@ -0,0 +1,20 @@ +import { FastifyInstance } from 'fastify'; +import { xhub } from '../utils/validate-x-hub.js'; + +export async function whatsapp(app: FastifyInstance) { + app.post('/whatsapp', async (request, reply) => { + try { + if (!xhub(request)) { + reply.status(401).send('Invalid X-Hub Signature'); + return; + } + + const body = request.body; + console.log(JSON.stringify(body, null, 2)); + + } catch (error) { + console.error(error); + reply.status(500).send('Internal Server Error'); + } + }); +} \ No newline at end of file diff --git a/typescript-server/src/routes/whatsapp-message.ts b/typescript-server/src/routes/whatsapp-message.ts new file mode 100644 index 0000000..480b374 --- /dev/null +++ b/typescript-server/src/routes/whatsapp-message.ts @@ -0,0 +1,50 @@ +import { FastifyInstance } from 'fastify'; +import axios from 'axios'; + +export async function sendText(app: FastifyInstance) { + app.post('/message', { + schema: { + body: { + type: 'object', + required: ['phone', 'text'], + properties: { + phone: { type: 'string' }, + text: { type: 'string' }, + }, + }, + }, + }, + async (request, reply) => { + try { + const { phone, text } = request.body as { phone: string, text: string }; + + // Send text message to the phone number + if (phone && text) { + const data = { + messaging_product: "whatsapp", + recipient_type: "individual", + to: phone, + type: "text", + text: { // the text object + preview_url: false, + body: text + } + }; + const config = { + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${process.env.WHATSAPP_BEARER_TOKEN}` + } + } + const uri = `https://graph.facebook.com/v21.0/${process.env.FROM_PHONE_NUMBER_ID}/messages` + const response = await axios.post(uri, data, config); + + reply.status(response.status).send(response.data); + } + + } catch (error) { + console.error(error); + reply.status(500).send('Internal Server Error'); + } + }); +} \ No newline at end of file diff --git a/typescript-server/src/utils/validate-x-hub.ts b/typescript-server/src/utils/validate-x-hub.ts new file mode 100644 index 0000000..f12609b --- /dev/null +++ b/typescript-server/src/utils/validate-x-hub.ts @@ -0,0 +1,22 @@ +import { FastifyRequest } from 'fastify'; +import crypto from 'crypto'; + +export function xhub(request: FastifyRequest) { + const header = request.headers['x-hub-signature'] as string; + + if (!header) { + console.error('Missing X-Hub Signature'); + return false; + } + + const [algorithm, sign] = header.split('='); + const secret = process.env.APP_SECRET as string; + const body = Buffer.from(JSON.stringify(request.body)); + const hash = crypto.createHmac(algorithm, secret).update(body).digest('hex'); + if (sign !== hash) { + console.log('Invalid X-Hub Signature'); + return false; + } + + return true; +} \ No newline at end of file diff --git a/typescript-server/tsconfig.json b/typescript-server/tsconfig.json new file mode 100644 index 0000000..d83e1c4 --- /dev/null +++ b/typescript-server/tsconfig.json @@ -0,0 +1,28 @@ +{ + "compilerOptions": { + /* Project */ + "rootDirs": [ + "src" + ], + "outDir": "dist", + /* Language and Environment */ + "target": "ES2022", /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */ + "lib": [ + "ES2023" + ], + /* Modules */ + "module": "Node16", /* Specify what module code is generated. */ + /* Interop Constraints */ + "esModuleInterop": true, /* Emit additional JavaScript to ease support for importing CommonJS modules. This enables 'allowSyntheticDefaultImports' for type compatibility. */ + "moduleResolution": "node16", + "forceConsistentCasingInFileNames": true, /* Ensure that casing is correct in imports. */ + "types": [ + "node" + ], + /* Type Checking */ + "strict": true, /* Enable all strict type-checking options. */ + "skipLibCheck": true, /* Skip type checking all .d.ts files. */ + "resolveJsonModule": true, + "allowJs": true + } +} \ No newline at end of file