From 9d93f1386bbe52bb24ad7d49e7a4c2600adb2cb0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christoph=20Pr=C3=B6schel?= Date: Wed, 30 Jun 2021 17:27:50 +0200 Subject: [PATCH] [#2051] Support instagram as a source --- .../sources/facebook/ChannelsController.java | 54 +++- .../co/airy/core/sources/facebook/Stores.java | 7 +- .../ConnectInstagramRequestPayload.java | 21 ++ ...ad.java => ConnectPageRequestPayload.java} | 2 +- .../core/sources/facebook/EventsRouter.java | 4 +- docs/docs/api/endpoints/channels.md | 16 ++ docs/docs/api/endpoints/connect-instagram.mdx | 38 +++ docs/docs/sources/instagram.md | 240 ++++++++++++++++++ .../ui/src/components/IconChannel/index.tsx | 6 + .../charts/tools/charts/ahkq/values.yaml | 2 +- .../helm-chart/templates/ingress.yaml | 14 + .../assets/images/icons/instagram.svg | 1 + lib/typescript/render/renderProviders.ts | 1 + 13 files changed, 398 insertions(+), 8 deletions(-) create mode 100644 backend/sources/facebook/connector/src/main/java/co/airy/core/sources/facebook/payload/ConnectInstagramRequestPayload.java rename backend/sources/facebook/connector/src/main/java/co/airy/core/sources/facebook/payload/{ConnectRequestPayload.java => ConnectPageRequestPayload.java} (89%) create mode 100644 docs/docs/api/endpoints/connect-instagram.mdx create mode 100644 docs/docs/sources/instagram.md create mode 100644 lib/typescript/assets/images/icons/instagram.svg diff --git a/backend/sources/facebook/connector/src/main/java/co/airy/core/sources/facebook/ChannelsController.java b/backend/sources/facebook/connector/src/main/java/co/airy/core/sources/facebook/ChannelsController.java index f85269d245..4d9c694af6 100644 --- a/backend/sources/facebook/connector/src/main/java/co/airy/core/sources/facebook/ChannelsController.java +++ b/backend/sources/facebook/connector/src/main/java/co/airy/core/sources/facebook/ChannelsController.java @@ -2,10 +2,12 @@ import co.airy.avro.communication.Channel; import co.airy.avro.communication.ChannelConnectionState; +import co.airy.avro.communication.Metadata; import co.airy.core.sources.facebook.api.Api; import co.airy.core.sources.facebook.api.ApiException; import co.airy.core.sources.facebook.api.model.PageWithConnectInfo; -import co.airy.core.sources.facebook.payload.ConnectRequestPayload; +import co.airy.core.sources.facebook.payload.ConnectInstagramRequestPayload; +import co.airy.core.sources.facebook.payload.ConnectPageRequestPayload; import co.airy.core.sources.facebook.payload.DisconnectChannelRequestPayload; import co.airy.core.sources.facebook.payload.ExploreRequestPayload; import co.airy.core.sources.facebook.payload.ExploreResponsePayload; @@ -78,7 +80,7 @@ ResponseEntity explore(@RequestBody @Valid ExploreRequestPayload requestPaylo } @PostMapping("/channels.facebook.connect") - ResponseEntity connect(@RequestBody @Valid ConnectRequestPayload requestPayload) { + ResponseEntity connectFacebook(@RequestBody @Valid ConnectPageRequestPayload requestPayload) { final String token = requestPayload.getPageToken(); final String pageId = requestPayload.getPageId(); @@ -115,7 +117,53 @@ ResponseEntity connect(@RequestBody @Valid ConnectRequestPayload requestPaylo } } - @PostMapping("/channels.facebook.disconnect") + @PostMapping("/channels.instagram.connect") + ResponseEntity connectInstagram(@RequestBody @Valid ConnectInstagramRequestPayload requestPayload) { + final String token = requestPayload.getPageToken(); + final String pageId = requestPayload.getPageId(); + final String accountId = requestPayload.getAccountId(); + + final String channelId = UUIDv5.fromNamespaceAndName("instagram", accountId).toString(); + + try { + final String longLivingUserToken = api.exchangeToLongLivingUserAccessToken(token); + final PageWithConnectInfo fbPageWithConnectInfo = api.getPageForUser(pageId, longLivingUserToken); + + api.connectPageToApp(fbPageWithConnectInfo.getAccessToken()); + + final MetadataMap metadataMap = MetadataMap.from(List.of( + newChannelMetadata(channelId, MetadataKeys.ChannelKeys.NAME, Optional.ofNullable(requestPayload.getName()).orElse(String.format("%s Instagram account", fbPageWithConnectInfo.getNameWithLocationDescriptor()))) + )); + + Optional.ofNullable(requestPayload.getImageUrl()) + .ifPresent((imageUrl) -> { + final Metadata metadata = newChannelMetadata(channelId, MetadataKeys.ChannelKeys.IMAGE_URL, imageUrl); + metadataMap.put(metadata.getKey(), metadata); + }); + + final ChannelContainer container = ChannelContainer.builder() + .channel( + Channel.newBuilder() + .setId(channelId) + .setConnectionState(ChannelConnectionState.CONNECTED) + .setSource("instagram") + .setSourceChannelId(accountId) + .setToken(longLivingUserToken) + .build() + ) + .metadataMap(metadataMap).build(); + + stores.storeChannelContainer(container); + + return ResponseEntity.ok(fromChannelContainer(container)); + } catch (ApiException e) { + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(new RequestErrorResponsePayload(e.getMessage())); + } catch (Exception e) { + return ResponseEntity.status(HttpStatus.SERVICE_UNAVAILABLE).build(); + } + } + + @PostMapping(path = {"/channels.facebook.disconnect", "/channels.instagram.disconnect"}) ResponseEntity disconnect(@RequestBody @Valid DisconnectChannelRequestPayload requestPayload) { final String channelId = requestPayload.getChannelId().toString(); diff --git a/backend/sources/facebook/connector/src/main/java/co/airy/core/sources/facebook/Stores.java b/backend/sources/facebook/connector/src/main/java/co/airy/core/sources/facebook/Stores.java index 7655edf616..165e95c264 100644 --- a/backend/sources/facebook/connector/src/main/java/co/airy/core/sources/facebook/Stores.java +++ b/backend/sources/facebook/connector/src/main/java/co/airy/core/sources/facebook/Stores.java @@ -30,6 +30,7 @@ import org.springframework.stereotype.Service; import java.util.HashMap; +import java.util.List; import java.util.Map; import java.util.Optional; import java.util.concurrent.ExecutionException; @@ -63,14 +64,16 @@ public void onApplicationEvent(ApplicationStartedEvent applicationStartedEvent) channelStream.toTable(Materialized.as(channelsStore)); + final List sources = List.of("facebook", "instagram"); + // Channels table KTable channelsTable = channelStream - .filter((sourceChannelId, channel) -> "facebook".equalsIgnoreCase(channel.getSource()) + .filter((sourceChannelId, channel) -> sources.contains(channel.getSource()) && channel.getConnectionState().equals(ChannelConnectionState.CONNECTED)).toTable(); // Facebook messaging stream by conversation-id final KStream messageStream = builder.stream(new ApplicationCommunicationMessages().name()) - .filter((messageId, message) -> "facebook".equalsIgnoreCase(message.getSource())) + .filter((messageId, message) -> sources.contains(message.getSource())) .selectKey((messageId, message) -> message.getConversationId()); // Metadata table diff --git a/backend/sources/facebook/connector/src/main/java/co/airy/core/sources/facebook/payload/ConnectInstagramRequestPayload.java b/backend/sources/facebook/connector/src/main/java/co/airy/core/sources/facebook/payload/ConnectInstagramRequestPayload.java new file mode 100644 index 0000000000..6dcdc403d2 --- /dev/null +++ b/backend/sources/facebook/connector/src/main/java/co/airy/core/sources/facebook/payload/ConnectInstagramRequestPayload.java @@ -0,0 +1,21 @@ +package co.airy.core.sources.facebook.payload; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +import javax.validation.constraints.NotNull; + +@Data +@NoArgsConstructor +@AllArgsConstructor +public class ConnectInstagramRequestPayload { + @NotNull + private String pageId; + @NotNull + private String accountId; + @NotNull + private String pageToken; + private String name; + private String imageUrl; +} diff --git a/backend/sources/facebook/connector/src/main/java/co/airy/core/sources/facebook/payload/ConnectRequestPayload.java b/backend/sources/facebook/connector/src/main/java/co/airy/core/sources/facebook/payload/ConnectPageRequestPayload.java similarity index 89% rename from backend/sources/facebook/connector/src/main/java/co/airy/core/sources/facebook/payload/ConnectRequestPayload.java rename to backend/sources/facebook/connector/src/main/java/co/airy/core/sources/facebook/payload/ConnectPageRequestPayload.java index 45e5a752b4..3f926c1e48 100644 --- a/backend/sources/facebook/connector/src/main/java/co/airy/core/sources/facebook/payload/ConnectRequestPayload.java +++ b/backend/sources/facebook/connector/src/main/java/co/airy/core/sources/facebook/payload/ConnectPageRequestPayload.java @@ -9,7 +9,7 @@ @Data @NoArgsConstructor @AllArgsConstructor -public class ConnectRequestPayload { +public class ConnectPageRequestPayload { @NotNull private String pageId; @NotNull diff --git a/backend/sources/facebook/events-router/src/main/java/co/airy/core/sources/facebook/EventsRouter.java b/backend/sources/facebook/events-router/src/main/java/co/airy/core/sources/facebook/EventsRouter.java index 8407788f48..84bcfcfbf4 100644 --- a/backend/sources/facebook/events-router/src/main/java/co/airy/core/sources/facebook/EventsRouter.java +++ b/backend/sources/facebook/events-router/src/main/java/co/airy/core/sources/facebook/EventsRouter.java @@ -50,11 +50,13 @@ public class EventsRouter implements DisposableBean, ApplicationListener sources = List.of("facebook", "instagram"); + // Channels table KTable channelsTable = builder.stream(new ApplicationCommunicationChannels().name()) .groupBy((k, v) -> v.getSourceChannelId()) .reduce((aggValue, newValue) -> newValue) - .filter((sourceChannelId, channel) -> "facebook".equalsIgnoreCase(channel.getSource()) + .filter((sourceChannelId, channel) -> sources.contains(channel.getSource()) && channel.getConnectionState().equals(ChannelConnectionState.CONNECTED)); builder.stream(new SourceFacebookEvents().name()) diff --git a/docs/docs/api/endpoints/channels.md b/docs/docs/api/endpoints/channels.md index 7aa603f35d..f69c671257 100644 --- a/docs/docs/api/endpoints/channels.md +++ b/docs/docs/api/endpoints/channels.md @@ -146,6 +146,12 @@ import ConnectFacebook from './connect-facebook.mdx' +### Instagram + +import ConnectInstagram from './connect-instagram.mdx' + + + ### Google import ConnectGoogle from './connect-google.mdx' @@ -186,6 +192,16 @@ POST /channels.facebook.disconnect +### Instagram + +Disconnects an instagram account from Airy Core. + +``` +POST /channels.instagram.disconnect +``` + + + ### Google ``` diff --git a/docs/docs/api/endpoints/connect-instagram.mdx b/docs/docs/api/endpoints/connect-instagram.mdx new file mode 100644 index 0000000000..beafb57ff3 --- /dev/null +++ b/docs/docs/api/endpoints/connect-instagram.mdx @@ -0,0 +1,38 @@ +Connects an Instagram account to Airy Core. + +``` +POST /channels.instagram.connect +``` + +- `page_id` is ID of the Facebook page connected to the Instagram account +- `page_token` is the Access Token of the Facebook page +- `account_id` is the ID of the Instagram account +- `name` is the custom name for the connected page +- `image_url` (optional) is the custom image URL + +**Sample request** + +```json5 +{ + "page_id": "fb-page-id-1", + "account_id": "ig-account-id", + "page_token": "authentication token", + "name": "My custom name for this account", + "image_url": "https://example.org/custom-image.jpg" // optional +} +``` + +**Sample response** + +```json5 +{ + "id": "channel-uuid-1", + "source": "instagram", + "source_channel_id": "ig-account-id", + "metadata": { + "name": "My custom name for this account", + // optional + "image_url": "https://example.org/custom-image.jpg" + } +} +``` diff --git a/docs/docs/sources/instagram.md b/docs/docs/sources/instagram.md new file mode 100644 index 0000000000..55aa5ed23d --- /dev/null +++ b/docs/docs/sources/instagram.md @@ -0,0 +1,240 @@ +--- +title: Instagram +sidebar_label: Instagram +--- + +import useBaseUrl from '@docusaurus/useBaseUrl'; +import TLDR from "@site/src/components/TLDR"; +import ButtonBox from "@site/src/components/ButtonBox"; +import BoltSVG from "@site/static/icons/bolt.svg"; +import InboxSVG from "@site/static/icons/prototype.svg"; +import SuccessBox from "@site/src/components/SuccessBox"; + + + +Start interacting with 1 billion monthly active users on Instagram! + + + +This document provides a step by step guide to integrate Facebook with your Airy +Core Platform instance. + +:::tip What you will learn + +- The required steps to configure the Facebook source +- How to connect a Facebook page to Airy Core + +::: + +:::note + +Because the Facebook part of the configuration is the same as for Facebook Messenger this guide assumes +that you have already completed the [Messenger guide](/sources/facebook.md) up until step number three. + +::: + +## Configuration + +Connecting an Instagram account requires the following configuration: + +- [Configuration](#configuration) + - [Step 1: Connect your account](#step-1-connect-your-account) + - [Step 2: Update the webhook integration](#step-2-configure-the-webhook-integration) + - [Step 3: Obtain your account id](#step-3-obtain-the-page-token) +- [Connect a Facebook page to your instance](#connect-a-facebook-page-to-your-instance) +- [Connect a Facebook source via API request](#connect-a-facebook-source-via-api-request) +- [Connect a Facebook source via the UI](#connect-a-facebook-source-via-the-ui) +- [Send messages from a Facebook source](#send-messages-from-a-facebook-source) + +Let's proceed step by step. + +### Step 1: Connect your account + +To connect a page, you must have an approved Facebook app. If you don't have +one, you must register and create a Business app on [Facebook for Developers](https://developers.facebook.com/). + +All of your registered apps are listed on [developers.facebook.com/apps](https://developers.facebook.com/apps/). + +Facebook apps page + +The dashboard of each registered app can be found on: + +``` +https://developers.facebook.com/apps/INSERT_YOUR_APP_ID_HERE/dashboard/ +``` + +On your application's dashboard, note down the `App ID` of your application and then head to the Basic Settings page. + +``` +https://developers.facebook.com/apps/INSERT_YOUR_APP_ID_HERE/settings/basic/ +``` + +You will find your `App Secret` on this page: + +Facebook apps page + +Copy and paste your App ID and App Secret as strings next to `appId:` and `appSecret:`, below `components/sources/facebook` in your `airy.yaml` file. + +import ApplyVariablesNote from './applyVariables-note.mdx' + + + +### Step 2: Configure the webhook integration + +Facebook must first verify your integration with a challenge to start sending events to your running instance. To verify your Facebook webhook integration, set the value next to `webhookSecret:`, below `components/sources/facebook` in your `airy.yaml` file, to a value of your choice. + +You are now ready to configure the webhook integration. Click on the + icon next to "Products" on the left sidebar of your app's dashboard: scroll down, a list of products will appear. + +``` +https://developers.facebook.com/apps/INSERT_YOUR_APP_ID_HERE/dashboard/#addProduct +``` + +Click on the button 'Set Up' on the Webhooks product card. + +Facebook webhook add product + +This will add the Webhooks as one of your app's products and will lead you to the Webhooks product page. + +``` +https://developers.facebook.com/apps/INSERT_YOUR_APP_ID_HERE/webhooks/ +``` + +Facebook webhook + +Select 'Page' from the dropdown (the default is 'User') and click on the button 'Subscribe to this object'. + +This will open a modal box: add your Callback URL (your instance's Facebook Webhook URL) and Verify Token (the webhookSecret you added in your `airy.yaml` file in the previous step). + +Facebook webhook + +
+ +:::note + +Your Facebook Webhook URL should have the following format: + +``` +https://your-public-fqdn/facebook +``` + +or if you are using Ngrok: + +``` +https://RANDOM_STRING.tunnel.airy.co/facebook +``` + +::: + +If you encounter errors, please make sure that the Verify Token matches the +`webhookSecret` in your `airy.yaml` file and that your variables have been +successfully set to your Airy Core instance. + + + +Once the verification process has been completed, Facebook will immediately +start sending events to your Airy Core instance. + +### Step 3: Obtain the page token + +Go to the Products page (click on the + icon next to Products on the left sidebar). + +Click the 'Set Up' button on the Messenger product card. + +``` +https://developers.facebook.com/apps/INSERT_YOUR_APP_ID_HERE/dashboard/#addProduct +``` + +Facebook messenger product + +This will add Messenger as one of your app's products and will lead you to the Messenger product page. + +Notice that at the bottom of the page, the Webhooks product has been added with the variables you gave at the previous step. + +Facebook messenger product + +Click on the blue button 'Add or Remove Pages' and select your page. + +Once your page has been added, scroll down and click on the button 'Add Subscriptions'. + +Facebook page subscriptions + +This opens a modal box: tick 'messages' and 'messaging_postbacks' from the Subscription Fields list. + +Facebook page subscriptions + +Next, scroll up, and click on the button 'Generate Token'. + +Facebook page token + +This will open a pop-up revealing your page Access Token. Copy it, you will need it to connect the Facebook page to your instance. + +Facebook page token + +
+
+ + + +Success! You are now ready to connect a Facebook page to your Airy Core instance 🎉 + + + +## Connect a Facebook page to your instance + +There are 2 options to connect a Facebook source to your instance: + +- you can connect the source via an API request (using curl or platforms such as Postman) +- you can connect the source via the UI + +We cover both options in this document. + +## Connect a Facebook source via API request + +The next step is to send a request to the [Channels endpoint](/api/endpoints/channels#facebook) to connect a Facebook page to your instance. + +} +title='Channels endpoint' +description='Connect a Facebook source to your Airy Core instance through the Channels endpoint' +link='api/endpoints/channels#facebook' +/> + +
+ +import ConnectFacebook from '../api/endpoints/connect-facebook.mdx' + + + +:::note + +If you encounter errors, please follow this debugging advice: + +- make sure that the tokens you have added to the airy.yaml file (refer back to step 1) have been applied to your Airy Core instance. An Airy Core instance should be created after editing the airy.yaml file. + +- verify your webhook integration (refer back to step 2). Make sure that your Facebook Webhook URL has been correctly added on your app's dashboard. You should edit the 'Page' subscriptions for the Webhooks and Messenger product each time you create a new instance. Make sure that you have selected 'Page' subscription and not 'User' (which is the default). + +::: + +## Connect a Facebook source via the UI + +You can connect a Facebook source via your Airy Core instance UI. + +On your instance's Airy Core UI, click on 'Channels' on the left sidebar menu and select the Facebook channel. Add your Facebook Page ID and Page Access Token in the respective fields. You can optionally add a name and an image. + +Facebook connect + +
+ +Your can find your Facebook Page ID and Page Access Token on your app's dashboard on [Facebook For Developers](https://developers.facebook.com/): the Facebook Page ID is the ID of the page you want to connect and the Page Access Token is generated on the Messenger product section (refer back to the previous steps). + +Make sure the variables have been successfully applied to your instance, otherwise you won't be able to connect the Facebook channel through the UI. + + + +## Send messages from a Facebook source + +After connecting the source to your instance, you will be able to send messages through the [Messages endpoint](/api/endpoints/messages#send). + +import InboxMessages from './inbox-messages.mdx' + + diff --git a/frontend/ui/src/components/IconChannel/index.tsx b/frontend/ui/src/components/IconChannel/index.tsx index db9cdec006..ec885ed723 100644 --- a/frontend/ui/src/components/IconChannel/index.tsx +++ b/frontend/ui/src/components/IconChannel/index.tsx @@ -3,6 +3,7 @@ import React from 'react'; import {Channel} from 'model'; import {ReactComponent as FacebookIcon} from 'assets/images/icons/facebook_rounded.svg'; +import {ReactComponent as InstagramIcon} from 'assets/images/icons/instagram.svg'; import {ReactComponent as GoogleIcon} from 'assets/images/icons/google-messages.svg'; import {ReactComponent as SmsIcon} from 'assets/images/icons/sms-icon.svg'; import {ReactComponent as WhatsappIcon} from 'assets/images/icons/whatsapp-icon.svg'; @@ -39,6 +40,11 @@ const SOURCE_INFO = { icon: () => , avatar: () => , }, + instagram: { + text: 'Instagram Account', + icon: () => , + avatar: () => , + }, google: { text: 'Google page', icon: () => , diff --git a/infrastructure/helm-chart/charts/tools/charts/ahkq/values.yaml b/infrastructure/helm-chart/charts/tools/charts/ahkq/values.yaml index bc114417c7..d4ca94189e 100644 --- a/infrastructure/helm-chart/charts/tools/charts/ahkq/values.yaml +++ b/infrastructure/helm-chart/charts/tools/charts/ahkq/values.yaml @@ -1 +1 @@ -enabled: false +enabled: true diff --git a/infrastructure/helm-chart/templates/ingress.yaml b/infrastructure/helm-chart/templates/ingress.yaml index 0b688f72cc..62fa979768 100644 --- a/infrastructure/helm-chart/templates/ingress.yaml +++ b/infrastructure/helm-chart/templates/ingress.yaml @@ -267,6 +267,20 @@ spec: name: sources-facebook-connector port: number: 80 + - path: /channels.instagram.connect + pathType: Prefix + backend: + service: + name: sources-facebook-connector + port: + number: 80 + - path: /channels.instagram.disconnect + pathType: Prefix + backend: + service: + name: sources-facebook-connector + port: + number: 80 - path: /channels.facebook.explore pathType: Prefix backend: diff --git a/lib/typescript/assets/images/icons/instagram.svg b/lib/typescript/assets/images/icons/instagram.svg new file mode 100644 index 0000000000..e40263c082 --- /dev/null +++ b/lib/typescript/assets/images/icons/instagram.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/lib/typescript/render/renderProviders.ts b/lib/typescript/render/renderProviders.ts index 1961c74088..c418e49136 100644 --- a/lib/typescript/render/renderProviders.ts +++ b/lib/typescript/render/renderProviders.ts @@ -9,6 +9,7 @@ type Provider = (messageRenderProps: RenderPropsUnion) => JSX.Element; export const renderProviders: {[key: string]: Provider} = { facebook: FacebookRender, + instagram: FacebookRender, chatplugin: ChatPluginRender, 'twilio.sms': TwilioSMSRender, 'twilio.whatsapp': TwilioWhatsappRender,