From ffbc407fdfee5559e32b73cce9f95f2ef893594d Mon Sep 17 00:00:00 2001 From: Pascal Holy <54705263+pascal-airy@users.noreply.github.com> Date: Tue, 9 Feb 2021 13:34:13 +0100 Subject: [PATCH 01/10] [#920] Release/0.8.0 (#919) * Fixes #null * Rm isFromContact chatpluginrenderer --- VERSION | 2 +- lib/typescript/render/providers/chatplugin/ChatPluginRender.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/VERSION b/VERSION index faef31a435..a3df0a6959 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.7.0 +0.8.0 diff --git a/lib/typescript/render/providers/chatplugin/ChatPluginRender.tsx b/lib/typescript/render/providers/chatplugin/ChatPluginRender.tsx index aa0f249da3..46fd171e9c 100644 --- a/lib/typescript/render/providers/chatplugin/ChatPluginRender.tsx +++ b/lib/typescript/render/providers/chatplugin/ChatPluginRender.tsx @@ -4,7 +4,7 @@ import {RichText} from '../../components/RichText'; import {RichCard} from '../../components/RichCard'; import {Text} from '../../components/Text'; import {ContentUnion} from './chatPluginModel'; -import {Message, isFromContact} from 'httpclient'; +import {Message} from 'httpclient'; export const ChatPluginRender = (props: MessageRenderProps) => { const {message} = props; From fddc9b0a28e38b5c4687daa70d467460aa439ca0 Mon Sep 17 00:00:00 2001 From: Bodo Tasche Date: Tue, 9 Feb 2021 15:43:31 +0100 Subject: [PATCH 02/10] [#855] Render Button Template from Facebook (#921) closes #855 --- .../providers/facebook/FacebookRender.tsx | 34 +++++++++++---- .../ButtonTemplate/index.module.scss | 42 +++++++++++++++++++ .../components/ButtonTemplate/index.tsx | 30 +++++++++++++ .../providers/facebook/facebookModel.ts | 28 ++++++++++++- 4 files changed, 125 insertions(+), 9 deletions(-) create mode 100644 lib/typescript/render/providers/facebook/components/ButtonTemplate/index.module.scss create mode 100644 lib/typescript/render/providers/facebook/components/ButtonTemplate/index.tsx diff --git a/lib/typescript/render/providers/facebook/FacebookRender.tsx b/lib/typescript/render/providers/facebook/FacebookRender.tsx index ff54a01407..f15905a8ce 100644 --- a/lib/typescript/render/providers/facebook/FacebookRender.tsx +++ b/lib/typescript/render/providers/facebook/FacebookRender.tsx @@ -3,7 +3,8 @@ import {isFromContact, Message} from '../../../httpclient/model'; import {getDefaultMessageRenderingProps, MessageRenderProps} from '../../shared'; import {Text} from '../../components/Text'; import {Image} from '../../components/Image'; -import {Attachment, ContentUnion} from './facebookModel'; +import {SimpleAttachment, ButtonAttachment, ContentUnion} from './facebookModel'; +import {ButtonTemplate} from './components/ButtonTemplate'; export const FacebookRender = (props: MessageRenderProps) => { const message = props.message; @@ -18,19 +19,29 @@ function render(content: ContentUnion, props: MessageRenderProps) { case 'image': return ; + + case 'buttonTemplate': + return ; } } -const parseAttachment = (attachement: Attachment): ContentUnion => { +const parseAttachment = (attachement: SimpleAttachment | ButtonAttachment): ContentUnion => { if (attachement.type === 'image') { return { type: 'image', imageUrl: attachement.payload.url, }; + } else if (attachement.type === 'template' && attachement.payload.template_type == 'button') { + return { + type: 'buttonTemplate', + text: attachement.payload.text, + buttons: attachement.payload.buttons, + }; } + return { type: 'text', - text: attachement.payload.title || 'Unknown message type', + text: 'Unknown message type', }; }; @@ -54,8 +65,17 @@ function facebookInbound(message: Message): ContentUnion { function facebookOutbound(message: Message): ContentUnion { const messageJson = JSON.parse(message.content); - return { - type: 'text', - text: messageJson.text, - }; + if (messageJson.attachment) { + return parseAttachment(messageJson.attachment); + } else if (messageJson.text) { + return { + type: 'text', + text: messageJson.text, + }; + } else { + return { + type: 'text', + text: 'Unknown message type', + }; + } } diff --git a/lib/typescript/render/providers/facebook/components/ButtonTemplate/index.module.scss b/lib/typescript/render/providers/facebook/components/ButtonTemplate/index.module.scss new file mode 100644 index 0000000000..e83e7a471d --- /dev/null +++ b/lib/typescript/render/providers/facebook/components/ButtonTemplate/index.module.scss @@ -0,0 +1,42 @@ +@import 'assets/scss/fonts.scss'; +@import 'assets/scss/colors.scss'; + +.wrapper { + display: flex; + align-items: end; + flex-direction: column; + margin-top: 8px; +} + +.template { + background-color: var(--color-template-gray); + border-radius: 16px; + width: 320px; + overflow-wrap: break-word; + word-break: break-word; + padding: 16px; +} + +.button { + font-weight: 700; + background-color: var(--color-template-highlight); + border-radius: 8px; + margin-top: 16px; + text-align: center; +} + +.buttonText { + color: var(--color-contrast); + text-decoration: none; + padding: 8px; + display: block; + &:hover { + color: var(--color-contrast); + text-decoration: none; + } +} + +.messageTime { + @include font-s; + color: var(--color-text-gray); +} diff --git a/lib/typescript/render/providers/facebook/components/ButtonTemplate/index.tsx b/lib/typescript/render/providers/facebook/components/ButtonTemplate/index.tsx new file mode 100644 index 0000000000..28285a2d6f --- /dev/null +++ b/lib/typescript/render/providers/facebook/components/ButtonTemplate/index.tsx @@ -0,0 +1,30 @@ +import React from 'react'; +import styles from './index.module.scss'; +import {DefaultMessageRenderingProps} from '../../../../components/index'; +import {ButtonTemplate as ButtonTemplateModel} from '../../facebookModel'; + +type ButtonTemplateRendererProps = DefaultMessageRenderingProps & { + template: ButtonTemplateModel; +}; + +export const ButtonTemplate = ({sentAt, template}: ButtonTemplateRendererProps) => ( +
+
+
{template.text}
+ {template.buttons.map((button, idx) => { + return ( +
+ {button.type == 'web_url' && button.url.length ? ( + + Link + + ) : ( +
button.title
+ )} +
+ ); + })} +
+ {sentAt &&
{sentAt}
} +
+); diff --git a/lib/typescript/render/providers/facebook/facebookModel.ts b/lib/typescript/render/providers/facebook/facebookModel.ts index 60384a358b..71815ef2bb 100644 --- a/lib/typescript/render/providers/facebook/facebookModel.ts +++ b/lib/typescript/render/providers/facebook/facebookModel.ts @@ -1,4 +1,7 @@ export interface Attachment { + type: string; +} +export interface SimpleAttachment { type: 'image' | 'video' | 'audio' | 'file' | 'fallback'; payload: { title?: string; @@ -6,8 +9,17 @@ export interface Attachment { }; } +export interface ButtonAttachment extends Attachment { + type: 'template'; + payload: { + text: string; + template_type: 'button'; + buttons: Button[]; + }; +} + export interface Content { - type: 'text' | 'image'; + type: string; } export interface TextContent extends Content { @@ -20,5 +32,17 @@ export interface ImageContent extends Content { imageUrl: string; } +export interface Button { + type: 'web_url'; + url: string; + title: string; +} + +export interface ButtonTemplate extends Content { + type: 'buttonTemplate'; + text: string; + buttons: Button[]; +} + // Add a new facebook content model here: -export type ContentUnion = TextContent | ImageContent; +export type ContentUnion = TextContent | ImageContent | ButtonTemplate; From 78e92d932e72b79f0800aca954f39f030dcf3b12 Mon Sep 17 00:00:00 2001 From: Bodo Tasche Date: Tue, 9 Feb 2021 17:21:42 +0100 Subject: [PATCH 03/10] [#856] Render Generic Template from Facebook (#930) closes #856 --- .../providers/facebook/FacebookRender.tsx | 13 ++++- .../ButtonTemplate/index.module.scss | 2 +- .../components/ButtonTemplate/index.tsx | 4 +- .../GenericTemplate/index.module.scss | 56 +++++++++++++++++++ .../components/GenericTemplate/index.tsx | 36 ++++++++++++ .../providers/facebook/facebookModel.ts | 26 ++++++++- 6 files changed, 131 insertions(+), 6 deletions(-) create mode 100644 lib/typescript/render/providers/facebook/components/GenericTemplate/index.module.scss create mode 100644 lib/typescript/render/providers/facebook/components/GenericTemplate/index.tsx diff --git a/lib/typescript/render/providers/facebook/FacebookRender.tsx b/lib/typescript/render/providers/facebook/FacebookRender.tsx index f15905a8ce..d55f319110 100644 --- a/lib/typescript/render/providers/facebook/FacebookRender.tsx +++ b/lib/typescript/render/providers/facebook/FacebookRender.tsx @@ -3,8 +3,9 @@ import {isFromContact, Message} from '../../../httpclient/model'; import {getDefaultMessageRenderingProps, MessageRenderProps} from '../../shared'; import {Text} from '../../components/Text'; import {Image} from '../../components/Image'; -import {SimpleAttachment, ButtonAttachment, ContentUnion} from './facebookModel'; +import {SimpleAttachment, ButtonAttachment, ContentUnion, GenericAttachment} from './facebookModel'; import {ButtonTemplate} from './components/ButtonTemplate'; +import {GenericTemplate} from './components/GenericTemplate'; export const FacebookRender = (props: MessageRenderProps) => { const message = props.message; @@ -22,10 +23,13 @@ function render(content: ContentUnion, props: MessageRenderProps) { case 'buttonTemplate': return ; + + case 'genericTemplate': + return ; } } -const parseAttachment = (attachement: SimpleAttachment | ButtonAttachment): ContentUnion => { +const parseAttachment = (attachement: SimpleAttachment | ButtonAttachment | GenericAttachment): ContentUnion => { if (attachement.type === 'image') { return { type: 'image', @@ -37,6 +41,11 @@ const parseAttachment = (attachement: SimpleAttachment | ButtonAttachment): Cont text: attachement.payload.text, buttons: attachement.payload.buttons, }; + } else if (attachement.type === 'template' && attachement.payload.template_type == 'generic') { + return { + type: 'genericTemplate', + elements: attachement.payload.elements, + }; } return { diff --git a/lib/typescript/render/providers/facebook/components/ButtonTemplate/index.module.scss b/lib/typescript/render/providers/facebook/components/ButtonTemplate/index.module.scss index e83e7a471d..143eac2b8b 100644 --- a/lib/typescript/render/providers/facebook/components/ButtonTemplate/index.module.scss +++ b/lib/typescript/render/providers/facebook/components/ButtonTemplate/index.module.scss @@ -3,7 +3,7 @@ .wrapper { display: flex; - align-items: end; + align-items: flex-end; flex-direction: column; margin-top: 8px; } diff --git a/lib/typescript/render/providers/facebook/components/ButtonTemplate/index.tsx b/lib/typescript/render/providers/facebook/components/ButtonTemplate/index.tsx index 28285a2d6f..2c297d4348 100644 --- a/lib/typescript/render/providers/facebook/components/ButtonTemplate/index.tsx +++ b/lib/typescript/render/providers/facebook/components/ButtonTemplate/index.tsx @@ -16,10 +16,10 @@ export const ButtonTemplate = ({sentAt, template}: ButtonTemplateRendererProps)
{button.type == 'web_url' && button.url.length ? ( - Link + {button.title} ) : ( -
button.title
+
{button.title}
)}
); diff --git a/lib/typescript/render/providers/facebook/components/GenericTemplate/index.module.scss b/lib/typescript/render/providers/facebook/components/GenericTemplate/index.module.scss new file mode 100644 index 0000000000..ab26a165b4 --- /dev/null +++ b/lib/typescript/render/providers/facebook/components/GenericTemplate/index.module.scss @@ -0,0 +1,56 @@ +@import 'assets/scss/fonts.scss'; +@import 'assets/scss/colors.scss'; + +.wrapper { + display: flex; + align-items: flex-end; + flex-direction: column; + margin-top: 8px; +} + +.template { + background-color: var(--color-template-gray); + border-radius: 16px; + width: 320px; + overflow-wrap: break-word; + word-break: break-word; +} + +.templateTitle { + font-weight: 700; +} + +.templateImage { + width: 100%; + border-top-right-radius: 16px; + border-top-left-radius: 16px; + overflow: hidden; +} + +.innerTemplate { + padding: 8px 16px 16px 16px; +} + +.button { + font-weight: 700; + background-color: var(--color-template-highlight); + border-radius: 8px; + margin-top: 16px; + text-align: center; +} + +.buttonText { + color: var(--color-contrast); + text-decoration: none; + padding: 8px; + display: block; + &:hover { + color: var(--color-contrast); + text-decoration: none; + } +} + +.messageTime { + @include font-s; + color: var(--color-text-gray); +} diff --git a/lib/typescript/render/providers/facebook/components/GenericTemplate/index.tsx b/lib/typescript/render/providers/facebook/components/GenericTemplate/index.tsx new file mode 100644 index 0000000000..8232903c18 --- /dev/null +++ b/lib/typescript/render/providers/facebook/components/GenericTemplate/index.tsx @@ -0,0 +1,36 @@ +import React from 'react'; +import styles from './index.module.scss'; +import {DefaultMessageRenderingProps} from '../../../../components/index'; +import {GenericTemplate as GenericTemplateModel} from '../../facebookModel'; + +type ButtonTemplateRendererProps = DefaultMessageRenderingProps & { + template: GenericTemplateModel; +}; + +export const GenericTemplate = ({sentAt, template}: ButtonTemplateRendererProps) => ( +
+ {template.elements.map((element, idx) => ( +
+ {element.image_url?.length && } +
+
{element.title}
+
{element.subtitle}
+ {element.buttons.map((button, idx) => { + return ( +
+ {button.type == 'web_url' && button.url.length ? ( + + {button.title} + + ) : ( +
{button.title}
+ )} +
+ ); + })} +
+
+ ))} + {sentAt &&
{sentAt}
} +
+); diff --git a/lib/typescript/render/providers/facebook/facebookModel.ts b/lib/typescript/render/providers/facebook/facebookModel.ts index 71815ef2bb..88f22e46c8 100644 --- a/lib/typescript/render/providers/facebook/facebookModel.ts +++ b/lib/typescript/render/providers/facebook/facebookModel.ts @@ -17,6 +17,25 @@ export interface ButtonAttachment extends Attachment { buttons: Button[]; }; } +export interface GenericAttachment extends Attachment { + type: 'template'; + payload: { + text: string; + template_type: 'generic'; + elements: Element[]; + }; +} + +export interface Element { + title: string; + subtitle?: string; + image_url?: string; + default_action?: { + type: string; + url?: string; + }; + buttons: Button[]; +} export interface Content { type: string; @@ -44,5 +63,10 @@ export interface ButtonTemplate extends Content { buttons: Button[]; } +export interface GenericTemplate extends Content { + type: 'genericTemplate'; + elements: Element[]; +} + // Add a new facebook content model here: -export type ContentUnion = TextContent | ImageContent | ButtonTemplate; +export type ContentUnion = TextContent | ImageContent | ButtonTemplate | GenericTemplate; From a11983b7b01cd2d4b54e085ab092b3a9c3834f40 Mon Sep 17 00:00:00 2001 From: Paulo Diniz Date: Wed, 10 Feb 2021 10:48:35 +0100 Subject: [PATCH 04/10] [#910] Add message metadata API (#933) --- .../api/communication/MessagesController.java | 8 +++-- .../core/api/communication/MessagesTest.java | 32 +++++++++++++++++++ .../message/dto/MessageResponsePayload.java | 4 +++ frontend/ui/README.md | 2 +- .../httpclient/payload/MessagePayload.ts | 1 + 5 files changed, 43 insertions(+), 4 deletions(-) diff --git a/backend/api/communication/src/main/java/co/airy/core/api/communication/MessagesController.java b/backend/api/communication/src/main/java/co/airy/core/api/communication/MessagesController.java index a156c50370..3b3274e383 100644 --- a/backend/api/communication/src/main/java/co/airy/core/api/communication/MessagesController.java +++ b/backend/api/communication/src/main/java/co/airy/core/api/communication/MessagesController.java @@ -14,7 +14,9 @@ import javax.validation.Valid; import java.util.List; +import java.util.Map; import java.util.Optional; +import java.util.stream.Collectors; import static java.util.stream.Collectors.toList; @@ -30,8 +32,7 @@ public class MessagesController { ResponseEntity messageList(@RequestBody @Valid MessageListRequestPayload messageListRequestPayload) { final String conversationId = messageListRequestPayload.getConversationId().toString(); final int pageSize = Optional.ofNullable(messageListRequestPayload.getPageSize()).orElse(20); - - MessageListResponsePayload response = fetchMessages(conversationId, pageSize, messageListRequestPayload.getCursor()); +MessageListResponsePayload response = fetchMessages(conversationId, pageSize, messageListRequestPayload.getCursor()); if (response == null) { return ResponseEntity.notFound().build(); @@ -58,6 +59,7 @@ private MessageListResponsePayload fetchMessages(String conversationId, int page .nextCursor(page.getNextCursor()) .previousCursor(cursor) .total(messages.size()) - .build()).build(); + .build()) + .build(); } } diff --git a/backend/api/communication/src/test/java/co/airy/core/api/communication/MessagesTest.java b/backend/api/communication/src/test/java/co/airy/core/api/communication/MessagesTest.java index 6e0758d169..41722c3738 100644 --- a/backend/api/communication/src/test/java/co/airy/core/api/communication/MessagesTest.java +++ b/backend/api/communication/src/test/java/co/airy/core/api/communication/MessagesTest.java @@ -106,6 +106,38 @@ void canFetchMessages() throws Exception { "/messages.list endpoint error"); } + @Test + void canReturnMetadata() throws Exception { + final String conversationId = UUID.randomUUID().toString(); + final String messageId = UUID.randomUUID().toString(); + final String text = "MESSAGE TEXT"; + + kafkaTestHelper.produceRecords(List.of( + new ProducerRecord<>(applicationCommunicationMessages.name(), messageId, Message.newBuilder() + .setId(messageId) + .setSentAt(Instant.now().toEpochMilli()) + .setSenderId("source-conversation-id") + .setDeliveryState(DeliveryState.DELIVERED) + .setSource("facebook") + .setSenderType(SenderType.SOURCE_CONTACT) + .setConversationId(conversationId) + .setHeaders(Map.of()) + .setChannelId(channel.getId()) + .setContent("{\"text\":\"" + text + "\"}") + .build()), + new ProducerRecord<>(applicationCommunicationMetadata.name(), "metadata-id", + newMessageMetadata(messageId, "metadata_key", "message metadata value")) + )); + + final String payload = "{\"conversation_id\":\"" + conversationId + "\"}"; + retryOnException( + () -> webTestHelper.post("/messages.list", payload, "user-id") + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data", hasSize(1))) + .andExpect(jsonPath("$.data[0].metadata.metadata_key", containsString("message metadata value"))), + "/messages.list metadata was not correct"); + } + @Test void canReplaceMessageContentUrl() throws Exception { final String conversationId = UUID.randomUUID().toString(); diff --git a/backend/model/message/src/main/java/co/airy/model/message/dto/MessageResponsePayload.java b/backend/model/message/src/main/java/co/airy/model/message/dto/MessageResponsePayload.java index 27f0791a79..a59598b69f 100644 --- a/backend/model/message/src/main/java/co/airy/model/message/dto/MessageResponsePayload.java +++ b/backend/model/message/src/main/java/co/airy/model/message/dto/MessageResponsePayload.java @@ -6,6 +6,8 @@ import lombok.Data; import lombok.NoArgsConstructor; +import java.util.Map; + import static co.airy.date.format.DateFormat.isoFromMillis; import static co.airy.model.message.MessageRepository.resolveContent; @@ -20,6 +22,7 @@ public class MessageResponsePayload { private String sentAt; private String deliveryState; private String source; + private Map metadata; public static MessageResponsePayload fromMessageContainer(MessageContainer messageContainer) { final Message message = messageContainer.getMessage(); @@ -30,6 +33,7 @@ public static MessageResponsePayload fromMessageContainer(MessageContainer messa .id(message.getId()) .sentAt(isoFromMillis(message.getSentAt())) .source(message.getSource()) + .metadata(messageContainer.getMetadataMap()) .build(); } diff --git a/frontend/ui/README.md b/frontend/ui/README.md index 6d302aa2de..05f5aba9d4 100644 --- a/frontend/ui/README.md +++ b/frontend/ui/README.md @@ -57,7 +57,7 @@ To start the app in development mode, run these commands: ``` yarn -yarn ibazel run //frontend/demo:bundle_server +yarn ibazel run //frontend/ui:bundle_server ``` After it started, open a web browser to [`localhost:8080`](http://localhost:8080). Login with the user you created above. \ No newline at end of file diff --git a/lib/typescript/httpclient/payload/MessagePayload.ts b/lib/typescript/httpclient/payload/MessagePayload.ts index 43f0bc7cb9..c643532d82 100644 --- a/lib/typescript/httpclient/payload/MessagePayload.ts +++ b/lib/typescript/httpclient/payload/MessagePayload.ts @@ -6,4 +6,5 @@ export interface MessagePayload { delivery_state: MessageState; sender_type: SenderType; sent_at: Date; + metadata?: Map; } From 22f546c7d45e7ddc0c419d0e8368ecf6890f3e36 Mon Sep 17 00:00:00 2001 From: Christoph Proeschel Date: Wed, 10 Feb 2021 13:37:05 +0100 Subject: [PATCH 05/10] [#794] Channel metadata API changes (#908) --- backend/api/admin/BUILD | 1 + .../core/api/admin/ChannelsController.java | 110 ++++++++++++---- .../java/co/airy/core/api/admin/Stores.java | 50 +++++++- .../api/admin/ChannelsControllerTest.java | 36 +++++- .../core/api/admin/TagsControllerTest.java | 3 + .../api/admin/WebhooksControllerTest.java | 3 + .../config/ClientConfigControllerTest.java | 3 + .../ConversationsController.java | 2 +- .../airy/core/api/communication/Mapper.java | 14 ++- .../api/communication/MetadataController.java | 6 +- .../api/communication/dto/Conversation.java | 6 +- .../communication/ConversationsInfoTest.java | 1 - .../communication/ConversationsListTest.java | 8 +- .../communication/ConversationsTagTest.java | 1 - .../core/api/communication/MessagesTest.java | 5 - .../communication/MetadataControllerTest.java | 2 - .../SendMessageControllerTest.java | 4 +- .../api/communication/UnreadCountTest.java | 2 - .../WebSocketControllerTest.java | 1 - backend/avro/communication/channel.avsc | 4 +- .../co/airy/core/media/MetadataResolver.java | 2 +- .../java/co/airy/core/media/MetadataTest.java | 6 +- backend/model/channel/BUILD | 1 + .../co/airy/model/channel/ChannelPayload.java | 33 ++--- .../model/channel/dto/ChannelContainer.java | 34 +++++ .../airy/model/message/MessageRepository.java | 2 +- .../co/airy/model/metadata/MetadataKeys.java | 19 +-- .../model/metadata/MetadataObjectMapper.java | 9 +- .../model/metadata/MetadataRepository.java | 19 ++- .../airy/model/metadata/dto/MetadataMap.java | 27 ++++ .../airy/model/metadata/ObjectMapperTest.java | 3 - .../core/chat_plugin/ChatControllerTest.java | 2 - .../sources/facebook/ChannelsController.java | 32 +++-- .../airy/core/sources/facebook/Connector.java | 18 +-- .../co/airy/core/sources/facebook/Stores.java | 17 ++- .../sources/facebook/FetchMetadataTest.java | 9 +- .../sources/facebook/SendMessageTest.java | 1 - .../sources/facebook/EventsRouterTest.java | 3 - backend/sources/google/connector/BUILD | 1 + .../sources/google/ChannelsController.java | 44 ++++--- .../co/airy/core/sources/google/Stores.java | 29 ++++- .../core/sources/google/SendMessageTest.java | 1 - .../core/sources/google/InfoExtractor.java | 6 +- .../core/sources/google/EventsRouterTest.java | 10 +- backend/sources/twilio/connector/BUILD | 1 + .../sources/twilio/ChannelsController.java | 40 +++--- .../co/airy/core/sources/twilio/Stores.java | 29 ++++- .../core/sources/twilio/SendMessageTest.java | 1 - .../core/sources/twilio/EventsRouterTest.java | 2 - .../airy/core/webhook/publisher/Mapper.java | 1 - docs/docs/api/endpoints/channels.md | 101 ++++++++++++--- docs/docs/api/endpoints/conversations.md | 15 ++- docs/docs/api/websocket.md | 8 +- .../ui/src/components/IconChannel/index.tsx | 119 +++++++----------- .../index.module.scss | 0 .../components/IconChannelFilter/index.tsx | 20 +++ .../components/SimpleIconChannel/index.tsx | 42 ------- frontend/ui/src/pages/Channels/index.tsx | 39 +++--- .../Inbox/ConversationListItem/index.tsx | 2 +- .../pages/Inbox/ConversationsFilter/Popup.tsx | 8 +- lib/typescript/httpclient/index.ts | 2 +- .../httpclient/mappers/channelMapper.ts | 21 ++-- .../httpclient/mappers/channelsMapper.ts | 10 +- lib/typescript/httpclient/model/Channel.ts | 10 +- lib/typescript/httpclient/model/Metadata.ts | 4 + .../httpclient/payload/ChannelApiPayload.ts | 6 +- .../httpclient/payload/ChannelsPayload.ts | 4 +- 67 files changed, 695 insertions(+), 380 deletions(-) create mode 100644 backend/model/channel/src/main/java/co/airy/model/channel/dto/ChannelContainer.java create mode 100644 backend/model/metadata/src/main/java/co/airy/model/metadata/dto/MetadataMap.java rename frontend/ui/src/components/{SimpleIconChannel => IconChannelFilter}/index.module.scss (100%) create mode 100644 frontend/ui/src/components/IconChannelFilter/index.tsx delete mode 100644 frontend/ui/src/components/SimpleIconChannel/index.tsx create mode 100644 lib/typescript/httpclient/model/Metadata.ts diff --git a/backend/api/admin/BUILD b/backend/api/admin/BUILD index f15680cd5a..7c0ad31647 100644 --- a/backend/api/admin/BUILD +++ b/backend/api/admin/BUILD @@ -8,6 +8,7 @@ app_deps = [ "//backend/model/channel", "//backend:tag", "//backend:webhook", + "//backend/model/metadata", "//lib/java/uuid", "//lib/java/spring/auth:spring-auth", "//lib/java/spring/web:spring-web", diff --git a/backend/api/admin/src/main/java/co/airy/core/api/admin/ChannelsController.java b/backend/api/admin/src/main/java/co/airy/core/api/admin/ChannelsController.java index 471bae9589..47a105c3c1 100644 --- a/backend/api/admin/src/main/java/co/airy/core/api/admin/ChannelsController.java +++ b/backend/api/admin/src/main/java/co/airy/core/api/admin/ChannelsController.java @@ -3,14 +3,14 @@ import co.airy.avro.communication.Channel; import co.airy.avro.communication.ChannelConnectionState; import co.airy.core.api.admin.payload.ChannelsResponsePayload; -import co.airy.kafka.schema.application.ApplicationCommunicationChannels; import co.airy.model.channel.ChannelPayload; +import co.airy.model.channel.dto.ChannelContainer; +import co.airy.model.metadata.MetadataKeys; +import co.airy.model.metadata.dto.MetadataMap; import co.airy.spring.web.payload.EmptyResponsePayload; import co.airy.uuid.UUIDv5; import lombok.Data; import lombok.NoArgsConstructor; -import org.apache.kafka.clients.producer.KafkaProducer; -import org.apache.kafka.clients.producer.ProducerRecord; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.PostMapping; @@ -22,29 +22,63 @@ import java.util.List; import java.util.UUID; -import static co.airy.model.channel.ChannelPayload.fromChannel; +import static co.airy.model.channel.ChannelPayload.fromChannelContainer; +import static co.airy.model.metadata.MetadataRepository.newChannelMetadata; import static java.util.stream.Collectors.toList; @RestController public class ChannelsController { private final Stores stores; - private final KafkaProducer producer; - private final String applicationCommunicationChannels = new ApplicationCommunicationChannels().name(); - public ChannelsController(Stores stores, KafkaProducer producer) { + public ChannelsController(Stores stores) { this.stores = stores; - this.producer = producer; } @PostMapping("/channels.list") - ResponseEntity listChannels() { - final List channels = stores.getChannels(); - + ResponseEntity listChannels(@RequestBody @Valid ListChannelRequestPayload requestPayload) { + final List channels = stores.getChannels(); + final String sourceToFilter = requestPayload.getSource(); return ResponseEntity.ok(new ChannelsResponsePayload(channels.stream() - .map(ChannelPayload::fromChannel) + .filter((container) -> sourceToFilter == null || sourceToFilter.equals(container.getChannel().getSource())) + .map(ChannelPayload::fromChannelContainer) .collect(toList()))); } + @PostMapping("/channels.info") + ResponseEntity getChannel(@RequestBody @Valid GetChannelRequestPayload requestPayload) { + final ChannelContainer container = stores.getChannel(requestPayload.getChannelId().toString()); + if (container == null) { + return ResponseEntity.status(HttpStatus.NOT_FOUND).body(new EmptyResponsePayload()); + } + + return ResponseEntity.ok(fromChannelContainer(container)); + } + + @PostMapping("/channels.update") + ResponseEntity updateChannel(@RequestBody @Valid UpdateChannelRequestPayload requestPayload) { + final String channelId = requestPayload.getChannelId().toString(); + final ChannelContainer container = stores.getChannel(channelId); + if (container == null) { + return ResponseEntity.status(HttpStatus.NOT_FOUND).body(new EmptyResponsePayload()); + } + + container.getMetadataMap(); + if (requestPayload.getName() != null) { + container.getMetadataMap().put(MetadataKeys.ChannelKeys.NAME, newChannelMetadata(channelId, MetadataKeys.ChannelKeys.NAME, requestPayload.getName())); + } + if (requestPayload.getImageUrl() != null) { + container.getMetadataMap().put(MetadataKeys.ChannelKeys.IMAGE_URL, newChannelMetadata(channelId, MetadataKeys.ChannelKeys.IMAGE_URL, requestPayload.getName())); + } + + try { + stores.storeMetadataMap(container.getMetadataMap()); + return ResponseEntity.ok(fromChannelContainer(container)); + } catch (Exception e) { + return ResponseEntity.status(HttpStatus.SERVICE_UNAVAILABLE).build(); + } + + } + @PostMapping("/channels.chatplugin.connect") ResponseEntity connect(@RequestBody @Valid ConnectChannelRequestPayload requestPayload) { final String sourceChannelId = requestPayload.getName(); @@ -52,33 +86,39 @@ ResponseEntity connect(@RequestBody @Valid ConnectChannelRequestPayload reque final String channelId = UUIDv5.fromNamespaceAndName(sourceIdentifier, sourceChannelId).toString(); - final Channel channel = Channel.newBuilder() - .setId(channelId) - .setConnectionState(ChannelConnectionState.CONNECTED) - .setSource(sourceIdentifier) - .setSourceChannelId(sourceChannelId) - .setName(requestPayload.getName()) - .build(); + final ChannelContainer container = ChannelContainer.builder() + .channel( + Channel.newBuilder() + .setId(channelId) + .setConnectionState(ChannelConnectionState.CONNECTED) + .setSource(sourceIdentifier) + .setSourceChannelId(sourceChannelId) + .build() + ) + .metadataMap(MetadataMap.from(List.of( + newChannelMetadata(channelId, MetadataKeys.ChannelKeys.NAME, requestPayload.getName()) + ))).build(); try { - producer.send(new ProducerRecord<>(applicationCommunicationChannels, channel.getId(), channel)).get(); + stores.storeChannelContainer(container); } catch (Exception e) { return ResponseEntity.status(HttpStatus.SERVICE_UNAVAILABLE).build(); } - return ResponseEntity.ok(fromChannel(channel)); + return ResponseEntity.ok(fromChannelContainer(container)); } @PostMapping("/channels.chatplugin.disconnect") ResponseEntity disconnect(@RequestBody @Valid ChannelDisconnectRequestPayload requestPayload) { final String channelId = requestPayload.getChannelId().toString(); - final Channel channel = stores.getConnectedChannelsStore().get(channelId); + final ChannelContainer container = stores.getConnectedChannelsStore().get(channelId); - if (channel == null) { + if (container == null) { return ResponseEntity.notFound().build(); } + final Channel channel = container.getChannel(); if (channel.getConnectionState().equals(ChannelConnectionState.DISCONNECTED)) { return ResponseEntity.accepted().body(new EmptyResponsePayload()); } @@ -87,7 +127,7 @@ ResponseEntity disconnect(@RequestBody @Valid ChannelDisconnectRequestPayload channel.setToken(null); try { - producer.send(new ProducerRecord<>(applicationCommunicationChannels, channel.getId(), channel)).get(); + stores.storeChannel(channel); } catch (Exception e) { return ResponseEntity.status(HttpStatus.SERVICE_UNAVAILABLE).build(); } @@ -97,6 +137,28 @@ ResponseEntity disconnect(@RequestBody @Valid ChannelDisconnectRequestPayload } +@Data +@NoArgsConstructor +class ListChannelRequestPayload { + private String source; +} + +@Data +@NoArgsConstructor +class GetChannelRequestPayload { + @NotNull + private UUID channelId; +} + +@Data +@NoArgsConstructor +class UpdateChannelRequestPayload { + @NotNull + private UUID channelId; + private String name; + private String imageUrl; +} + @Data @NoArgsConstructor class ConnectChannelRequestPayload { diff --git a/backend/api/admin/src/main/java/co/airy/core/api/admin/Stores.java b/backend/api/admin/src/main/java/co/airy/core/api/admin/Stores.java index f480fad2a7..a7f79ce65e 100644 --- a/backend/api/admin/src/main/java/co/airy/core/api/admin/Stores.java +++ b/backend/api/admin/src/main/java/co/airy/core/api/admin/Stores.java @@ -2,16 +2,22 @@ import co.airy.avro.communication.Channel; import co.airy.avro.communication.ChannelConnectionState; +import co.airy.avro.communication.Metadata; import co.airy.avro.communication.Tag; import co.airy.avro.communication.Webhook; import co.airy.kafka.schema.application.ApplicationCommunicationChannels; +import co.airy.kafka.schema.application.ApplicationCommunicationMetadata; import co.airy.kafka.schema.application.ApplicationCommunicationTags; import co.airy.kafka.schema.application.ApplicationCommunicationWebhooks; import co.airy.kafka.streams.KafkaStreamsWrapper; +import co.airy.model.channel.dto.ChannelContainer; +import co.airy.model.metadata.dto.MetadataMap; import org.apache.avro.specific.SpecificRecordBase; import org.apache.kafka.clients.producer.KafkaProducer; import org.apache.kafka.clients.producer.ProducerRecord; +import org.apache.kafka.streams.KeyValue; import org.apache.kafka.streams.StreamsBuilder; +import org.apache.kafka.streams.kstream.KTable; import org.apache.kafka.streams.kstream.Materialized; import org.apache.kafka.streams.state.KeyValueIterator; import org.apache.kafka.streams.state.ReadOnlyKeyValueStore; @@ -26,6 +32,10 @@ import java.util.List; import java.util.concurrent.ExecutionException; +import static co.airy.model.metadata.MetadataRepository.getId; +import static co.airy.model.metadata.MetadataRepository.getSubject; +import static co.airy.model.metadata.MetadataRepository.isChannelMetadata; + @Component public class Stores implements HealthIndicator, ApplicationListener, DisposableBean { private static final String appId = "api.AdminStores"; @@ -44,6 +54,7 @@ public class Stores implements HealthIndicator, ApplicationListener producer) { this.streams = streams; @@ -54,8 +65,15 @@ public Stores(KafkaStreamsWrapper streams, KafkaProducer metadataTable = builder.table(applicationCommunicationMetadata) + .filter((metadataId, metadata) -> isChannelMetadata(metadata)) + .groupBy((metadataId, metadata) -> KeyValue.pair(getSubject(metadata).getIdentifier(), metadata)) + .aggregate(MetadataMap::new, MetadataMap::adder, MetadataMap::subtractor); + builder.table(applicationCommunicationChannels) - .filter((k, v) -> v.getConnectionState().equals(ChannelConnectionState.CONNECTED), Materialized.as(connectedChannelsStore)); + .filter((k, v) -> v.getConnectionState().equals(ChannelConnectionState.CONNECTED)) + .leftJoin(metadataTable, ChannelContainer::new, Materialized.as(connectedChannelsStore)); builder.stream(applicationCommunicationWebhooks) .groupBy((webhookId, webhook) -> allWebhooksKey) @@ -79,6 +97,21 @@ public void storeWebhook(Webhook webhook) throws ExecutionException, Interrupted producer.send(new ProducerRecord<>(applicationCommunicationWebhooks, allWebhooksKey, webhook)).get(); } + public void storeChannelContainer(ChannelContainer container) throws ExecutionException, InterruptedException { + storeChannel(container.getChannel()); + storeMetadataMap(container.getMetadataMap()); + } + + public void storeMetadataMap(MetadataMap metadataMap) throws ExecutionException, InterruptedException { + for (Metadata metadata : metadataMap.values()) { + producer.send(new ProducerRecord<>(applicationCommunicationMetadata, getId(metadata).toString(), metadata)).get(); + } + } + + public void storeChannel(Channel channel) throws ExecutionException, InterruptedException { + producer.send(new ProducerRecord<>(applicationCommunicationChannels, channel.getId(), channel)).get(); + } + public void storeTag(Tag tag) throws ExecutionException, InterruptedException { producer.send(new ProducerRecord<>(applicationCommunicationTags, tag.getId(), tag)).get(); } @@ -87,16 +120,21 @@ public void deleteTag(Tag tag) { producer.send(new ProducerRecord<>(applicationCommunicationTags, tag.getId(), null)); } - public ReadOnlyKeyValueStore getConnectedChannelsStore() { + public ReadOnlyKeyValueStore getConnectedChannelsStore() { return streams.acquireLocalStore(connectedChannelsStore); } - public List getChannels() { - final ReadOnlyKeyValueStore store = getConnectedChannelsStore(); + public ChannelContainer getChannel(String channelId) { + final ReadOnlyKeyValueStore store = getConnectedChannelsStore(); + return store.get(channelId); + } + + public List getChannels() { + final ReadOnlyKeyValueStore store = getConnectedChannelsStore(); - final KeyValueIterator iterator = store.all(); + final KeyValueIterator iterator = store.all(); - List channels = new ArrayList<>(); + List channels = new ArrayList<>(); iterator.forEachRemaining(kv -> channels.add(kv.value)); return channels; diff --git a/backend/api/admin/src/test/java/co/airy/core/api/admin/ChannelsControllerTest.java b/backend/api/admin/src/test/java/co/airy/core/api/admin/ChannelsControllerTest.java index aab6d489e7..d8cb692c5f 100644 --- a/backend/api/admin/src/test/java/co/airy/core/api/admin/ChannelsControllerTest.java +++ b/backend/api/admin/src/test/java/co/airy/core/api/admin/ChannelsControllerTest.java @@ -3,6 +3,7 @@ import co.airy.avro.communication.Channel; import co.airy.avro.communication.ChannelConnectionState; import co.airy.kafka.schema.application.ApplicationCommunicationChannels; +import co.airy.kafka.schema.application.ApplicationCommunicationMetadata; import co.airy.kafka.schema.application.ApplicationCommunicationTags; import co.airy.kafka.schema.application.ApplicationCommunicationWebhooks; import co.airy.kafka.test.KafkaTestHelper; @@ -27,6 +28,7 @@ import static co.airy.test.Timing.retryOnException; import static org.hamcrest.Matchers.contains; +import static org.hamcrest.Matchers.equalTo; import static org.hamcrest.Matchers.greaterThanOrEqualTo; import static org.hamcrest.Matchers.not; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; @@ -43,6 +45,7 @@ public class ChannelsControllerTest { private static KafkaTestHelper kafkaTestHelper; private static final ApplicationCommunicationChannels applicationCommunicationChannels = new ApplicationCommunicationChannels(); private static final ApplicationCommunicationWebhooks applicationCommunicationWebhooks = new ApplicationCommunicationWebhooks(); + private static final ApplicationCommunicationMetadata applicationCommunicationMetadata = new ApplicationCommunicationMetadata(); private static final ApplicationCommunicationTags applicationCommunicationTags = new ApplicationCommunicationTags(); @Autowired @@ -53,6 +56,7 @@ static void beforeAll() throws Exception { kafkaTestHelper = new KafkaTestHelper(sharedKafkaTestResource, applicationCommunicationChannels, applicationCommunicationWebhooks, + applicationCommunicationMetadata, applicationCommunicationTags ); kafkaTestHelper.beforeAll(); @@ -65,13 +69,10 @@ static void afterAll() throws Exception { private static boolean testDataInitialized = false; - static final String facebookToken = "token"; static final Channel connectedChannel = Channel.newBuilder() .setConnectionState(ChannelConnectionState.CONNECTED) .setId(UUID.randomUUID().toString()) - .setName("connected channel name") .setSource("facebook") - .setToken(facebookToken) .setSourceChannelId("source-channel-id") .build(); @@ -98,11 +99,10 @@ void canListChannels() throws Exception { Channel.newBuilder() .setConnectionState(ChannelConnectionState.DISCONNECTED) .setId(disconnectedChannel) - .setName("channel-name-2") .setSource("facebook") .setSourceChannelId("ps-id-2") - .build())) - ); + .build()) + )); retryOnException(() -> webTestHelper.post("/channels.list", "{}", "user-id") .andExpect(status().isOk()) @@ -111,4 +111,28 @@ void canListChannels() throws Exception { "/channels.list did not return the right number of channels"); } + @Test + void canUpdateChannel() throws Exception { + final String expectedChannelName = "channel name"; + + retryOnException(() -> webTestHelper.post("/channels.info", String.format("{\"channel_id\":\"%s\"}", connectedChannel.getId()), "user-id") + .andExpect(status().isOk()) + .andExpect(jsonPath("$.id", equalTo(connectedChannel.getId()))) + .andExpect(jsonPath("$.metadata.name", not(equalTo(expectedChannelName)))), + "/channels.info did not return the right channel"); + + webTestHelper.post("/channels.update", String.format("{\"channel_id\":\"%s\",\"name\":\"%s\"}", + connectedChannel.getId(), expectedChannelName), "user-id") + .andExpect(status().isOk()) + .andExpect(jsonPath("$.id", equalTo(connectedChannel.getId()))) + .andExpect(jsonPath("$.metadata.name", not(equalTo(connectedChannel.getId())))) + .andExpect(jsonPath("$.source", equalTo(connectedChannel.getSource()))); + + retryOnException(() -> webTestHelper.post("/channels.info", String.format("{\"channel_id\":\"%s\"}", connectedChannel.getId()), "user-id") + .andExpect(status().isOk()) + .andExpect(jsonPath("$.id", equalTo(connectedChannel.getId()))) + .andExpect(jsonPath("$.metadata.name", equalTo(expectedChannelName))), + "/channels.update did not update"); + } + } diff --git a/backend/api/admin/src/test/java/co/airy/core/api/admin/TagsControllerTest.java b/backend/api/admin/src/test/java/co/airy/core/api/admin/TagsControllerTest.java index d65cf4311b..fab6d00163 100644 --- a/backend/api/admin/src/test/java/co/airy/core/api/admin/TagsControllerTest.java +++ b/backend/api/admin/src/test/java/co/airy/core/api/admin/TagsControllerTest.java @@ -1,6 +1,7 @@ package co.airy.core.api.admin; import co.airy.kafka.schema.application.ApplicationCommunicationChannels; +import co.airy.kafka.schema.application.ApplicationCommunicationMetadata; import co.airy.kafka.schema.application.ApplicationCommunicationTags; import co.airy.kafka.schema.application.ApplicationCommunicationWebhooks; import co.airy.kafka.test.KafkaTestHelper; @@ -37,6 +38,7 @@ public class TagsControllerTest { public static final SharedKafkaTestResource sharedKafkaTestResource = new SharedKafkaTestResource(); private static final ApplicationCommunicationChannels applicationCommunicationChannels = new ApplicationCommunicationChannels(); private static final ApplicationCommunicationWebhooks applicationCommunicationWebhooks = new ApplicationCommunicationWebhooks(); + private static final ApplicationCommunicationMetadata applicationCommunicationMetadata = new ApplicationCommunicationMetadata(); private static final ApplicationCommunicationTags applicationCommunicationTags = new ApplicationCommunicationTags(); private static KafkaTestHelper kafkaTestHelper; @@ -51,6 +53,7 @@ static void beforeAll() throws Exception { kafkaTestHelper = new KafkaTestHelper(sharedKafkaTestResource, applicationCommunicationChannels, applicationCommunicationWebhooks, + applicationCommunicationMetadata, applicationCommunicationTags ); kafkaTestHelper.beforeAll(); diff --git a/backend/api/admin/src/test/java/co/airy/core/api/admin/WebhooksControllerTest.java b/backend/api/admin/src/test/java/co/airy/core/api/admin/WebhooksControllerTest.java index 10dae14e36..eca1759360 100644 --- a/backend/api/admin/src/test/java/co/airy/core/api/admin/WebhooksControllerTest.java +++ b/backend/api/admin/src/test/java/co/airy/core/api/admin/WebhooksControllerTest.java @@ -1,6 +1,7 @@ package co.airy.core.api.admin; import co.airy.kafka.schema.application.ApplicationCommunicationChannels; +import co.airy.kafka.schema.application.ApplicationCommunicationMetadata; import co.airy.kafka.schema.application.ApplicationCommunicationTags; import co.airy.kafka.schema.application.ApplicationCommunicationWebhooks; import co.airy.kafka.test.KafkaTestHelper; @@ -44,6 +45,7 @@ public class WebhooksControllerTest { private static final ApplicationCommunicationChannels applicationCommunicationChannels = new ApplicationCommunicationChannels(); private static final ApplicationCommunicationWebhooks applicationCommunicationWebhooks = new ApplicationCommunicationWebhooks(); + private static final ApplicationCommunicationMetadata applicationCommunicationMetadata = new ApplicationCommunicationMetadata(); private static final ApplicationCommunicationTags applicationCommunicationTags = new ApplicationCommunicationTags(); @BeforeAll @@ -51,6 +53,7 @@ static void beforeAll() throws Exception { kafkaTestHelper = new KafkaTestHelper(sharedKafkaTestResource, applicationCommunicationChannels, applicationCommunicationWebhooks, + applicationCommunicationMetadata, applicationCommunicationTags ); kafkaTestHelper.beforeAll(); diff --git a/backend/api/admin/src/test/java/co/airy/core/api/config/ClientConfigControllerTest.java b/backend/api/admin/src/test/java/co/airy/core/api/config/ClientConfigControllerTest.java index f5cd36a103..cad5953046 100644 --- a/backend/api/admin/src/test/java/co/airy/core/api/config/ClientConfigControllerTest.java +++ b/backend/api/admin/src/test/java/co/airy/core/api/config/ClientConfigControllerTest.java @@ -1,6 +1,7 @@ package co.airy.core.api.config; import co.airy.kafka.schema.application.ApplicationCommunicationChannels; +import co.airy.kafka.schema.application.ApplicationCommunicationMetadata; import co.airy.kafka.schema.application.ApplicationCommunicationTags; import co.airy.kafka.schema.application.ApplicationCommunicationWebhooks; import co.airy.kafka.test.KafkaTestHelper; @@ -59,6 +60,7 @@ public class ClientConfigControllerTest { private static final ApplicationCommunicationChannels applicationCommunicationChannels = new ApplicationCommunicationChannels(); private static final ApplicationCommunicationWebhooks applicationCommunicationWebhooks = new ApplicationCommunicationWebhooks(); + private static final ApplicationCommunicationMetadata applicationCommunicationMetadata = new ApplicationCommunicationMetadata(); private static final ApplicationCommunicationTags applicationCommunicationTags = new ApplicationCommunicationTags(); @BeforeAll @@ -66,6 +68,7 @@ static void beforeAll() throws Exception { kafkaTestHelper = new KafkaTestHelper(sharedKafkaTestResource, applicationCommunicationChannels, applicationCommunicationWebhooks, + applicationCommunicationMetadata, applicationCommunicationTags ); kafkaTestHelper.beforeAll(); diff --git a/backend/api/communication/src/main/java/co/airy/core/api/communication/ConversationsController.java b/backend/api/communication/src/main/java/co/airy/core/api/communication/ConversationsController.java index b4d47d6ea7..52190889a2 100644 --- a/backend/api/communication/src/main/java/co/airy/core/api/communication/ConversationsController.java +++ b/backend/api/communication/src/main/java/co/airy/core/api/communication/ConversationsController.java @@ -219,7 +219,7 @@ ResponseEntity conversationUntag(@RequestBody @Valid ConversationTagRequestPa try { final Subject subject = new Subject("conversation", conversationId); - final String metadataKey = String.format("%s.%s", MetadataKeys.TAGS, tagId); + final String metadataKey = String.format("%s.%s", MetadataKeys.ConversationKeys.TAGS, tagId); stores.deleteMetadata(subject, metadataKey); } catch (Exception e) { return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(new RequestErrorResponsePayload(e.getMessage())); diff --git a/backend/api/communication/src/main/java/co/airy/core/api/communication/Mapper.java b/backend/api/communication/src/main/java/co/airy/core/api/communication/Mapper.java index 507e9886f5..458a11cb8c 100644 --- a/backend/api/communication/src/main/java/co/airy/core/api/communication/Mapper.java +++ b/backend/api/communication/src/main/java/co/airy/core/api/communication/Mapper.java @@ -18,12 +18,14 @@ public class Mapper { public ConversationResponsePayload fromConversation(Conversation conversation) { + return ConversationResponsePayload.builder() - .channel(ChannelPayload.builder() - .id(conversation.getChannelId()) - .name(conversation.getChannel().getName()) - .source(conversation.getChannel().getSource()) - .build()) + .channel( + // TODO https://github.com/airyhq/airy/issues/909 + // Once we have the channel metadata map in the topology, + // create this payload using ChannelPayload.fromChannelContainer + ChannelPayload.fromChannel(conversation.getChannel()) + ) .id(conversation.getId()) .unreadMessageCount(conversation.getUnreadMessageCount()) .tags(conversation.getTagIds()) @@ -38,7 +40,7 @@ private ContactResponsePayload getContact(Conversation conversation) { final DisplayName displayName = conversation.getDisplayNameOrDefault(); return ContactResponsePayload.builder() - .avatarUrl(metadata.get(MetadataKeys.Source.Contact.AVATAR_URL)) + .avatarUrl(metadata.get(MetadataKeys.ConversationKeys.Contact.AVATAR_URL)) .displayName(displayName.toString()) .info(getConversationInfo(metadata)) .build(); diff --git a/backend/api/communication/src/main/java/co/airy/core/api/communication/MetadataController.java b/backend/api/communication/src/main/java/co/airy/core/api/communication/MetadataController.java index ffdff2549c..d5a7705cac 100644 --- a/backend/api/communication/src/main/java/co/airy/core/api/communication/MetadataController.java +++ b/backend/api/communication/src/main/java/co/airy/core/api/communication/MetadataController.java @@ -14,7 +14,7 @@ import javax.validation.Valid; -import static co.airy.model.metadata.MetadataKeys.PUBLIC; +import static co.airy.model.metadata.MetadataKeys.USER_DATA; import static co.airy.model.metadata.MetadataRepository.newConversationMetadata; @RestController @@ -28,7 +28,7 @@ public MetadataController(Stores stores) { @PostMapping("/metadata.set") ResponseEntity setMetadata(@RequestBody @Valid SetMetadataRequestPayload requestPayload) { final Metadata metadata = newConversationMetadata(requestPayload.getConversationId(), - PUBLIC + "." + requestPayload.getKey(), + USER_DATA + "." + requestPayload.getKey(), requestPayload.getValue()); try { stores.storeMetadata(metadata); @@ -41,7 +41,7 @@ ResponseEntity setMetadata(@RequestBody @Valid SetMetadataRequestPayload requ @PostMapping("/metadata.remove") ResponseEntity removeMetadata(@RequestBody @Valid RemoveMetadataRequestPayload requestPayload) { final Subject subject = new Subject("conversation", requestPayload.getConversationId()); - final String metadataKey = PUBLIC + "." + requestPayload.getKey(); + final String metadataKey = USER_DATA + "." + requestPayload.getKey(); try { stores.deleteMetadata(subject, metadataKey); diff --git a/backend/api/communication/src/main/java/co/airy/core/api/communication/dto/Conversation.java b/backend/api/communication/src/main/java/co/airy/core/api/communication/dto/Conversation.java index 1ca8d82a13..d08cf5e2e8 100644 --- a/backend/api/communication/src/main/java/co/airy/core/api/communication/dto/Conversation.java +++ b/backend/api/communication/src/main/java/co/airy/core/api/communication/dto/Conversation.java @@ -35,8 +35,8 @@ public class Conversation implements Serializable { @JsonIgnore public DisplayName getDisplayNameOrDefault() { - String firstName = metadata.get(MetadataKeys.Source.Contact.FIRST_NAME); - String lastName = metadata.get(MetadataKeys.Source.Contact.LAST_NAME); + String firstName = metadata.get(MetadataKeys.ConversationKeys.Contact.FIRST_NAME); + String lastName = metadata.get(MetadataKeys.ConversationKeys.Contact.LAST_NAME); // Default to a display name that looks like: "Facebook 4ecb3" if (firstName == null && lastName == null) { @@ -60,7 +60,7 @@ private String prettifySource(String source) { @JsonIgnore public List getTagIds() { - return MetadataRepository.filterPrefix(metadata, MetadataKeys.TAGS) + return MetadataRepository.filterPrefix(metadata, MetadataKeys.ConversationKeys.TAGS) .keySet() .stream() .map(s -> s.split("\\.")[1]) diff --git a/backend/api/communication/src/test/java/co/airy/core/api/communication/ConversationsInfoTest.java b/backend/api/communication/src/test/java/co/airy/core/api/communication/ConversationsInfoTest.java index 43c56fa0cb..dba2f33c0b 100644 --- a/backend/api/communication/src/test/java/co/airy/core/api/communication/ConversationsInfoTest.java +++ b/backend/api/communication/src/test/java/co/airy/core/api/communication/ConversationsInfoTest.java @@ -64,7 +64,6 @@ void canFetchConversationsInfo() throws Exception { final Channel channel = Channel.newBuilder() .setConnectionState(ChannelConnectionState.CONNECTED) .setId("channel-id") - .setName("channel-name") .setSource("facebook") .setSourceChannelId("ps-id") .build(); diff --git a/backend/api/communication/src/test/java/co/airy/core/api/communication/ConversationsListTest.java b/backend/api/communication/src/test/java/co/airy/core/api/communication/ConversationsListTest.java index a82525b2bf..007f40139c 100644 --- a/backend/api/communication/src/test/java/co/airy/core/api/communication/ConversationsListTest.java +++ b/backend/api/communication/src/test/java/co/airy/core/api/communication/ConversationsListTest.java @@ -58,7 +58,6 @@ class ConversationsListTest { private static final Channel defaultChannel = Channel.newBuilder() .setConnectionState(ChannelConnectionState.CONNECTED) .setId("channel-id") - .setName("channel-name") .setSource("facebook") .setSourceChannelId("ps-id") .build(); @@ -66,7 +65,6 @@ class ConversationsListTest { private static final Channel channelToFind = Channel.newBuilder() .setConnectionState(ChannelConnectionState.CONNECTED) .setId("special-channel-id") - .setName("channel-name") .setSource("facebook") .setSourceChannelId("special-external-channel-id") .build(); @@ -78,11 +76,11 @@ class ConversationsListTest { private static final String userId = "user-id"; private static final List conversations = List.of( - TestConversation.from(UUID.randomUUID().toString(), channelToFind, Map.of(MetadataKeys.Source.Contact.FIRST_NAME, firstNameToFind), 1), + TestConversation.from(UUID.randomUUID().toString(), channelToFind, Map.of(MetadataKeys.ConversationKeys.Contact.FIRST_NAME, firstNameToFind), 1), TestConversation.from(UUID.randomUUID().toString(), channelToFind, - Map.of(MetadataKeys.TAGS + "." + tagId, "", MetadataKeys.TAGS + "." + anotherTagId, ""), + Map.of(MetadataKeys.ConversationKeys.TAGS + "." + tagId, "", MetadataKeys.ConversationKeys.TAGS + "." + anotherTagId, ""), 1), - TestConversation.from(conversationIdToFind, defaultChannel, Map.of(MetadataKeys.TAGS + "." + tagId, ""), 1), + TestConversation.from(conversationIdToFind, defaultChannel, Map.of(MetadataKeys.ConversationKeys.TAGS + "." + tagId, ""), 1), TestConversation.from(UUID.randomUUID().toString(), defaultChannel, 2), TestConversation.from(UUID.randomUUID().toString(), defaultChannel, 5) ); diff --git a/backend/api/communication/src/test/java/co/airy/core/api/communication/ConversationsTagTest.java b/backend/api/communication/src/test/java/co/airy/core/api/communication/ConversationsTagTest.java index 64cedd6317..bd4fd19e64 100644 --- a/backend/api/communication/src/test/java/co/airy/core/api/communication/ConversationsTagTest.java +++ b/backend/api/communication/src/test/java/co/airy/core/api/communication/ConversationsTagTest.java @@ -66,7 +66,6 @@ void canTagAndUntagConversations() throws Exception { final Channel channel = Channel.newBuilder() .setConnectionState(ChannelConnectionState.CONNECTED) .setId("channel-id") - .setName("channel-name") .setSource("facebook") .setSourceChannelId("ps-id") .build(); diff --git a/backend/api/communication/src/test/java/co/airy/core/api/communication/MessagesTest.java b/backend/api/communication/src/test/java/co/airy/core/api/communication/MessagesTest.java index 41722c3738..989eecbf8a 100644 --- a/backend/api/communication/src/test/java/co/airy/core/api/communication/MessagesTest.java +++ b/backend/api/communication/src/test/java/co/airy/core/api/communication/MessagesTest.java @@ -13,7 +13,6 @@ import co.airy.spring.test.WebTestHelper; import org.apache.avro.specific.SpecificRecordBase; import org.apache.kafka.clients.producer.ProducerRecord; -import org.hamcrest.core.StringContains; import org.junit.jupiter.api.AfterAll; import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.BeforeEach; @@ -26,13 +25,10 @@ import org.springframework.test.context.TestPropertySource; import org.springframework.test.context.junit.jupiter.SpringExtension; -import java.time.Duration; import java.time.Instant; -import java.util.Comparator; import java.util.List; import java.util.Map; import java.util.UUID; -import java.util.concurrent.TimeUnit; import static co.airy.core.api.communication.util.Topics.applicationCommunicationChannels; import static co.airy.core.api.communication.util.Topics.applicationCommunicationMessages; @@ -63,7 +59,6 @@ public class MessagesTest { private static final Channel channel = Channel.newBuilder() .setConnectionState(ChannelConnectionState.CONNECTED) .setId(channelId) - .setName("channel-name") .setSource("facebook") .setSourceChannelId("ps-id") .build(); diff --git a/backend/api/communication/src/test/java/co/airy/core/api/communication/MetadataControllerTest.java b/backend/api/communication/src/test/java/co/airy/core/api/communication/MetadataControllerTest.java index cbc2b609c4..b9ade229e6 100644 --- a/backend/api/communication/src/test/java/co/airy/core/api/communication/MetadataControllerTest.java +++ b/backend/api/communication/src/test/java/co/airy/core/api/communication/MetadataControllerTest.java @@ -61,7 +61,6 @@ void canSetMetadata() throws Exception { final Channel channel = Channel.newBuilder() .setConnectionState(ChannelConnectionState.CONNECTED) .setId("channel-id") - .setName("channel-name") .setSource("facebook") .setSourceChannelId("ps-id") .build(); @@ -84,7 +83,6 @@ void canRemoveMetadata() throws Exception { final Channel channel = Channel.newBuilder() .setConnectionState(ChannelConnectionState.CONNECTED) .setId("channel-id") - .setName("channel-name") .setSource("facebook") .setSourceChannelId("ps-id") .build(); diff --git a/backend/api/communication/src/test/java/co/airy/core/api/communication/SendMessageControllerTest.java b/backend/api/communication/src/test/java/co/airy/core/api/communication/SendMessageControllerTest.java index ccbaea3578..ec563444da 100644 --- a/backend/api/communication/src/test/java/co/airy/core/api/communication/SendMessageControllerTest.java +++ b/backend/api/communication/src/test/java/co/airy/core/api/communication/SendMessageControllerTest.java @@ -52,7 +52,6 @@ public class SendMessageControllerTest { private static final Channel channel = Channel.newBuilder() .setConnectionState(ChannelConnectionState.CONNECTED) .setId("channel-id") - .setName("channel-name") .setSource("facebook") .setSourceChannelId("ps-id") .setToken("AWESOME TOKEN") @@ -87,9 +86,8 @@ void canSendTextMessages() throws Exception { final String requestPayload = String.format("{\"conversation_id\":\"%s\"," + "\"message\":%s}", conversationId, messagePayload); - final String userId = "user-id"; - final String response = webTestHelper.post("/messages.send", requestPayload, userId) + final String response = webTestHelper.post("/messages.send", requestPayload, "user-id") .andExpect(status().isOk()) .andReturn().getResponse().getContentAsString(); diff --git a/backend/api/communication/src/test/java/co/airy/core/api/communication/UnreadCountTest.java b/backend/api/communication/src/test/java/co/airy/core/api/communication/UnreadCountTest.java index bf505c08d6..01a40ea42c 100644 --- a/backend/api/communication/src/test/java/co/airy/core/api/communication/UnreadCountTest.java +++ b/backend/api/communication/src/test/java/co/airy/core/api/communication/UnreadCountTest.java @@ -45,7 +45,6 @@ class UnreadCountTest { @BeforeAll static void beforeAll() throws Exception { kafkaTestHelper = new KafkaTestHelper(sharedKafkaTestResource, getTopics()); - kafkaTestHelper.beforeAll(); } @@ -65,7 +64,6 @@ void canResetUnreadCount() throws Exception { final Channel channel = Channel.newBuilder() .setConnectionState(ChannelConnectionState.CONNECTED) .setId("channel-id") - .setName("channel-name") .setSource("facebook") .setSourceChannelId("ps-id") .build(); diff --git a/backend/api/communication/src/test/java/co/airy/core/api/communication/WebSocketControllerTest.java b/backend/api/communication/src/test/java/co/airy/core/api/communication/WebSocketControllerTest.java index a65e67622f..aabf76a976 100644 --- a/backend/api/communication/src/test/java/co/airy/core/api/communication/WebSocketControllerTest.java +++ b/backend/api/communication/src/test/java/co/airy/core/api/communication/WebSocketControllerTest.java @@ -68,7 +68,6 @@ public class WebSocketControllerTest { final Channel channel = Channel.newBuilder() .setConnectionState(ChannelConnectionState.CONNECTED) .setId("facebook-channel-id") - .setName("channel-name") .setSource("facebook") .setSourceChannelId("ps-id") .setToken("AWESOME TOKEN") diff --git a/backend/avro/communication/channel.avsc b/backend/avro/communication/channel.avsc index 5caf122d49..c4c0ba1e4e 100644 --- a/backend/avro/communication/channel.avsc +++ b/backend/avro/communication/channel.avsc @@ -4,11 +4,9 @@ "type": "record", "fields": [ {"name": "id", "type": "string"}, - {"name": "name", "type": "string"}, {"name": "source", "type": "string"}, {"name": "sourceChannelId", "type": "string"}, {"name": "token", "type": ["null", "string"], "default": null}, - {"name": "connectionState", "type": {"name": "ChannelConnectionState", "type": "enum", "symbols": ["CONNECTED", "DISCONNECTED"]}}, - {"name": "imageUrl", "type": ["null", "string"], "default": null} + {"name": "connectionState", "type": {"name": "ChannelConnectionState", "type": "enum", "symbols": ["CONNECTED", "DISCONNECTED"]}} ] } diff --git a/backend/media/src/main/java/co/airy/core/media/MetadataResolver.java b/backend/media/src/main/java/co/airy/core/media/MetadataResolver.java index ffae659dd4..d5c304da22 100644 --- a/backend/media/src/main/java/co/airy/core/media/MetadataResolver.java +++ b/backend/media/src/main/java/co/airy/core/media/MetadataResolver.java @@ -51,7 +51,7 @@ public boolean shouldResolve(Metadata metadata) { } return isConversationMetadata(metadata) - && metadata.getKey().equals(MetadataKeys.Source.Contact.AVATAR_URL) + && metadata.getKey().equals(MetadataKeys.ConversationKeys.Contact.AVATAR_URL) && !mediaUpload.isUserStorageUrl(dataUrl); } diff --git a/backend/media/src/test/java/co/airy/core/media/MetadataTest.java b/backend/media/src/test/java/co/airy/core/media/MetadataTest.java index 30370741cb..aeef4a2751 100644 --- a/backend/media/src/test/java/co/airy/core/media/MetadataTest.java +++ b/backend/media/src/test/java/co/airy/core/media/MetadataTest.java @@ -100,7 +100,7 @@ void storesMetadataUrlsWithRetries() throws Exception { bucket, path, conversationId, - MetadataKeys.Source.Contact.AVATAR_URL); + MetadataKeys.ConversationKeys.Contact.AVATAR_URL); final ArgumentCaptor s3PutCaptor = ArgumentCaptor.forClass(PutObjectRequest.class); @@ -112,7 +112,7 @@ void storesMetadataUrlsWithRetries() throws Exception { kafkaTestHelper.produceRecord( new ProducerRecord<>(applicationCommunicationMetadata.name(), metadataId, newConversationMetadata(conversationId, - MetadataKeys.Source.Contact.AVATAR_URL, + MetadataKeys.ConversationKeys.Contact.AVATAR_URL, originalUrl) )); @@ -127,6 +127,6 @@ void storesMetadataUrlsWithRetries() throws Exception { final PutObjectRequest putObjectRequest = s3PutCaptor.getValue(); assertThat(putObjectRequest.getBucketName(), equalTo(bucket)); assertThat(putObjectRequest.getKey(), - equalTo(String.format("%s/%s", conversationId, MetadataKeys.Source.Contact.AVATAR_URL + ".resolved"))); + equalTo(String.format("%s/%s", conversationId, MetadataKeys.ConversationKeys.Contact.AVATAR_URL + ".resolved"))); } } diff --git a/backend/model/channel/BUILD b/backend/model/channel/BUILD index ce160a468e..933fb6289d 100644 --- a/backend/model/channel/BUILD +++ b/backend/model/channel/BUILD @@ -12,5 +12,6 @@ custom_java_library( "//:jackson", "//:lombok", "//backend/avro/communication:channel-avro", + "//backend/model/metadata", ], ) diff --git a/backend/model/channel/src/main/java/co/airy/model/channel/ChannelPayload.java b/backend/model/channel/src/main/java/co/airy/model/channel/ChannelPayload.java index 72a98faaf3..4e05c3192b 100644 --- a/backend/model/channel/src/main/java/co/airy/model/channel/ChannelPayload.java +++ b/backend/model/channel/src/main/java/co/airy/model/channel/ChannelPayload.java @@ -1,40 +1,43 @@ package co.airy.model.channel; import co.airy.avro.communication.Channel; -import com.fasterxml.jackson.annotation.JsonInclude; +import co.airy.model.channel.dto.ChannelContainer; +import com.fasterxml.jackson.core.JsonFactory; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.node.JsonNodeFactory; import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Data; import lombok.NoArgsConstructor; -import static com.fasterxml.jackson.annotation.JsonInclude.Include.NON_NULL; +import static co.airy.model.metadata.MetadataObjectMapper.getMetadataPayload; +@Data @Builder @NoArgsConstructor @AllArgsConstructor -@Data public class ChannelPayload { private String id; - - @JsonInclude(NON_NULL) - private String name; - - @JsonInclude(NON_NULL) private String source; - - @JsonInclude(NON_NULL) private String sourceChannelId; + private JsonNode metadata; - @JsonInclude(NON_NULL) - private String imageUrl; + public static ChannelPayload fromChannelContainer(ChannelContainer container) { + final Channel channel = container.getChannel(); + return ChannelPayload.builder() + .id(channel.getId()) + .metadata(getMetadataPayload(container.getMetadataMap())) + .source(channel.getSource()) + .sourceChannelId(channel.getSourceChannelId()) + .build(); + } public static ChannelPayload fromChannel(Channel channel) { return ChannelPayload.builder() - .name(channel.getName()) .id(channel.getId()) - .imageUrl(channel.getImageUrl()) + .metadata(JsonNodeFactory.instance.objectNode()) .source(channel.getSource()) .sourceChannelId(channel.getSourceChannelId()) .build(); } -} \ No newline at end of file +} diff --git a/backend/model/channel/src/main/java/co/airy/model/channel/dto/ChannelContainer.java b/backend/model/channel/src/main/java/co/airy/model/channel/dto/ChannelContainer.java new file mode 100644 index 0000000000..0ef0d35d98 --- /dev/null +++ b/backend/model/channel/src/main/java/co/airy/model/channel/dto/ChannelContainer.java @@ -0,0 +1,34 @@ +package co.airy.model.channel.dto; + +import co.airy.avro.communication.Channel; +import co.airy.model.metadata.MetadataKeys; +import co.airy.model.metadata.dto.MetadataMap; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.io.Serializable; +import java.util.List; + +import static co.airy.model.metadata.MetadataRepository.newChannelMetadata; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class ChannelContainer implements Serializable { + private Channel channel; + private MetadataMap metadataMap; + + public MetadataMap getMetadataMap() { + return this.metadataMap != null ? this.metadataMap : getDefaultMetadata(); + } + + private MetadataMap getDefaultMetadata() { + final String defaultName = String.format("%s %s", channel.getSource(), channel.getId().substring(31)); + return MetadataMap.from(List.of( + newChannelMetadata(channel.getId(), MetadataKeys.ChannelKeys.NAME, defaultName) + )); + } +} diff --git a/backend/model/message/src/main/java/co/airy/model/message/MessageRepository.java b/backend/model/message/src/main/java/co/airy/model/message/MessageRepository.java index 8ecbbe7b23..cb0af12dd3 100644 --- a/backend/model/message/src/main/java/co/airy/model/message/MessageRepository.java +++ b/backend/model/message/src/main/java/co/airy/model/message/MessageRepository.java @@ -3,8 +3,8 @@ import co.airy.avro.communication.DeliveryState; import co.airy.avro.communication.Message; -import java.util.Map; import java.time.Instant; +import java.util.Map; public class MessageRepository { public static Message updateDeliveryState(Message message, DeliveryState state) { diff --git a/backend/model/metadata/src/main/java/co/airy/model/metadata/MetadataKeys.java b/backend/model/metadata/src/main/java/co/airy/model/metadata/MetadataKeys.java index dc10ba3ba9..a085bc0ae6 100644 --- a/backend/model/metadata/src/main/java/co/airy/model/metadata/MetadataKeys.java +++ b/backend/model/metadata/src/main/java/co/airy/model/metadata/MetadataKeys.java @@ -4,13 +4,15 @@ * JSON dot notation keys for pre-defined metadata */ public class MetadataKeys { - public static String PUBLIC = "public"; - public static class Source { + public static String USER_DATA = "user_data"; + public static class ConversationKeys { + public static final String TAGS = "tags"; + public static class Contact { - public static final String FIRST_NAME = "source.contact.first_name"; - public static final String LAST_NAME = "source.contact.last_name"; - public static final String AVATAR_URL = "source.contact.avatar_url"; - public static final String FETCH_STATE = "source.contact.fetch_state"; + public static final String FIRST_NAME = "contact.first_name"; + public static final String LAST_NAME = "contact.last_name"; + public static final String AVATAR_URL = "contact.avatar_url"; + public static final String FETCH_STATE = "contact.fetch_state"; } public enum ContactFetchState { @@ -30,6 +32,9 @@ public String toString() { } } - public static final String TAGS = "tags"; + public static class ChannelKeys { + public static final String NAME = "name"; + public static final String IMAGE_URL = "image_url"; + } } diff --git a/backend/model/metadata/src/main/java/co/airy/model/metadata/MetadataObjectMapper.java b/backend/model/metadata/src/main/java/co/airy/model/metadata/MetadataObjectMapper.java index 7b68497ae2..a7aee29203 100644 --- a/backend/model/metadata/src/main/java/co/airy/model/metadata/MetadataObjectMapper.java +++ b/backend/model/metadata/src/main/java/co/airy/model/metadata/MetadataObjectMapper.java @@ -1,9 +1,9 @@ package co.airy.model.metadata; import co.airy.avro.communication.Metadata; +import co.airy.model.metadata.dto.MetadataMap; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.node.JsonNodeFactory; -import com.fasterxml.jackson.databind.node.JsonNodeType; import com.fasterxml.jackson.databind.node.ObjectNode; import lombok.AllArgsConstructor; import lombok.Data; @@ -15,11 +15,16 @@ import java.util.List; import java.util.Map; import java.util.stream.Collectors; -import java.util.stream.Stream; import static java.util.Comparator.comparing; public class MetadataObjectMapper { + + public static JsonNode getMetadataPayload(MetadataMap metadataMap) { + final List metadataList = new ArrayList<>(metadataMap.values()); + return getMetadataPayload(metadataList); + } + public static JsonNode getMetadataPayload(List metadataList) { metadataList.sort(comparing(Metadata::getTimestamp)); diff --git a/backend/model/metadata/src/main/java/co/airy/model/metadata/MetadataRepository.java b/backend/model/metadata/src/main/java/co/airy/model/metadata/MetadataRepository.java index 48517d752e..4c135b0f56 100644 --- a/backend/model/metadata/src/main/java/co/airy/model/metadata/MetadataRepository.java +++ b/backend/model/metadata/src/main/java/co/airy/model/metadata/MetadataRepository.java @@ -7,7 +7,7 @@ import java.util.Map; import java.util.UUID; -import static co.airy.model.metadata.MetadataKeys.PUBLIC; +import static co.airy.model.metadata.MetadataKeys.USER_DATA; import static java.util.stream.Collectors.toMap; public class MetadataRepository { @@ -28,6 +28,15 @@ public static Metadata newConversationMetadata(String conversationId, String key .build(); } + public static Metadata newChannelMetadata(String channelId, String key, String value) { + return Metadata.newBuilder() + .setSubject(new Subject("channel", channelId).toString()) + .setKey(key) + .setValue(value) + .setTimestamp(Instant.now().toEpochMilli()) + .build(); + } + public static Metadata newMessageMetadata(String messageId, String key, String value) { return Metadata.newBuilder() .setSubject(new Subject("message", messageId).toString()) @@ -41,18 +50,22 @@ public static boolean isConversationMetadata(Metadata metadata) { return metadata.getSubject().startsWith("conversation:"); } + public static boolean isChannelMetadata(Metadata metadata) { + return metadata.getSubject().startsWith("channel:"); + } + public static boolean isMessageMetadata(Metadata metadata) { return metadata.getSubject().startsWith("message:"); } public static Map getConversationInfo(Map metadataMap) { - return filterPrefix(metadataMap, PUBLIC); + return filterPrefix(metadataMap, USER_DATA); } public static Metadata newConversationTag(String conversationId, String tagId) { return Metadata.newBuilder() .setSubject(new Subject("conversation",conversationId).toString()) - .setKey(String.format("%s.%s", MetadataKeys.TAGS, tagId)) + .setKey(String.format("%s.%s", MetadataKeys.ConversationKeys.TAGS, tagId)) .setValue("") .setTimestamp(Instant.now().toEpochMilli()) .build(); diff --git a/backend/model/metadata/src/main/java/co/airy/model/metadata/dto/MetadataMap.java b/backend/model/metadata/src/main/java/co/airy/model/metadata/dto/MetadataMap.java new file mode 100644 index 0000000000..38605ae742 --- /dev/null +++ b/backend/model/metadata/src/main/java/co/airy/model/metadata/dto/MetadataMap.java @@ -0,0 +1,27 @@ +package co.airy.model.metadata.dto; + +import co.airy.avro.communication.Metadata; + +import java.io.Serializable; +import java.util.Collection; +import java.util.HashMap; + +public class MetadataMap extends HashMap implements Serializable { + // Convenience methods for aggregating on Metadata in Kafka Streams + public static MetadataMap adder(String key, Metadata metadata, MetadataMap aggregate) { + aggregate.put(metadata.getKey(), metadata); + return aggregate; + } + public static MetadataMap subtractor(String key, Metadata metadata, MetadataMap aggregate) { + aggregate.remove(metadata.getKey()); + return aggregate; + } + + public static MetadataMap from(Collection metadataList) { + final MetadataMap metadataMap = new MetadataMap(); + for (Metadata metadata : metadataList) { + metadataMap.put(metadata.getKey(), metadata); + } + return metadataMap; + } +} diff --git a/backend/model/metadata/src/test/java/co/airy/model/metadata/ObjectMapperTest.java b/backend/model/metadata/src/test/java/co/airy/model/metadata/ObjectMapperTest.java index bd5f29f4c9..22afeb234a 100644 --- a/backend/model/metadata/src/test/java/co/airy/model/metadata/ObjectMapperTest.java +++ b/backend/model/metadata/src/test/java/co/airy/model/metadata/ObjectMapperTest.java @@ -1,7 +1,6 @@ package co.airy.model.metadata; import co.airy.avro.communication.Metadata; -import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; import org.junit.jupiter.api.Test; @@ -9,8 +8,6 @@ import java.util.ArrayList; import java.util.Arrays; import java.util.List; -import java.util.stream.Collectors; -import java.util.stream.Stream; import static co.airy.model.metadata.MetadataObjectMapper.getMetadataFromJson; import static co.airy.model.metadata.MetadataObjectMapper.getMetadataPayload; diff --git a/backend/sources/chat-plugin/src/test/java/co/airy/core/chat_plugin/ChatControllerTest.java b/backend/sources/chat-plugin/src/test/java/co/airy/core/chat_plugin/ChatControllerTest.java index c747c9f991..305e62c415 100644 --- a/backend/sources/chat-plugin/src/test/java/co/airy/core/chat_plugin/ChatControllerTest.java +++ b/backend/sources/chat-plugin/src/test/java/co/airy/core/chat_plugin/ChatControllerTest.java @@ -50,7 +50,6 @@ import static org.hamcrest.core.StringContains.containsString; import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; -import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.head; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; @@ -76,7 +75,6 @@ public class ChatControllerTest { private static final Channel channel = Channel.newBuilder() .setConnectionState(ChannelConnectionState.CONNECTED) .setId(UUID.randomUUID().toString()) - .setName("Chat Plugin") .setSource("chat_plugin") .setSourceChannelId("some custom identifier") .build(); 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 5edb18c636..a417f9f506 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 @@ -9,6 +9,9 @@ import co.airy.core.sources.facebook.payload.ExploreRequestPayload; import co.airy.core.sources.facebook.payload.ExploreResponsePayload; import co.airy.core.sources.facebook.payload.PageInfoResponsePayload; +import co.airy.model.channel.dto.ChannelContainer; +import co.airy.model.metadata.MetadataKeys; +import co.airy.model.metadata.dto.MetadataMap; import co.airy.spring.web.payload.RequestErrorResponsePayload; import co.airy.uuid.UUIDv5; import org.apache.kafka.streams.state.KeyValueIterator; @@ -23,7 +26,8 @@ import java.util.List; import java.util.Optional; -import static co.airy.model.channel.ChannelPayload.fromChannel; +import static co.airy.model.channel.ChannelPayload.fromChannelContainer; +import static co.airy.model.metadata.MetadataRepository.newChannelMetadata; import static java.util.stream.Collectors.toList; @RestController @@ -85,19 +89,23 @@ ResponseEntity connect(@RequestBody @Valid ConnectRequestPayload requestPaylo api.connectPageToApp(fbPageWithConnectInfo.getAccessToken()); - final Channel channel = Channel.newBuilder() - .setId(channelId) - .setConnectionState(ChannelConnectionState.CONNECTED) - .setImageUrl(Optional.ofNullable(requestPayload.getImageUrl()).orElse(fbPageWithConnectInfo.getPicture().getData().getUrl())) - .setName(Optional.ofNullable(requestPayload.getName()).orElse(fbPageWithConnectInfo.getNameWithLocationDescriptor())) - .setSource("facebook") - .setSourceChannelId(pageId) - .setToken(token) - .build(); + final ChannelContainer container = ChannelContainer.builder() + .channel( + Channel.newBuilder() + .setId(channelId) + .setConnectionState(ChannelConnectionState.CONNECTED) + .setSource("facebook") + .setSourceChannelId(pageId) + .build() + ) + .metadataMap(MetadataMap.from(List.of( + newChannelMetadata(channelId, MetadataKeys.ChannelKeys.NAME, Optional.ofNullable(requestPayload.getName()).orElse(fbPageWithConnectInfo.getNameWithLocationDescriptor())), + newChannelMetadata(channelId, MetadataKeys.ChannelKeys.IMAGE_URL, Optional.ofNullable(requestPayload.getImageUrl()).orElse(fbPageWithConnectInfo.getPicture().getData().getUrl())) + ))).build(); - stores.storeChannel(channel); + stores.storeChannelContainer(container); - return ResponseEntity.ok(fromChannel(channel)); + return ResponseEntity.ok(fromChannelContainer(container)); } catch (ApiException e) { return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(new RequestErrorResponsePayload(e.getMessage())); } catch (Exception e) { diff --git a/backend/sources/facebook/connector/src/main/java/co/airy/core/sources/facebook/Connector.java b/backend/sources/facebook/connector/src/main/java/co/airy/core/sources/facebook/Connector.java index c456286661..72b52c7c67 100644 --- a/backend/sources/facebook/connector/src/main/java/co/airy/core/sources/facebook/Connector.java +++ b/backend/sources/facebook/connector/src/main/java/co/airy/core/sources/facebook/Connector.java @@ -8,8 +8,8 @@ import co.airy.core.sources.facebook.api.Mapper; import co.airy.core.sources.facebook.api.model.SendMessagePayload; import co.airy.core.sources.facebook.api.model.UserProfile; -import co.airy.core.sources.facebook.dto.Conversation; import co.airy.core.sources.facebook.dto.SendMessageRequest; +import co.airy.core.sources.facebook.dto.Conversation; import co.airy.log.AiryLoggerFactory; import co.airy.model.metadata.MetadataKeys; import co.airy.spring.auth.IgnoreAuthPattern; @@ -24,8 +24,8 @@ import java.util.Map; import static co.airy.model.message.MessageRepository.updateDeliveryState; -import static co.airy.model.metadata.MetadataKeys.Source.ContactFetchState.failed; -import static co.airy.model.metadata.MetadataKeys.Source.ContactFetchState.ok; +import static co.airy.model.metadata.MetadataKeys.ConversationKeys.ContactFetchState.failed; +import static co.airy.model.metadata.MetadataKeys.ConversationKeys.ContactFetchState.ok; import static co.airy.model.metadata.MetadataRepository.getId; import static co.airy.model.metadata.MetadataRepository.newConversationMetadata; @@ -65,7 +65,7 @@ public Message sendMessage(SendMessageRequest sendMessageRequest) { public boolean needsMetadataFetched(Conversation conversation) { final Map metadata = conversation.getMetadata(); - final String fetchState = metadata.get(MetadataKeys.Source.Contact.FETCH_STATE); + final String fetchState = metadata.get(MetadataKeys.ConversationKeys.Contact.FETCH_STATE); return !ok.toString().equals(fetchState) && !failed.toString().equals(fetchState); } @@ -76,26 +76,26 @@ public List> fetchMetadata(String conversationId, Con final List> recordList = new ArrayList<>(); if (profile.getFirstName() != null) { - final Metadata firstName = newConversationMetadata(conversationId, MetadataKeys.Source.Contact.FIRST_NAME, profile.getFirstName()); + final Metadata firstName = newConversationMetadata(conversationId, MetadataKeys.ConversationKeys.Contact.FIRST_NAME, profile.getFirstName()); recordList.add(KeyValue.pair(getId(firstName).toString(), firstName)); } if (profile.getLastName() != null) { - final Metadata lastName = newConversationMetadata(conversationId, MetadataKeys.Source.Contact.LAST_NAME, profile.getLastName()); + final Metadata lastName = newConversationMetadata(conversationId, MetadataKeys.ConversationKeys.Contact.LAST_NAME, profile.getLastName()); recordList.add(KeyValue.pair(getId(lastName).toString(), lastName)); } if (profile.getProfilePic() != null) { - final Metadata avatarUrl = newConversationMetadata(conversationId, MetadataKeys.Source.Contact.AVATAR_URL, profile.getProfilePic()); + final Metadata avatarUrl = newConversationMetadata(conversationId, MetadataKeys.ConversationKeys.Contact.AVATAR_URL, profile.getProfilePic()); recordList.add(KeyValue.pair(getId(avatarUrl).toString(), avatarUrl)); } final String newFetchState = recordList.size() > 0 ? ok.toString() : failed.toString(); - final String oldFetchState = conversation.getMetadata().get(MetadataKeys.Source.Contact.FETCH_STATE); + final String oldFetchState = conversation.getMetadata().get(MetadataKeys.ConversationKeys.Contact.FETCH_STATE); // Only update fetch state if there has been a change if (!newFetchState.equals(oldFetchState)) { - final Metadata fetchState = newConversationMetadata(conversationId, MetadataKeys.Source.Contact.FETCH_STATE, newFetchState); + final Metadata fetchState = newConversationMetadata(conversationId, MetadataKeys.ConversationKeys.Contact.FETCH_STATE, newFetchState); recordList.add(KeyValue.pair(getId(fetchState).toString(), fetchState)); } 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 309032af93..9093d5f953 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 @@ -12,7 +12,7 @@ import co.airy.kafka.schema.application.ApplicationCommunicationMessages; import co.airy.kafka.schema.application.ApplicationCommunicationMetadata; import co.airy.kafka.streams.KafkaStreamsWrapper; -import co.airy.log.AiryLoggerFactory; +import co.airy.model.channel.dto.ChannelContainer; import org.apache.avro.specific.SpecificRecordBase; import org.apache.kafka.clients.producer.KafkaProducer; import org.apache.kafka.clients.producer.ProducerRecord; @@ -22,9 +22,7 @@ import org.apache.kafka.streams.kstream.KStream; import org.apache.kafka.streams.kstream.KTable; import org.apache.kafka.streams.kstream.Materialized; -import org.apache.kafka.streams.kstream.Suppressed; import org.apache.kafka.streams.state.ReadOnlyKeyValueStore; -import org.slf4j.Logger; import org.springframework.beans.factory.DisposableBean; import org.springframework.boot.actuate.health.Health; import org.springframework.boot.actuate.health.HealthIndicator; @@ -32,23 +30,23 @@ import org.springframework.context.ApplicationListener; import org.springframework.stereotype.Service; -import java.time.Duration; import java.util.HashMap; import java.util.Map; import java.util.Optional; import java.util.concurrent.ExecutionException; +import static co.airy.model.metadata.MetadataRepository.getId; import static co.airy.model.metadata.MetadataRepository.getSubject; import static co.airy.model.metadata.MetadataRepository.isConversationMetadata; @Service public class Stores implements ApplicationListener, DisposableBean, HealthIndicator { - private static final Logger log = AiryLoggerFactory.getLogger(Stores.class); private static final String appId = "sources.facebook.ConnectorStores"; private final KafkaStreamsWrapper streams; private final String channelsStore = "channels-store"; private final String applicationCommunicationChannels = new ApplicationCommunicationChannels().name(); + private final String applicationCommunicationMetadata = new ApplicationCommunicationMetadata().name(); private final KafkaProducer producer; private final Connector connector; @@ -133,6 +131,15 @@ public ReadOnlyKeyValueStore getChannelsStore() { return streams.acquireLocalStore(channelsStore); } + public void storeChannelContainer(ChannelContainer container) throws ExecutionException, InterruptedException { + final Channel channel = container.getChannel(); + storeChannel(channel); + + for (Metadata metadata : container.getMetadataMap().values()) { + producer.send(new ProducerRecord<>(applicationCommunicationMetadata, getId(metadata).toString(), metadata)).get(); + } + } + public void storeChannel(Channel channel) throws ExecutionException, InterruptedException { producer.send(new ProducerRecord<>(applicationCommunicationChannels, channel.getId(), channel)).get(); } diff --git a/backend/sources/facebook/connector/src/test/java/co/airy/core/sources/facebook/FetchMetadataTest.java b/backend/sources/facebook/connector/src/test/java/co/airy/core/sources/facebook/FetchMetadataTest.java index f1efd323c5..e4666663bc 100644 --- a/backend/sources/facebook/connector/src/test/java/co/airy/core/sources/facebook/FetchMetadataTest.java +++ b/backend/sources/facebook/connector/src/test/java/co/airy/core/sources/facebook/FetchMetadataTest.java @@ -103,7 +103,6 @@ void canFetchMetadata() throws Exception { .setToken(token) .setSourceChannelId("ps-id") .setSource("facebook") - .setName("name") .setId(channelId) .setConnectionState(ChannelConnectionState.CONNECTED) .build() @@ -126,16 +125,16 @@ void canFetchMetadata() throws Exception { assertThat(metadataList, hasSize(4)); assertTrue(metadataList.stream().anyMatch((metadata -> - metadata.getKey().equals(MetadataKeys.Source.Contact.FIRST_NAME) && metadata.getValue().equals(firstName) + metadata.getKey().equals(MetadataKeys.ConversationKeys.Contact.FIRST_NAME) && metadata.getValue().equals(firstName) ))); assertTrue(metadataList.stream().anyMatch((metadata -> - metadata.getKey().equals(MetadataKeys.Source.Contact.LAST_NAME) && metadata.getValue().equals(lastName) + metadata.getKey().equals(MetadataKeys.ConversationKeys.Contact.LAST_NAME) && metadata.getValue().equals(lastName) ))); assertTrue(metadataList.stream().anyMatch((metadata -> - metadata.getKey().equals(MetadataKeys.Source.Contact.AVATAR_URL) && metadata.getValue().equals(avatarUrl) + metadata.getKey().equals(MetadataKeys.ConversationKeys.Contact.AVATAR_URL) && metadata.getValue().equals(avatarUrl) ))); assertTrue(metadataList.stream().anyMatch((metadata -> - metadata.getKey().equals(MetadataKeys.Source.Contact.FETCH_STATE) && metadata.getValue().equals("ok") + metadata.getKey().equals(MetadataKeys.ConversationKeys.Contact.FETCH_STATE) && metadata.getValue().equals("ok") ))); assertThat(sourceConversationIdCaptor.getValue(), equalTo(sourceConversationId)); diff --git a/backend/sources/facebook/connector/src/test/java/co/airy/core/sources/facebook/SendMessageTest.java b/backend/sources/facebook/connector/src/test/java/co/airy/core/sources/facebook/SendMessageTest.java index ea55061ef2..1b7bd012ed 100644 --- a/backend/sources/facebook/connector/src/test/java/co/airy/core/sources/facebook/SendMessageTest.java +++ b/backend/sources/facebook/connector/src/test/java/co/airy/core/sources/facebook/SendMessageTest.java @@ -106,7 +106,6 @@ void canSendMessageViaTheFacebookApi() throws Exception { .setToken(token) .setSourceChannelId("ps-id") .setSource("facebook") - .setName("name") .setId(channelId) .setConnectionState(ChannelConnectionState.CONNECTED) .build() diff --git a/backend/sources/facebook/events-router/src/test/java/co/airy/core/sources/facebook/EventsRouterTest.java b/backend/sources/facebook/events-router/src/test/java/co/airy/core/sources/facebook/EventsRouterTest.java index 86e8deb0c9..035ede8bf0 100644 --- a/backend/sources/facebook/events-router/src/test/java/co/airy/core/sources/facebook/EventsRouterTest.java +++ b/backend/sources/facebook/events-router/src/test/java/co/airy/core/sources/facebook/EventsRouterTest.java @@ -101,7 +101,6 @@ void joinsAndCountsMessagesCorrectly() throws Exception { .setId(channelId) .setConnectionState(ChannelConnectionState.CONNECTED) .setSourceChannelId(pageId) - .setName("fb-page-a") .setSource("facebook") .setToken("") .build())); @@ -152,9 +151,7 @@ void parsesPageMessagesCorrectly() throws Exception { .setId(channelId) .setConnectionState(ChannelConnectionState.CONNECTED) .setSourceChannelId(pageId) - .setName("fb-page-a") .setSource("facebook") - .setToken("") .build())); final String webhookPayload = String.format(payload, pageId, pageId); diff --git a/backend/sources/google/connector/BUILD b/backend/sources/google/connector/BUILD index dd317369ba..c9023349be 100644 --- a/backend/sources/google/connector/BUILD +++ b/backend/sources/google/connector/BUILD @@ -7,6 +7,7 @@ app_deps = [ "//:springboot_actuator", "//backend/model/channel", "//backend/model/message", + "//backend/model/metadata", "//lib/java/spring/kafka/core:spring-kafka-core", "//lib/java/spring/kafka/streams:spring-kafka-streams", "//lib/java/uuid", diff --git a/backend/sources/google/connector/src/main/java/co/airy/core/sources/google/ChannelsController.java b/backend/sources/google/connector/src/main/java/co/airy/core/sources/google/ChannelsController.java index 02696e713a..fd882a496f 100644 --- a/backend/sources/google/connector/src/main/java/co/airy/core/sources/google/ChannelsController.java +++ b/backend/sources/google/connector/src/main/java/co/airy/core/sources/google/ChannelsController.java @@ -2,7 +2,11 @@ import co.airy.avro.communication.Channel; import co.airy.avro.communication.ChannelConnectionState; +import co.airy.avro.communication.Metadata; import co.airy.kafka.schema.application.ApplicationCommunicationChannels; +import co.airy.model.channel.dto.ChannelContainer; +import co.airy.model.metadata.MetadataKeys; +import co.airy.model.metadata.dto.MetadataMap; import co.airy.spring.web.payload.EmptyResponsePayload; import co.airy.uuid.UUIDv5; import lombok.AllArgsConstructor; @@ -18,9 +22,12 @@ import javax.validation.Valid; import javax.validation.constraints.NotNull; +import java.util.ArrayList; +import java.util.List; import java.util.UUID; -import static co.airy.model.channel.ChannelPayload.fromChannel; +import static co.airy.model.channel.ChannelPayload.fromChannelContainer; +import static co.airy.model.metadata.MetadataRepository.newChannelMetadata; @RestController public class ChannelsController { @@ -41,22 +48,31 @@ ResponseEntity connect(@RequestBody @Valid ConnectChannelRequestPayload reque final String channelId = UUIDv5.fromNamespaceAndName(sourceIdentifier, gbmId).toString(); - final Channel channel = Channel.newBuilder() - .setId(channelId) - .setConnectionState(ChannelConnectionState.CONNECTED) - .setSource(sourceIdentifier) - .setSourceChannelId(gbmId) - .setName(requestPayload.getName()) - .setImageUrl(requestPayload.getImageUrl()) - .build(); - try { - producer.send(new ProducerRecord<>(applicationCommunicationChannels, channel.getId(), channel)).get(); + List metadataList = new ArrayList<>(); + metadataList.add(newChannelMetadata(channelId, MetadataKeys.ChannelKeys.NAME, requestPayload.getName())); + + if (requestPayload.getImageUrl() != null) { + metadataList.add(newChannelMetadata(channelId, MetadataKeys.ChannelKeys.IMAGE_URL, requestPayload.getImageUrl())); + } + + final ChannelContainer container = ChannelContainer.builder() + .channel( + Channel.newBuilder() + .setId(channelId) + .setConnectionState(ChannelConnectionState.CONNECTED) + .setSource(sourceIdentifier) + .setSourceChannelId(gbmId) + .build() + ) + .metadataMap(MetadataMap.from(metadataList)).build(); + + stores.storeChannelContainer(container); + + return ResponseEntity.ok(fromChannelContainer(container)); } catch (Exception e) { return ResponseEntity.status(HttpStatus.SERVICE_UNAVAILABLE).build(); } - - return ResponseEntity.ok(fromChannel(channel)); } @PostMapping("/channels.google.disconnect") @@ -92,10 +108,8 @@ ResponseEntity disconnect(@RequestBody @Valid DisconnectChannelRequestPayload class ConnectChannelRequestPayload { @NotNull private String gbmId; - @NotNull private String name; - private String imageUrl; } diff --git a/backend/sources/google/connector/src/main/java/co/airy/core/sources/google/Stores.java b/backend/sources/google/connector/src/main/java/co/airy/core/sources/google/Stores.java index b0ce5eb282..243c51b462 100644 --- a/backend/sources/google/connector/src/main/java/co/airy/core/sources/google/Stores.java +++ b/backend/sources/google/connector/src/main/java/co/airy/core/sources/google/Stores.java @@ -3,11 +3,17 @@ import co.airy.avro.communication.Channel; import co.airy.avro.communication.DeliveryState; import co.airy.avro.communication.Message; +import co.airy.avro.communication.Metadata; import co.airy.avro.communication.SenderType; import co.airy.core.sources.google.model.SendMessageRequest; import co.airy.kafka.schema.application.ApplicationCommunicationChannels; import co.airy.kafka.schema.application.ApplicationCommunicationMessages; +import co.airy.kafka.schema.application.ApplicationCommunicationMetadata; import co.airy.kafka.streams.KafkaStreamsWrapper; +import co.airy.model.channel.dto.ChannelContainer; +import org.apache.avro.specific.SpecificRecordBase; +import org.apache.kafka.clients.producer.KafkaProducer; +import org.apache.kafka.clients.producer.ProducerRecord; import org.apache.kafka.streams.KafkaStreams; import org.apache.kafka.streams.StreamsBuilder; import org.apache.kafka.streams.kstream.KStream; @@ -21,18 +27,25 @@ import org.springframework.context.ApplicationListener; import org.springframework.stereotype.Component; +import java.util.concurrent.ExecutionException; + +import static co.airy.model.metadata.MetadataRepository.getId; + @Component public class Stores implements ApplicationListener, DisposableBean, HealthIndicator { private static final String appId = "sources.google.ConnectorStores"; private final String channelsStore = "channels-store"; private static final String applicationCommunicationChannels = new ApplicationCommunicationChannels().name(); + private static final String applicationCommunicationMetadata = new ApplicationCommunicationMetadata().name(); private final KafkaStreamsWrapper streams; + private final KafkaProducer producer; private final Connector connector; - Stores(KafkaStreamsWrapper streams, Connector connector) { + Stores(KafkaStreamsWrapper streams, Connector connector, KafkaProducer producer) { this.streams = streams; this.connector = connector; + this.producer = producer; } public void onApplicationEvent(ApplicationReadyEvent applicationReadyEvent) { @@ -66,6 +79,20 @@ public ReadOnlyKeyValueStore getChannelsStore() { return streams.acquireLocalStore(channelsStore); } + + public void storeChannelContainer(ChannelContainer container) throws ExecutionException, InterruptedException { + final Channel channel = container.getChannel(); + storeChannel(channel); + + for (Metadata metadata : container.getMetadataMap().values()) { + producer.send(new ProducerRecord<>(applicationCommunicationMetadata, getId(metadata).toString(), metadata)).get(); + } + } + + public void storeChannel(Channel channel) throws ExecutionException, InterruptedException { + producer.send(new ProducerRecord<>(applicationCommunicationChannels, channel.getId(), channel)).get(); + } + @Override public void destroy() { if (streams != null) { diff --git a/backend/sources/google/connector/src/test/java/co/airy/core/sources/google/SendMessageTest.java b/backend/sources/google/connector/src/test/java/co/airy/core/sources/google/SendMessageTest.java index 96d2f559d0..2501cd3248 100644 --- a/backend/sources/google/connector/src/test/java/co/airy/core/sources/google/SendMessageTest.java +++ b/backend/sources/google/connector/src/test/java/co/airy/core/sources/google/SendMessageTest.java @@ -11,7 +11,6 @@ import co.airy.kafka.test.junit.SharedKafkaTestResource; import co.airy.spring.core.AirySpringBootApplication; import com.fasterxml.jackson.databind.JsonNode; -import com.fasterxml.jackson.databind.ObjectMapper; import org.apache.kafka.clients.producer.ProducerRecord; import org.junit.jupiter.api.AfterAll; import org.junit.jupiter.api.BeforeAll; diff --git a/backend/sources/google/events-router/src/main/java/co/airy/core/sources/google/InfoExtractor.java b/backend/sources/google/events-router/src/main/java/co/airy/core/sources/google/InfoExtractor.java index 4c96b038a5..66e1cdd894 100644 --- a/backend/sources/google/events-router/src/main/java/co/airy/core/sources/google/InfoExtractor.java +++ b/backend/sources/google/events-router/src/main/java/co/airy/core/sources/google/InfoExtractor.java @@ -49,10 +49,10 @@ static List getMetadataFromContext(String conversationId, WebhookEvent final String firstName = displayName.substring(0, lastIndexOf); final String lastName = displayName.substring(lastIndexOf + 1); - metadata.add(newConversationMetadata(conversationId, MetadataKeys.Source.Contact.FIRST_NAME, firstName)); - metadata.add(newConversationMetadata(conversationId, MetadataKeys.Source.Contact.LAST_NAME, lastName)); + metadata.add(newConversationMetadata(conversationId, MetadataKeys.ConversationKeys.Contact.FIRST_NAME, firstName)); + metadata.add(newConversationMetadata(conversationId, MetadataKeys.ConversationKeys.Contact.LAST_NAME, lastName)); } else { - metadata.add(newConversationMetadata(conversationId, MetadataKeys.Source.Contact.FIRST_NAME, displayName)); + metadata.add(newConversationMetadata(conversationId, MetadataKeys.ConversationKeys.Contact.FIRST_NAME, displayName)); } } diff --git a/backend/sources/google/events-router/src/test/java/co/airy/core/sources/google/EventsRouterTest.java b/backend/sources/google/events-router/src/test/java/co/airy/core/sources/google/EventsRouterTest.java index d614e7c486..7d540bcd8e 100644 --- a/backend/sources/google/events-router/src/test/java/co/airy/core/sources/google/EventsRouterTest.java +++ b/backend/sources/google/events-router/src/test/java/co/airy/core/sources/google/EventsRouterTest.java @@ -81,9 +81,7 @@ void canRouteGoogleMessages() throws Exception { .setId(channelId) .setConnectionState(ChannelConnectionState.CONNECTED) .setSourceChannelId(agentId) - .setName("Awesome place") .setSource("google") - .setToken("") .build())); // Wait for the channels table to catch up @@ -106,9 +104,7 @@ void canRouteGoogleMetadata() throws Exception { .setId(channelId) .setConnectionState(ChannelConnectionState.CONNECTED) .setSourceChannelId(agentId) - .setName("Awesome place") .setSource("google") - .setToken("") .build())); // Wait for the channels table to catch up @@ -136,15 +132,15 @@ void canRouteGoogleMetadata() throws Exception { assertThat(metadataList, hasSize(3)); assertTrue(metadataList.stream().anyMatch((metadata -> - metadata.getKey().equals(MetadataKeys.Source.Contact.FIRST_NAME) && + metadata.getKey().equals(MetadataKeys.ConversationKeys.Contact.FIRST_NAME) && metadata.getValue().equals(singleName) ))); assertTrue(metadataList.stream().anyMatch((metadata -> - metadata.getKey().equals(MetadataKeys.Source.Contact.FIRST_NAME) && + metadata.getKey().equals(MetadataKeys.ConversationKeys.Contact.FIRST_NAME) && metadata.getValue().equals("Grace") ))); assertTrue(metadataList.stream().anyMatch((metadata -> - metadata.getKey().equals(MetadataKeys.Source.Contact.LAST_NAME) && + metadata.getKey().equals(MetadataKeys.ConversationKeys.Contact.LAST_NAME) && metadata.getValue().equals("Brewster Murray Hopper") ))); } diff --git a/backend/sources/twilio/connector/BUILD b/backend/sources/twilio/connector/BUILD index 0298cc6b63..582a606baa 100644 --- a/backend/sources/twilio/connector/BUILD +++ b/backend/sources/twilio/connector/BUILD @@ -7,6 +7,7 @@ app_deps = [ "//:springboot_actuator", "//backend/model/channel", "//backend/model/message", + "//backend/model/metadata", "//lib/java/log", "//lib/java/uuid", "@maven//:com_twilio_sdk_twilio", diff --git a/backend/sources/twilio/connector/src/main/java/co/airy/core/sources/twilio/ChannelsController.java b/backend/sources/twilio/connector/src/main/java/co/airy/core/sources/twilio/ChannelsController.java index 5263d29c44..1a951512e2 100644 --- a/backend/sources/twilio/connector/src/main/java/co/airy/core/sources/twilio/ChannelsController.java +++ b/backend/sources/twilio/connector/src/main/java/co/airy/core/sources/twilio/ChannelsController.java @@ -2,14 +2,17 @@ import co.airy.avro.communication.Channel; import co.airy.avro.communication.ChannelConnectionState; +import co.airy.avro.communication.Metadata; import co.airy.kafka.schema.application.ApplicationCommunicationChannels; +import co.airy.model.channel.dto.ChannelContainer; +import co.airy.model.metadata.MetadataKeys; +import co.airy.model.metadata.dto.MetadataMap; import co.airy.spring.web.payload.EmptyResponsePayload; import co.airy.uuid.UUIDv5; import lombok.AllArgsConstructor; import lombok.Data; import lombok.NoArgsConstructor; import org.apache.kafka.clients.producer.KafkaProducer; -import org.apache.kafka.clients.producer.ProducerRecord; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.PostMapping; @@ -18,9 +21,12 @@ import javax.validation.Valid; import javax.validation.constraints.NotNull; +import java.util.ArrayList; +import java.util.List; import java.util.UUID; -import static co.airy.model.channel.ChannelPayload.fromChannel; +import static co.airy.model.channel.ChannelPayload.fromChannelContainer; +import static co.airy.model.metadata.MetadataRepository.newChannelMetadata; @RestController public class ChannelsController { @@ -43,11 +49,9 @@ ResponseEntity connectSms(@RequestBody @Valid ConnectChannelRequestPayload re .setConnectionState(ChannelConnectionState.CONNECTED) .setSource("twilio.sms") .setSourceChannelId(requestPayload.getPhoneNumber()) - .setName(requestPayload.getName()) - .setImageUrl(requestPayload.getImageUrl()) .build(); - return connectChannel(channel); + return connectChannel(channel, requestPayload.getName(), requestPayload.getImageUrl()); } @PostMapping("/channels.twilio.whatsapp.connect") @@ -60,21 +64,29 @@ ResponseEntity connectWhatsapp(@RequestBody @Valid ConnectChannelRequestPaylo .setConnectionState(ChannelConnectionState.CONNECTED) .setSource("twilio.whatsapp") .setSourceChannelId(phoneNumber) - .setName(requestPayload.getName()) - .setImageUrl(requestPayload.getImageUrl()) .build(); - return connectChannel(channel); + return connectChannel(channel, requestPayload.getName(), requestPayload.getImageUrl()); } - private ResponseEntity connectChannel(Channel channel) { + private ResponseEntity connectChannel(Channel channel, String name, String imageUrl) { try { - producer.send(new ProducerRecord<>(applicationCommunicationChannels, channel.getId(), channel)).get(); + List metadataList = new ArrayList<>(); + metadataList.add(newChannelMetadata(channel.getId(), MetadataKeys.ChannelKeys.NAME, name)); + + if (imageUrl != null) { + metadataList.add(newChannelMetadata(channel.getId(), MetadataKeys.ChannelKeys.IMAGE_URL, imageUrl)); + } + + final ChannelContainer container = ChannelContainer.builder() + .channel(channel) + .metadataMap(MetadataMap.from(metadataList)).build(); + + stores.storeChannelContainer(container); + return ResponseEntity.ok(fromChannelContainer(container)); } catch (Exception e) { return ResponseEntity.status(HttpStatus.SERVICE_UNAVAILABLE).build(); } - - return ResponseEntity.ok(fromChannel(channel)); } @PostMapping("/channels.twilio.sms.disconnect") @@ -104,7 +116,7 @@ private ResponseEntity disconnect(@RequestBody @Valid DisconnectChannelReques channel.setToken(null); try { - producer.send(new ProducerRecord<>(applicationCommunicationChannels, channel.getId(), channel)).get(); + stores.storeChannel(channel); } catch (Exception e) { return ResponseEntity.status(HttpStatus.SERVICE_UNAVAILABLE).build(); } @@ -120,10 +132,8 @@ private ResponseEntity disconnect(@RequestBody @Valid DisconnectChannelReques class ConnectChannelRequestPayload { @NotNull private String phoneNumber; - @NotNull private String name; - private String imageUrl; } diff --git a/backend/sources/twilio/connector/src/main/java/co/airy/core/sources/twilio/Stores.java b/backend/sources/twilio/connector/src/main/java/co/airy/core/sources/twilio/Stores.java index ba2746be62..7477967561 100644 --- a/backend/sources/twilio/connector/src/main/java/co/airy/core/sources/twilio/Stores.java +++ b/backend/sources/twilio/connector/src/main/java/co/airy/core/sources/twilio/Stores.java @@ -4,11 +4,17 @@ import co.airy.avro.communication.ChannelConnectionState; import co.airy.avro.communication.DeliveryState; import co.airy.avro.communication.Message; +import co.airy.avro.communication.Metadata; import co.airy.avro.communication.SenderType; import co.airy.core.sources.twilio.dto.SendMessageRequest; import co.airy.kafka.schema.application.ApplicationCommunicationChannels; import co.airy.kafka.schema.application.ApplicationCommunicationMessages; +import co.airy.kafka.schema.application.ApplicationCommunicationMetadata; import co.airy.kafka.streams.KafkaStreamsWrapper; +import co.airy.model.channel.dto.ChannelContainer; +import org.apache.avro.specific.SpecificRecordBase; +import org.apache.kafka.clients.producer.KafkaProducer; +import org.apache.kafka.clients.producer.ProducerRecord; import org.apache.kafka.streams.KafkaStreams; import org.apache.kafka.streams.StreamsBuilder; import org.apache.kafka.streams.kstream.KStream; @@ -22,20 +28,26 @@ import org.springframework.context.ApplicationListener; import org.springframework.stereotype.Component; +import java.util.concurrent.ExecutionException; + +import static co.airy.model.metadata.MetadataRepository.getId; + @Component public class Stores implements ApplicationListener, DisposableBean, HealthIndicator { private static final String applicationCommunicationChannels = new ApplicationCommunicationChannels().name(); - + private static final String applicationCommunicationMetadata = new ApplicationCommunicationMetadata().name(); private static final String appId = "sources.twilio.ConnectorStores"; private final String channelsStore = "channels-store"; private final KafkaStreamsWrapper streams; + private final KafkaProducer producer; private final Connector connector; - Stores(KafkaStreamsWrapper streams, Connector connector) { + Stores(KafkaStreamsWrapper streams, Connector connector, KafkaProducer producer) { this.streams = streams; this.connector = connector; + this.producer = producer; } @Override @@ -83,6 +95,19 @@ public ReadOnlyKeyValueStore getChannelsStore() { return streams.acquireLocalStore(channelsStore); } + public void storeChannelContainer(ChannelContainer container) throws ExecutionException, InterruptedException { + final Channel channel = container.getChannel(); + storeChannel(channel); + + for (Metadata metadata : container.getMetadataMap().values()) { + producer.send(new ProducerRecord<>(applicationCommunicationMetadata, getId(metadata).toString(), metadata)).get(); + } + } + + public void storeChannel(Channel channel) throws ExecutionException, InterruptedException { + producer.send(new ProducerRecord<>(applicationCommunicationChannels, channel.getId(), channel)).get(); + } + @Override public void destroy() { if (streams != null) { diff --git a/backend/sources/twilio/connector/src/test/java/co/airy/core/sources/twilio/SendMessageTest.java b/backend/sources/twilio/connector/src/test/java/co/airy/core/sources/twilio/SendMessageTest.java index 5e1c2919e2..82814bffc6 100644 --- a/backend/sources/twilio/connector/src/test/java/co/airy/core/sources/twilio/SendMessageTest.java +++ b/backend/sources/twilio/connector/src/test/java/co/airy/core/sources/twilio/SendMessageTest.java @@ -101,7 +101,6 @@ void canSendMessageViaTheTwilioApi() throws Exception { .setToken(token) .setSourceChannelId(sourceChannelId) .setSource("twilio.sms") - .setName("name") .setId(channelId) .setConnectionState(ChannelConnectionState.CONNECTED) .build() diff --git a/backend/sources/twilio/events-router/src/test/java/co/airy/core/sources/twilio/EventsRouterTest.java b/backend/sources/twilio/events-router/src/test/java/co/airy/core/sources/twilio/EventsRouterTest.java index 0cb012b694..0576759226 100644 --- a/backend/sources/twilio/events-router/src/test/java/co/airy/core/sources/twilio/EventsRouterTest.java +++ b/backend/sources/twilio/events-router/src/test/java/co/airy/core/sources/twilio/EventsRouterTest.java @@ -77,9 +77,7 @@ void canRouteTwilioMessage() throws Exception { .setId(channelId) .setSourceChannelId("whatsapp:+" + externalChannelId) .setConnectionState(ChannelConnectionState.CONNECTED) - .setName("twilio place") .setSource("twilio.whatsapp") - .setToken("") .build()) )); diff --git a/backend/webhook/publisher/src/main/java/co/airy/core/webhook/publisher/Mapper.java b/backend/webhook/publisher/src/main/java/co/airy/core/webhook/publisher/Mapper.java index 3b641bddd2..56ad2ecb1e 100644 --- a/backend/webhook/publisher/src/main/java/co/airy/core/webhook/publisher/Mapper.java +++ b/backend/webhook/publisher/src/main/java/co/airy/core/webhook/publisher/Mapper.java @@ -5,7 +5,6 @@ import co.airy.core.webhook.publisher.model.WebhookBody; import org.springframework.stereotype.Component; -import java.util.List; import java.util.Map; import static co.airy.date.format.DateFormat.isoFromMillis; diff --git a/docs/docs/api/endpoints/channels.md b/docs/docs/api/endpoints/channels.md index 08b46d0c94..4aa46e4e9a 100644 --- a/docs/docs/api/endpoints/channels.md +++ b/docs/docs/api/endpoints/channels.md @@ -10,6 +10,14 @@ for more information. `POST /channels.list` +**Sample request** + +```json5 +{ + "source": "source to filter on" // optional +} +``` + **Sample response** ```json5 @@ -17,21 +25,84 @@ for more information. "data": [ { "id": "channel-uuid-1", - "name": "my page 1", "source": "facebook", "source_channel_id": "fb-page-id-1", - "image_url": "http://example.org/avatar.jpeg" // optional + "metadata": { + "name": "my page 1", + // optional + "image_url": "http://example.org/avatar.jpeg" + } }, { "id": "channel-uuid-2", - "name": "my page 2", "source": "facebook", - "source_channel_id": "fb-page-id-2" + "source_channel_id": "fb-page-id-2", + "metadata": { + "name": "my page 2" + } } ] } ``` +## Info + +`POST /channels.info` + +**Sample request** + +```json5 +{ + "channel_id": "channel-uuid" +} +``` + +**Sample response** + +```json5 +{ + "id": "channel-uuid", + "source": "facebook", + "source_channel_id": "fb-page-id-1", + "metadata": { + "name": "my page 1", + // optional + "image_url": "http://example.org/avatar.jpeg" + } +} +``` + +## Update + +`POST /channels.update` + +Update a channel's name or image url. + +**Sample request** + +```json5 +{ + "channel_id": "channel-uuid", + "name": "new name for this channel", // optional + "image_url": "http://example.org/avatar_redesign.jpeg" // optional +} +``` + +**Sample response** + +```json5 +{ + "id": "channel-uuid", + "source": "facebook", + "source_channel_id": "fb-page-id-1", + "metadata": { + "name": "new name for this channel", + // optional + "image_url": "http://example.org/avatar_redesign.jpeg" + } +} +``` + ## Connecting channels ### Airy Live Chat Plugin @@ -55,9 +126,9 @@ POST /channels.chatplugin.connect ```json5 { "id": "1F679227-76C2-4302-BB12-703B2ADB0F66", - "name": "website-identifier-42", "source": "chat_plugin", - "source_channel_id": "website-identifier-42" + "source_channel_id": "website-identifier-42", + "metadata": {"name": "website-identifier-42"} } ``` @@ -90,10 +161,13 @@ POST /channels.facebook.connect ```json5 { "id": "channel-uuid-1", - "name": "My custom name for this page", - "image_url": "https://example.org/custom-image.jpg", "source": "facebook", - "source_channel_id": "fb-page-id-1" + "source_channel_id": "fb-page-id-1", + "metadata": { + "name": "My custom name for this page", + // optional + "image_url": "https://example.org/custom-image.jpg" + } } ``` @@ -122,8 +196,7 @@ POST /channels.google.connect ```json5 { "id": "channel-uuid-1", - "name": "My custom name for this location", - "image_url": "https://example.com/custom-image.jpg", + "metadata": {"name": "My custom name for this location", "image_url": "https://example.com/custom-image.jpg"}, "source": "google", "source_channel_id": "gbm-id" } @@ -162,8 +235,7 @@ POST /channels.twilio.sms.connect ```json5 { "id": "channel-uuid-1", - "name": "SMS for receipts", - "image_url": "https://example.com/custom-image.jpg", + "metadata": {"name": "SMS for receipts", "image_url": "https://example.com/custom-image.jpg"}, "source": "twilio.sms", "source_channel_id": "+491234567" } @@ -203,8 +275,7 @@ POST /channels.twilio.whatsapp.connect ```json5 { "id": "channel-uuid-1", - "name": "WhatsApp Marketing", - "image_url": "https://example.com/custom-image.jpg", + "metadata": {"name": "WhatsApp Marketing", "image_url": "https://example.com/custom-image.jpg"}, "source": "twilio.whatsapp", "source_channel_id": "whatsapp:+491234567" } diff --git a/docs/docs/api/endpoints/conversations.md b/docs/docs/api/endpoints/conversations.md index 8b16ed3831..212a7c60c4 100644 --- a/docs/docs/api/endpoints/conversations.md +++ b/docs/docs/api/endpoints/conversations.md @@ -48,9 +48,11 @@ Find users whose name ends with "Lovelace": { "id": "a688d36c-a85e-44af-bc02-4248c2c97622", "channel": { - "name": "Facebook page name", "source": "facebook", - "id": "318efa04-7cc1-4200-988e-50699d0dd6e3" + "id": "318efa04-7cc1-4200-988e-50699d0dd6e3", + "metadata": { + "name": "Facebook page name" + } }, "created_at": "2019-01-07T09:01:44.000Z", "contact": { @@ -104,8 +106,11 @@ Find users whose name ends with "Lovelace": { "id": "a688d36c-a85e-44af-bc02-4248c2c97622", "channel": { - "name": "facebook", - "id": "318efa04-7cc1-4200-988e-50699d0dd6e3" + "metadata": { + "name": "Facebook page name" + }, + "id": "318efa04-7cc1-4200-988e-50699d0dd6e3", + "source": "facebook" }, "created_at": "2019-01-07T09:01:44.000Z", "contact": { @@ -137,7 +142,7 @@ Resets the unread count of a conversation and returns status code `202 (Accepted **Sample request** -```json +```json5 { "conversation_id": "a688d36c-a85e-44af-bc02-4248c2c97622" } diff --git a/docs/docs/api/websocket.md b/docs/docs/api/websocket.md index e5c6a716d4..162867ae7c 100644 --- a/docs/docs/api/websocket.md +++ b/docs/docs/api/websocket.md @@ -88,10 +88,8 @@ updated. ```json5 { "id": "{UUID}", - "name": "my page 1", "source": "facebook", - "source_channel_id": "fb-page-id-1", - "image_url": "http://example.org/avatar.jpeg" // optional + "source_channel_id": "fb-page-id-1" } ``` @@ -108,9 +106,7 @@ Incoming payloads notify connected clients whenever a channel was disconnected. ```json5 { "id": "{UUID}", - "name": "my page 1", "source": "facebook", - "source_channel_id": "fb-page-id-1", - "image_url": "http://example.org/avatar.jpeg" // optional + "source_channel_id": "fb-page-id-1" } ``` diff --git a/frontend/ui/src/components/IconChannel/index.tsx b/frontend/ui/src/components/IconChannel/index.tsx index 1648ccc7d7..1fb86501aa 100644 --- a/frontend/ui/src/components/IconChannel/index.tsx +++ b/frontend/ui/src/components/IconChannel/index.tsx @@ -18,107 +18,76 @@ import styles from './index.module.scss'; type IconChannelProps = { channel: Channel; icon?: boolean; - avatar?: boolean; - name?: boolean; + showAvatar?: boolean; + showName?: boolean; text?: boolean; }; -const PlaceholderChannelData = { +const PlaceholderChannelData: Channel = { id: 'id', - name: 'Retriving Data...', - source: 'FACEBOOK', + source: 'facebook', + metadata: { + name: 'Retrieving Data...', + }, sourceChannelId: 'external_channel_id', connected: true, }; +const SOURCE_INFO = { + facebook: { + text: 'Facebook page', + icon: () => , + avatar: () => , + }, + google: { + text: 'Google page', + icon: () => , + avatar: () => , + }, + 'twilio.sms': { + text: 'SMS phone number', + icon: () => , + avatar: () => , + }, + 'twilio.whatsapp': { + text: 'Whatsapp number', + icon: () => , + avatar: () => , + }, + chat_plugin: { + text: 'Airy Chat plugin', + icon: () => , + avatar: () => , + }, +}; + const IconChannel: React.FC = ({ channel, icon, - avatar, - name, + showAvatar, + showName, text, }: IconChannelProps): JSX.Element => { if (!channel) { channel = PlaceholderChannelData; } - const SOURCE_INFO = { - facebook: { - text: 'Facebook page', - icon: function() { - return ; - }, - avatar: function() { - return ; - }, - name: channel.name, - }, - google: { - text: 'Google page', - icon: function() { - return ; - }, - avatar: function() { - return ; - }, - name: channel.name, - }, - 'twilio.sms': { - text: 'SMS page', - icon: function() { - return ; - }, - avatar: function() { - return ; - }, - name: channel.name, - }, - 'twilio.whatsapp': { - text: 'Whatsapp page', - icon: function() { - return ; - }, - avatar: function() { - return ; - }, - name: channel.name, - }, - chat_plugin: { - text: 'Airy Chat plugin', - icon: function() { - return ; - }, - avatar: function() { - return ; - }, - name: channel.name, - }, - }; - //TODO: This has to go once the backend returns the source const channelInfo = SOURCE_INFO[channel.source || 'chat_plugin']; - const fbFallback = SOURCE_INFO['FACEBOOK']; - - if (!channelInfo) { - return ( - <> - {fbFallback.icon()} {fbFallback.text} - - ); - } + const fbFallback = SOURCE_INFO['facebook']; - if (icon && name) { + if (icon && showName) { return (
{channelInfo.icon()} -

{channelInfo.name}

+

{channel.metadata.name}

); - } else if (avatar && name) { + } else if (showAvatar && showName) { return (
{channelInfo.avatar()} -

{channelInfo.name}

+

{channel.metadata.name}

); } else if (icon && text) { @@ -128,7 +97,7 @@ const IconChannel: React.FC = ({

{channelInfo.text}

); - } else if (avatar && text) { + } else if (showAvatar && text) { return (
{channelInfo.avatar()} @@ -137,7 +106,7 @@ const IconChannel: React.FC = ({ ); } else if (icon) { return <>{channelInfo.icon()}; - } else if (avatar) { + } else if (showAvatar) { return <>{channelInfo.avatar()}; } return ( diff --git a/frontend/ui/src/components/SimpleIconChannel/index.module.scss b/frontend/ui/src/components/IconChannelFilter/index.module.scss similarity index 100% rename from frontend/ui/src/components/SimpleIconChannel/index.module.scss rename to frontend/ui/src/components/IconChannelFilter/index.module.scss diff --git a/frontend/ui/src/components/IconChannelFilter/index.tsx b/frontend/ui/src/components/IconChannelFilter/index.tsx new file mode 100644 index 0000000000..3742ca16d4 --- /dev/null +++ b/frontend/ui/src/components/IconChannelFilter/index.tsx @@ -0,0 +1,20 @@ +import React from 'react'; + +import {Channel} from 'httpclient'; + +import {ReactComponent as GoogleIcon} from '../../assets/images/icons/google_avatar.svg'; +import {ReactComponent as WhatsappIcon} from '../../assets/images/icons/whatsapp_avatar.svg'; +import {ReactComponent as SmsIcon} from '../../assets/images/icons/sms_avatar.svg'; +import {ReactComponent as FacebookIcon} from '../../assets/images/icons/messenger_avatar.svg'; + +const sourceIconsMap = { + google: GoogleIcon, + facebook: FacebookIcon, + 'twilio.sms': SmsIcon, + 'twilio.whatsapp': WhatsappIcon, +}; + +export const IconChannelFilter = ({channel}: {channel: Channel}) => { + const SourceIcon = sourceIconsMap[channel.source]; + return ; +}; diff --git a/frontend/ui/src/components/SimpleIconChannel/index.tsx b/frontend/ui/src/components/SimpleIconChannel/index.tsx deleted file mode 100644 index bc372e7c1a..0000000000 --- a/frontend/ui/src/components/SimpleIconChannel/index.tsx +++ /dev/null @@ -1,42 +0,0 @@ -import React, {useState} from 'react'; - -import {Channel} from 'httpclient'; - -import {ReactComponent as GoogleIcon} from '../../assets/images/icons/google_avatar.svg'; -import {ReactComponent as WhatsappIcon} from '../../assets/images/icons/whatsapp_avatar.svg'; -import {ReactComponent as SmsIcon} from '../../assets/images/icons/sms_avatar.svg'; -import {ReactComponent as FacebookIcon} from '../../assets/images/icons/messenger_avatar.svg'; - -import styles from './index.module.scss'; - -const getInitials = (channelName: string) => { - const tokens = channelName.split(' '); - if (tokens.length > 1) { - return `${tokens[0].slice(0, 1)}${tokens[1].slice(0, 1)}`; - } - return channelName.slice(0, 2); -}; - -const sourceIconsMap = {GOOGLE: GoogleIcon, FACEBOOK: FacebookIcon, SMS_TWILIO: SmsIcon, WHATSAPP_TWILIO: WhatsappIcon}; - -export const IconChannelFilter = ({channel}: {channel: Channel}) => { - const SourceIcon = sourceIconsMap[channel.source]; - return ; -}; - -export const SimpleIconChannel = ({channel}: {channel: Channel}) => { - const imageUrl = channel.imageUrl || ''; - const [useDefault, setUseDefault] = useState(false); - return useDefault ? ( - {getInitials(channel.name).toUpperCase()} - ) : ( - {channel.name} { - setUseDefault(true); - }} - className={styles.channelLogo} - /> - ); -}; diff --git a/frontend/ui/src/pages/Channels/index.tsx b/frontend/ui/src/pages/Channels/index.tsx index 89f104c05f..32fae7332e 100644 --- a/frontend/ui/src/pages/Channels/index.tsx +++ b/frontend/ui/src/pages/Channels/index.tsx @@ -8,7 +8,7 @@ import {Button} from '@airyhq/components'; import {Channel} from 'httpclient'; import {AiryConfig} from '../../AiryConfig'; import {listChannels, exploreChannels, connectChannel, disconnectChannel} from '../../actions/channel'; -import {StateModel} from '../../reducers/index'; +import {StateModel} from '../../reducers'; import styles from './index.module.scss'; @@ -92,23 +92,28 @@ const Channels = (props: ChannelsConnectProps) => { />
    - {props.channels.map((channel: Channel) => ( -
  • - -
    {channel.name}
    -
    - {channel.connected ? ( - - ) : ( - + {props.channels.map((channel: Channel) => { + const channelName = channel.metadata.name; + return ( +
  • + {channel.metadata.imageUrl && ( + {channelName} )} - -
  • - ))} +
    {channel.metadata.name}
    +
    + {channel.connected ? ( + + ) : ( + + )} +
    + + ); + })}
); diff --git a/frontend/ui/src/pages/Inbox/ConversationListItem/index.tsx b/frontend/ui/src/pages/Inbox/ConversationListItem/index.tsx index ac946ec5b6..37faf787c4 100644 --- a/frontend/ui/src/pages/Inbox/ConversationListItem/index.tsx +++ b/frontend/ui/src/pages/Inbox/ConversationListItem/index.tsx @@ -77,7 +77,7 @@ const ConversationListItem = (props: ConversationListItemProps) => {
- + {conversation.channel && }
{formatTimeOfMessage(conversation.lastMessage)}
diff --git a/frontend/ui/src/pages/Inbox/ConversationsFilter/Popup.tsx b/frontend/ui/src/pages/Inbox/ConversationsFilter/Popup.tsx index 2482cd47c9..edacd82f11 100644 --- a/frontend/ui/src/pages/Inbox/ConversationsFilter/Popup.tsx +++ b/frontend/ui/src/pages/Inbox/ConversationsFilter/Popup.tsx @@ -11,7 +11,7 @@ import {setFilter, resetFilter} from '../../../actions/conversationsFilter'; import {StateModel} from '../../../reducers'; -import {IconChannelFilter} from '../../../components/SimpleIconChannel'; +import {IconChannelFilter} from '../../../components/IconChannelFilter'; import DialogCustomizable from '../../../components/DialogCustomizable'; import Tag from '../../../components/Tag'; @@ -160,8 +160,8 @@ const PopUpFilter = (props: PopUpFilterProps) => { />
- {sortBy(channels, channel => channel.name) - .filter((channel: Channel) => channel.name.toLowerCase().includes(pageSearch.toLowerCase())) + {sortBy(channels, channel => channel.metadata.name) + .filter((channel: Channel) => channel.metadata.name.toLowerCase().includes(pageSearch.toLowerCase())) .map((channel, key) => (
{
)} -
{channel.name}
+
{channel.metadata.name}
))} diff --git a/lib/typescript/httpclient/index.ts b/lib/typescript/httpclient/index.ts index 1bab079515..7220dcd8c1 100644 --- a/lib/typescript/httpclient/index.ts +++ b/lib/typescript/httpclient/index.ts @@ -102,7 +102,7 @@ export class HttpClient { public async exploreFacebookChannels(requestPayload: ExploreChannelRequestPayload) { const response: ChannelsPayload = await this.doFetchFromBackend('facebook.channels.explore', requestPayload); - return channelsMapper(response, requestPayload.source); + return channelsMapper(response); } public async connectFacebookChannel(requestPayload: ConnectChannelRequestPayload) { diff --git a/lib/typescript/httpclient/mappers/channelMapper.ts b/lib/typescript/httpclient/mappers/channelMapper.ts index d9f9c8ad1c..625eb2452a 100644 --- a/lib/typescript/httpclient/mappers/channelMapper.ts +++ b/lib/typescript/httpclient/mappers/channelMapper.ts @@ -1,11 +1,16 @@ import {Channel} from '../model'; import {ChannelApiPayload} from '../payload/ChannelApiPayload'; -export const channelMapper = (payload: ChannelApiPayload): Channel => ({ - id: payload.id, - name: payload.name, - source: payload.source, - sourceChannelId: payload.source_channel_id, - imageUrl: payload.image_url, - connected: true, -}); +export const channelMapper = (payload: ChannelApiPayload): Channel => { + let channel = { + ...payload, + sourceChannelId: payload.source_channel_id, + metadata: { + ...payload.metadata, + imageUrl: payload.metadata.image_url, + }, + connected: true, + }; + delete channel.metadata.image_url; + return channel; +}; diff --git a/lib/typescript/httpclient/mappers/channelsMapper.ts b/lib/typescript/httpclient/mappers/channelsMapper.ts index 2980892bb3..4d34e15af2 100644 --- a/lib/typescript/httpclient/mappers/channelsMapper.ts +++ b/lib/typescript/httpclient/mappers/channelsMapper.ts @@ -1,11 +1,5 @@ import {ChannelsPayload} from '../payload/ChannelsPayload'; +import {channelMapper} from './channelMapper'; import {Channel} from '../model'; -export const channelsMapper = (payload: ChannelsPayload, source?: string): Channel[] => { - return payload.data.map( - (entry: Channel): Channel => ({ - source, - ...entry, - }) - ); -}; +export const channelsMapper = (payload: ChannelsPayload): Channel[] => payload.data.map(channelMapper); diff --git a/lib/typescript/httpclient/model/Channel.ts b/lib/typescript/httpclient/model/Channel.ts index 1caf39e948..b55e62ca4c 100644 --- a/lib/typescript/httpclient/model/Channel.ts +++ b/lib/typescript/httpclient/model/Channel.ts @@ -1,8 +1,14 @@ +import {Metadata} from './Metadata'; + +export type ChannelMetadata = Metadata & { + name: string; + imageUrl?: string; +}; + export interface Channel { id?: string; - name: string; + metadata: ChannelMetadata; source: string; sourceChannelId: string; connected: boolean; - imageUrl?: string; } diff --git a/lib/typescript/httpclient/model/Metadata.ts b/lib/typescript/httpclient/model/Metadata.ts new file mode 100644 index 0000000000..5077ac312c --- /dev/null +++ b/lib/typescript/httpclient/model/Metadata.ts @@ -0,0 +1,4 @@ +export interface Metadata { + userData?: Metadata; + [key: string]: any; +} diff --git a/lib/typescript/httpclient/payload/ChannelApiPayload.ts b/lib/typescript/httpclient/payload/ChannelApiPayload.ts index 0be2de89c1..1f6e328a06 100644 --- a/lib/typescript/httpclient/payload/ChannelApiPayload.ts +++ b/lib/typescript/httpclient/payload/ChannelApiPayload.ts @@ -1,7 +1,9 @@ export interface ChannelApiPayload { id: string; - name: string; - image_url: string; + metadata: any & { + name: string; + image_url?: string; + }; source: string; source_channel_id: string; } diff --git a/lib/typescript/httpclient/payload/ChannelsPayload.ts b/lib/typescript/httpclient/payload/ChannelsPayload.ts index 703b85f400..b5e3172bf3 100644 --- a/lib/typescript/httpclient/payload/ChannelsPayload.ts +++ b/lib/typescript/httpclient/payload/ChannelsPayload.ts @@ -1,5 +1,5 @@ -import {Channel} from '../model'; +import {ChannelApiPayload} from './ChannelApiPayload'; export interface ChannelsPayload { - data: Channel[]; + data: ChannelApiPayload[]; } From dec98b59c713d586ee43e8071b0938d5494ed4b1 Mon Sep 17 00:00:00 2001 From: Aitor Algorta Date: Wed, 10 Feb 2021 16:46:36 +0100 Subject: [PATCH 06/10] [#875] Improve Box component (#924) * improve Box component * remove unused info * improve customisation of Box * change name of Box * improvements in docs * remove text decoration * linting * change colors * improvements --- docs/docs/cli/installation.md | 6 +- .../deployment/introduction.md | 37 +++++++++++- docs/docs/getting-started/installation.md | 34 ++++------- docs/docs/getting-started/introduction.md | 50 ++++++++++++++++ docs/docusaurus.config.js | 3 +- docs/src/components/Box.js | 34 ----------- docs/src/components/ButtonBox/index.js | 57 +++++++++++++++++++ .../components/ButtonBox/styles.module.css | 34 +++++++++++ docs/src/components/SuccessBox/index.js | 10 ++++ .../components/SuccessBox/styles.module.css | 13 +++++ .../src/components/{TLDR.js => TLDR/index.js} | 0 .../introduction/apache_kafka-vertical.svg | 1 + .../introduction/cloud-svgrepo-com.svg | 10 ++++ .../introduction/vagrantup-icon.svg | 1 + .../getting-started/installation/deploy.svg | 3 + .../getting-started/introduction/diamond.svg | 3 + 16 files changed, 232 insertions(+), 64 deletions(-) delete mode 100644 docs/src/components/Box.js create mode 100644 docs/src/components/ButtonBox/index.js create mode 100644 docs/src/components/ButtonBox/styles.module.css create mode 100644 docs/src/components/SuccessBox/index.js create mode 100644 docs/src/components/SuccessBox/styles.module.css rename docs/src/components/{TLDR.js => TLDR/index.js} (100%) create mode 100644 docs/static/img/getting-started/deployment/introduction/apache_kafka-vertical.svg create mode 100644 docs/static/img/getting-started/deployment/introduction/cloud-svgrepo-com.svg create mode 100644 docs/static/img/getting-started/deployment/introduction/vagrantup-icon.svg create mode 100644 docs/static/img/getting-started/installation/deploy.svg create mode 100644 docs/static/img/getting-started/introduction/diamond.svg diff --git a/docs/docs/cli/installation.md b/docs/docs/cli/installation.md index 77c68bc21d..a74e529bf4 100644 --- a/docs/docs/cli/installation.md +++ b/docs/docs/cli/installation.md @@ -5,7 +5,7 @@ hide_table_of_contents: false --- import TLDR from "@site/src/components/TLDR"; -import Box from "@site/src/components/Box"; +import SuccessBox from "@site/src/components/SuccessBox"; import Tabs from '@theme/Tabs'; import TabItem from '@theme/TabItem'; @@ -134,14 +134,14 @@ your `apiHost` and `apiJwtToken`: airy init ``` - + :tada: Congratulations! You have successfully installed Airy CLI! Next step: Choose a way to [Deploy Airy Core](/getting-started/deployment/introduction.md) - + ## Building from source diff --git a/docs/docs/getting-started/deployment/introduction.md b/docs/docs/getting-started/deployment/introduction.md index e789b1e891..4dce9518e7 100644 --- a/docs/docs/getting-started/deployment/introduction.md +++ b/docs/docs/getting-started/deployment/introduction.md @@ -4,6 +4,9 @@ sidebar_label: Introduction --- import TLDR from "@site/src/components/TLDR"; +import ButtonBox from "@site/src/components/ButtonBox"; +import VagrantSVG from '@site/static/img/getting-started/deployment/introduction/vagrantup-icon.svg' +import ProductionSVG from '@site/static/img/getting-started/deployment/introduction/cloud-svgrepo-com.svg' @@ -13,7 +16,35 @@ You can deploy Airy Core in many different ways: **locally** or The following documentation covers how to install Airy Core locally (using -Vagrant) or deploy it with various hosting options: +Vagrant) or deploy it with various hosting options. -- Try it locally with [Vagrant](vagrant.md) -- Install Airy Core in a [production environment](production.md) +## Deployment Guides + +
    + +
  • + } + title='Local test environment with Vagrant' + description='Step by step guide to run Airy Core on your local machine' + link='getting-started/deployment/vagrant' +/> +
  • + +
  • + } + title='Production ready environment with Kafka' + description='Manual step by step guide for running Airy in production' + link='getting-started/deployment/production' +/> +
  • + +
diff --git a/docs/docs/getting-started/installation.md b/docs/docs/getting-started/installation.md index ab5e637355..6b233f783e 100644 --- a/docs/docs/getting-started/installation.md +++ b/docs/docs/getting-started/installation.md @@ -4,8 +4,9 @@ sidebar_label: Installation --- import TLDR from "@site/src/components/TLDR"; -import Box from "@site/src/components/Box"; +import ButtonBox from "@site/src/components/ButtonBox"; import useBaseUrl from '@docusaurus/useBaseUrl'; +import DeploySVG from '@site/static/img/getting-started/installation/deploy.svg' @@ -14,14 +15,8 @@ or in the cloud. -### Command Line Interface - -The [Airy CLI](/cli/introduction.md) is a developer tool to help you **build**, **test**, and **manage** Airy directly from your terminal. - We recommend to [install](/cli/installation.md) the Airy CLI first which will -aid you in the process of installing and managing your Airy Core instance. - -It is easy to install and works on macOS, Windows, and Linux. +aid you in the process of installing and managing your Airy Core instance. It is easy to install and works on macOS, Windows, and Linux. ## Installation guides @@ -29,21 +24,16 @@ It is easy to install and works on macOS, Windows, and Linux. listStyleType: "none", padding: 0 }}> -
  • - -[Run Airy Core on your machine inside an isolated Vagrant -box](/getting-started/deployment/vagrant.md) - - -
  • - -
  • - - -[Run Airy Core in the cloud](/getting-started/deployment/production.md) - - +
  • + } + title='CLI' + description='Run Airy on your local machine using the CLI' + link='/cli/installation' +/>
  • diff --git a/docs/docs/getting-started/introduction.md b/docs/docs/getting-started/introduction.md index 965a8d4e8d..217e585754 100644 --- a/docs/docs/getting-started/introduction.md +++ b/docs/docs/getting-started/introduction.md @@ -5,6 +5,9 @@ slug: / --- import TLDR from "@site/src/components/TLDR"; +import ButtonBox from "@site/src/components/ButtonBox"; +import DeploySVG from "@site/static/img/getting-started/installation/deploy.svg"; +import DiamondSVG from "@site/static/img/getting-started/introduction/diamond.svg"; ## What is Airy Core? @@ -36,6 +39,53 @@ Since Airy's infrastructure is built around Apache Kafka, it can process a large amount of conversations and messages simultaneously and stream the relevant conversational data to wherever you need it. +## Get Started + +
      + +
    • + } + title='Start Installation' + description='Install Airy with our CLI, locally or in the cloud' + link='cli/installation' +/> +
    • + +
    + +Once you have Airy installed, follow our Quick Start for guidance. + +
      + +
    • + } + title='To the Quick Start' + description='Learn the Airy Basics with our Quick Start' + link='getting-started/quickstart' +/> +
    • + +
    + +We'll guide you through the following journey: + +- Connect your first Source +- Send Messages +- Use the API to list conversations +- Consume directly from Kafka + ## Airy Core Components The platform contains the following core components: diff --git a/docs/docusaurus.config.js b/docs/docusaurus.config.js index 6c09e451f2..b70d4bf774 100644 --- a/docs/docusaurus.config.js +++ b/docs/docusaurus.config.js @@ -22,7 +22,6 @@ module.exports = { additionalLanguages: ['json5'], }, navbar: { - title: 'Documentation', logo: { alt: 'Airy Documentation', src: 'img/logo_light.svg', @@ -33,7 +32,7 @@ module.exports = { target: '_self', label: 'Airy Core', position: 'left', - href: 'https://docs.airy.co', + to: '/', }, { target: '_self', diff --git a/docs/src/components/Box.js b/docs/src/components/Box.js deleted file mode 100644 index 2f22f0bcb8..0000000000 --- a/docs/src/components/Box.js +++ /dev/null @@ -1,34 +0,0 @@ -import React from 'react'; -import useThemeContext from '@theme/hooks/useThemeContext'; - -const adjust = (color, amount) => { - return ( - '#' + - color - .replace(/^#/, '') - .replace(/../g, color => ('0' + Math.min(255, Math.max(0, parseInt(color, 16) + amount)).toString(16)).substr(-2)) - ); -}; - -const Box = ({children, color}) => { - const {isDarkTheme} = useThemeContext(); - - if (typeof color == 'undefined') { - color = isDarkTheme ? '#4BB3FD' : '#F1FAFF'; - } else { - color = isDarkTheme ? adjust(color, -100) : color; - } - - return ( -
    - {children} -
    - ); -}; - -export default Box; diff --git a/docs/src/components/ButtonBox/index.js b/docs/src/components/ButtonBox/index.js new file mode 100644 index 0000000000..7d8c14293b --- /dev/null +++ b/docs/src/components/ButtonBox/index.js @@ -0,0 +1,57 @@ +import React from 'react'; +import useThemeContext from '@theme/hooks/useThemeContext'; +import Link from '@docusaurus/Link'; +import useBaseUrl from '@docusaurus/useBaseUrl'; +import {withRouter} from 'react-router-dom'; + +import styles from './styles.module.css'; + +const adjust = (color, amount) => { + return ( + '#' + + color + .replace(/^#/, '') + .replace(/../g, color => ('0' + Math.min(255, Math.max(0, parseInt(color, 16) + amount)).toString(16)).substr(-2)) + ); +}; + +const ButtonBox = ({children, icon, title, description, link, customizedBackgroundColor, customizedHoverColor}) => { + const {isDarkTheme} = useThemeContext(); + + if (customizedBackgroundColor) { + customizedBackgroundColor = isDarkTheme ? adjust(customizedBackgroundColor, -100) : customizedBackgroundColor; + } + + if (customizedHoverColor) { + customizedHoverColor = isDarkTheme ? adjust(customizedHoverColor, -100) : customizedHoverColor; + } + + return ( + + {icon && icon()} +
    +

    + {title} +

    +

    + {description} +

    +
    + {children} + + ); +}; + +export default withRouter(ButtonBox); diff --git a/docs/src/components/ButtonBox/styles.module.css b/docs/src/components/ButtonBox/styles.module.css new file mode 100644 index 0000000000..35660e8a0d --- /dev/null +++ b/docs/src/components/ButtonBox/styles.module.css @@ -0,0 +1,34 @@ +.containerDark { + display: flex; + align-items: center; + padding: 1em; + border-radius: 8px; + cursor: pointer; + transition: all 0.2s ease-in-out; + background-color: #4bb3fd; + box-shadow: none; +} + +.containerLight { + display: flex; + align-items: center; + padding: 1em; + border-radius: 8px; + cursor: pointer; + transition: all 0.2s ease-in-out; + background-color: #4bb3fd; + box-shadow: none; +} + +.containerLight:hover, +.containerDark:hover { + box-shadow: 0px 0px 0px 4px #3678e5; + text-decoration: none; +} + +svg { + color: white; + height: 32px; + width: 32px; + margin-right: 12px; +} diff --git a/docs/src/components/SuccessBox/index.js b/docs/src/components/SuccessBox/index.js new file mode 100644 index 0000000000..4b49575c75 --- /dev/null +++ b/docs/src/components/SuccessBox/index.js @@ -0,0 +1,10 @@ +import React from 'react'; +import useThemeContext from '@theme/hooks/useThemeContext'; +import styles from './styles.module.css'; + +const SuccessBox = ({children}) => { + const {isDarkTheme} = useThemeContext(); + return
    {children}
    ; +}; + +export default SuccessBox; diff --git a/docs/src/components/SuccessBox/styles.module.css b/docs/src/components/SuccessBox/styles.module.css new file mode 100644 index 0000000000..8228b8f6bc --- /dev/null +++ b/docs/src/components/SuccessBox/styles.module.css @@ -0,0 +1,13 @@ +.successBoxDark { + padding: 1em; + padding-bottom: 2px; + border-radius: 14px; + background-color: #487b24; +} + +.successBoxLight { + padding: 1em; + padding-bottom: 2px; + border-radius: 14px; + background-color: #acdf87; +} diff --git a/docs/src/components/TLDR.js b/docs/src/components/TLDR/index.js similarity index 100% rename from docs/src/components/TLDR.js rename to docs/src/components/TLDR/index.js diff --git a/docs/static/img/getting-started/deployment/introduction/apache_kafka-vertical.svg b/docs/static/img/getting-started/deployment/introduction/apache_kafka-vertical.svg new file mode 100644 index 0000000000..8c7059311c --- /dev/null +++ b/docs/static/img/getting-started/deployment/introduction/apache_kafka-vertical.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/docs/static/img/getting-started/deployment/introduction/cloud-svgrepo-com.svg b/docs/static/img/getting-started/deployment/introduction/cloud-svgrepo-com.svg new file mode 100644 index 0000000000..691e506b0a --- /dev/null +++ b/docs/static/img/getting-started/deployment/introduction/cloud-svgrepo-com.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/docs/static/img/getting-started/deployment/introduction/vagrantup-icon.svg b/docs/static/img/getting-started/deployment/introduction/vagrantup-icon.svg new file mode 100644 index 0000000000..8879caff3e --- /dev/null +++ b/docs/static/img/getting-started/deployment/introduction/vagrantup-icon.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/docs/static/img/getting-started/installation/deploy.svg b/docs/static/img/getting-started/installation/deploy.svg new file mode 100644 index 0000000000..f78c42b9c3 --- /dev/null +++ b/docs/static/img/getting-started/installation/deploy.svg @@ -0,0 +1,3 @@ + + + diff --git a/docs/static/img/getting-started/introduction/diamond.svg b/docs/static/img/getting-started/introduction/diamond.svg new file mode 100644 index 0000000000..b43d90cdfe --- /dev/null +++ b/docs/static/img/getting-started/introduction/diamond.svg @@ -0,0 +1,3 @@ + + + From 9455747fac8885124e3e63ed4f3e1e99b99b5f10 Mon Sep 17 00:00:00 2001 From: Christoph Proeschel Date: Wed, 10 Feb 2021 16:53:48 +0100 Subject: [PATCH 07/10] New Airy websocket using Airy events (#928) --- backend/api/websocket/BUILD | 45 ++++ .../co/airy/core/api/websocket/Stores.java | 70 ++++++ .../core/api/websocket/WebSocketConfig.java | 91 ++++++++ .../api/websocket/WebSocketController.java | 38 ++++ .../api/websocket/payload/ChannelEvent.java | 20 ++ .../core/api/websocket/payload/Event.java | 13 ++ .../api/websocket/payload/MessageEvent.java | 44 ++++ .../api/websocket/payload/MetadataEvent.java | 48 +++++ .../src/main/resources/application.properties | 6 + .../websocket/WebSocketControllerTest.java | 199 ++++++++++++++++++ .../src/test/resources/test.properties | 3 + .../core/sources/twilio/EventsRouterTest.java | 3 +- docs/docs/api/websocket.md | 114 ++++------ .../api/charts/api-websocket/Chart.yaml | 5 + .../api-websocket/templates/deployment.yaml | 65 ++++++ .../api-websocket/templates/service.yaml | 13 ++ .../api/charts/api-websocket/values.yaml | 1 + .../helm-chart/templates/ingress.yaml | 7 + .../httpclient/mappers/channelMapper.ts | 2 +- .../httpclient/payload/MessagePayload.ts | 2 +- 20 files changed, 717 insertions(+), 72 deletions(-) create mode 100644 backend/api/websocket/BUILD create mode 100644 backend/api/websocket/src/main/java/co/airy/core/api/websocket/Stores.java create mode 100644 backend/api/websocket/src/main/java/co/airy/core/api/websocket/WebSocketConfig.java create mode 100644 backend/api/websocket/src/main/java/co/airy/core/api/websocket/WebSocketController.java create mode 100644 backend/api/websocket/src/main/java/co/airy/core/api/websocket/payload/ChannelEvent.java create mode 100644 backend/api/websocket/src/main/java/co/airy/core/api/websocket/payload/Event.java create mode 100644 backend/api/websocket/src/main/java/co/airy/core/api/websocket/payload/MessageEvent.java create mode 100644 backend/api/websocket/src/main/java/co/airy/core/api/websocket/payload/MetadataEvent.java create mode 100644 backend/api/websocket/src/main/resources/application.properties create mode 100644 backend/api/websocket/src/test/java/co/airy/core/api/websocket/WebSocketControllerTest.java create mode 100644 backend/api/websocket/src/test/resources/test.properties create mode 100644 infrastructure/helm-chart/charts/apps/charts/api/charts/api-websocket/Chart.yaml create mode 100644 infrastructure/helm-chart/charts/apps/charts/api/charts/api-websocket/templates/deployment.yaml create mode 100644 infrastructure/helm-chart/charts/apps/charts/api/charts/api-websocket/templates/service.yaml create mode 100644 infrastructure/helm-chart/charts/apps/charts/api/charts/api-websocket/values.yaml diff --git a/backend/api/websocket/BUILD b/backend/api/websocket/BUILD new file mode 100644 index 0000000000..91cf6bbc7d --- /dev/null +++ b/backend/api/websocket/BUILD @@ -0,0 +1,45 @@ +load("@rules_java//java:defs.bzl", "java_library") +load("//tools/build:springboot.bzl", "springboot") +load("//tools/build:junit5.bzl", "junit5") +load("//tools/build:container_push.bzl", "container_push") + +app_deps = [ + "//backend:base_app", + "//:springboot_actuator", + "//:springboot_websocket", + "//backend/model/message", + "//backend/model/channel", + "//backend/model/metadata", + "//lib/java/date", + "//lib/java/spring/auth:spring-auth", + "//lib/java/spring/web:spring-web", + "//lib/java/spring/kafka/core:spring-kafka-core", + "//lib/java/spring/kafka/streams:spring-kafka-streams", +] + +springboot( + name = "api-websocket", + srcs = glob(["src/main/java/**/*.java"]), + main_class = "co.airy.spring.core.AirySpringBootApplication", + deps = app_deps, +) + +[ + junit5( + size = "medium", + file = file, + resources = glob(["src/test/resources/**/*"]), + deps = [ + ":app", + "//backend:base_test", + "//lib/java/kafka/test:kafka-test", + "//lib/java/spring/test:spring-test", + ] + app_deps, + ) + for file in glob(["src/test/java/**/*Test.java"]) +] + +container_push( + registry = "ghcr.io/airyhq/api", + repository = "websocket", +) diff --git a/backend/api/websocket/src/main/java/co/airy/core/api/websocket/Stores.java b/backend/api/websocket/src/main/java/co/airy/core/api/websocket/Stores.java new file mode 100644 index 0000000000..59a156be01 --- /dev/null +++ b/backend/api/websocket/src/main/java/co/airy/core/api/websocket/Stores.java @@ -0,0 +1,70 @@ +package co.airy.core.api.websocket; + +import co.airy.avro.communication.Channel; +import co.airy.avro.communication.Message; +import co.airy.avro.communication.Metadata; +import co.airy.kafka.schema.application.ApplicationCommunicationChannels; +import co.airy.kafka.schema.application.ApplicationCommunicationMessages; +import co.airy.kafka.schema.application.ApplicationCommunicationMetadata; +import co.airy.kafka.streams.KafkaStreamsWrapper; +import co.airy.model.metadata.dto.MetadataMap; +import org.apache.avro.specific.SpecificRecordBase; +import org.apache.kafka.clients.consumer.KafkaConsumer; +import org.apache.kafka.streams.KeyValue; +import org.apache.kafka.streams.StreamsBuilder; +import org.apache.kafka.streams.kstream.KTable; +import org.springframework.beans.factory.DisposableBean; +import org.springframework.boot.actuate.health.Health; +import org.springframework.boot.actuate.health.HealthIndicator; +import org.springframework.boot.actuate.health.Status; +import org.springframework.boot.context.event.ApplicationStartedEvent; +import org.springframework.context.ApplicationListener; +import org.springframework.stereotype.Component; + +import static co.airy.model.metadata.MetadataRepository.getSubject; +import static co.airy.model.metadata.MetadataRepository.isChannelMetadata; + +@Component +public class Stores implements HealthIndicator, ApplicationListener, DisposableBean { + private static final String appId = "api.WebsocketStores"; + private final KafkaStreamsWrapper streams; + private final WebSocketController webSocketController; + + Stores(KafkaStreamsWrapper streams, + WebSocketController webSocketController + ) { + this.streams = streams; + this.webSocketController = webSocketController; + } + + @Override + public void onApplicationEvent(ApplicationStartedEvent event) { + final StreamsBuilder builder = new StreamsBuilder(); + + builder.stream(new ApplicationCommunicationMessages().name()) + .peek((messageId, message) -> webSocketController.onMessage(message)); + + builder.stream(new ApplicationCommunicationChannels().name()) + .peek((channelId, channel) -> webSocketController.onChannel(channel)); + + builder.table(new ApplicationCommunicationMetadata().name()) + .groupBy((metadataId, metadata) -> KeyValue.pair(getSubject(metadata).getIdentifier(), metadata)) + .aggregate(MetadataMap::new, MetadataMap::adder, MetadataMap::subtractor) + .toStream() + .peek((identifier, metadataMap) -> webSocketController.onMetadata(metadataMap)); + + streams.start(builder.build(), appId); + } + + @Override + public void destroy() { + if (streams != null) { + streams.close(); + } + } + + @Override + public Health health() { + return Health.status(Status.UP).build(); + } +} diff --git a/backend/api/websocket/src/main/java/co/airy/core/api/websocket/WebSocketConfig.java b/backend/api/websocket/src/main/java/co/airy/core/api/websocket/WebSocketConfig.java new file mode 100644 index 0000000000..0c2e150d23 --- /dev/null +++ b/backend/api/websocket/src/main/java/co/airy/core/api/websocket/WebSocketConfig.java @@ -0,0 +1,91 @@ +package co.airy.core.api.websocket; + +import co.airy.log.AiryLoggerFactory; +import co.airy.spring.jwt.Jwt; +import org.slf4j.Logger; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpStatus; +import org.springframework.messaging.Message; +import org.springframework.messaging.MessageChannel; +import org.springframework.messaging.simp.config.ChannelRegistration; +import org.springframework.messaging.simp.config.MessageBrokerRegistry; +import org.springframework.messaging.simp.stomp.StompCommand; +import org.springframework.messaging.simp.stomp.StompHeaderAccessor; +import org.springframework.messaging.support.ChannelInterceptor; +import org.springframework.messaging.support.MessageHeaderAccessor; +import org.springframework.scheduling.TaskScheduler; +import org.springframework.scheduling.concurrent.ThreadPoolTaskScheduler; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.web.client.HttpClientErrorException; +import org.springframework.web.socket.config.annotation.EnableWebSocketMessageBroker; +import org.springframework.web.socket.config.annotation.StompEndpointRegistry; +import org.springframework.web.socket.config.annotation.WebSocketMessageBrokerConfigurer; + +import java.util.List; + +@Configuration +@EnableWebSocketMessageBroker +public class WebSocketConfig implements WebSocketMessageBrokerConfigurer { + private static final Logger log = AiryLoggerFactory.getLogger(WebSocketConfig.class); + private final Jwt jwt; + + public WebSocketConfig(Jwt jwt) { + this.jwt = jwt; + } + + @Override + public void configureMessageBroker(MessageBrokerRegistry config) { + config.enableSimpleBroker() + .setHeartbeatValue(new long[]{30_000, 30_000}) + .setTaskScheduler(heartbeatScheduler()); + } + + @Override + public void registerStompEndpoints(StompEndpointRegistry registry) { + // TODO this is a temporary name. We can change it back to + // /ws.communication in https://github.com/airyhq/airy/issues/886 + registry.addEndpoint("/ws.events").setAllowedOrigins("*"); + } + + @Bean + TaskScheduler heartbeatScheduler() { + final ThreadPoolTaskScheduler heartbeatScheduler = new ThreadPoolTaskScheduler(); + + heartbeatScheduler.setPoolSize(1); + heartbeatScheduler.setThreadNamePrefix("wss-heartbeat-scheduler-thread-"); + + return heartbeatScheduler; + } + + @Override + public void configureClientInboundChannel(ChannelRegistration registration) { + registration.interceptors(new ChannelInterceptor() { + @Override + public Message preSend(Message message, MessageChannel channel) { + final StompHeaderAccessor accessor = MessageHeaderAccessor.getAccessor(message, StompHeaderAccessor.class); + + if (accessor != null && StompCommand.CONNECT.equals(accessor.getCommand())) { + String authToken = accessor.getFirstNativeHeader(HttpHeaders.AUTHORIZATION); + if (authToken != null && authToken.startsWith("Bearer")) { + authToken = authToken.substring(7); + } + + try { + final String userId = jwt.authenticate(authToken); + accessor.setUser(new UsernamePasswordAuthenticationToken(userId, null, List.of())); + } catch (Exception e) { + log.error(String.format("STOMP Command: %s, token: %s \n Failed to authenticate", accessor.getCommand(), authToken)); + } + } + + if (accessor == null || accessor.getUser() == null) { + throw new HttpClientErrorException(HttpStatus.UNAUTHORIZED, "Unauthorized"); + } + + return message; + } + }); + } +} diff --git a/backend/api/websocket/src/main/java/co/airy/core/api/websocket/WebSocketController.java b/backend/api/websocket/src/main/java/co/airy/core/api/websocket/WebSocketController.java new file mode 100644 index 0000000000..8439a52a28 --- /dev/null +++ b/backend/api/websocket/src/main/java/co/airy/core/api/websocket/WebSocketController.java @@ -0,0 +1,38 @@ +package co.airy.core.api.websocket; + +import co.airy.avro.communication.Channel; +import co.airy.avro.communication.Message; +import co.airy.core.api.websocket.payload.ChannelEvent; +import co.airy.core.api.websocket.payload.MessageEvent; +import co.airy.core.api.websocket.payload.MetadataEvent; +import co.airy.model.channel.ChannelPayload; +import co.airy.model.metadata.dto.MetadataMap; +import org.springframework.messaging.simp.SimpMessagingTemplate; +import org.springframework.stereotype.Service; + +@Service +public class WebSocketController { + public static final String QUEUE_EVENTS = "/events"; + + private final SimpMessagingTemplate messagingTemplate; + WebSocketController(SimpMessagingTemplate messagingTemplate) { + this.messagingTemplate = messagingTemplate; + } + + public void onMessage(Message message) { + messagingTemplate.convertAndSend(QUEUE_EVENTS, MessageEvent.fromMessage(message)); + } + public void onChannel(Channel channel) { + messagingTemplate.convertAndSend(QUEUE_EVENTS, ChannelEvent.builder() + .payload(ChannelPayload.fromChannel(channel)) + .build() + ); + } + public void onMetadata(MetadataMap metadataMap) { + if(metadataMap.isEmpty()) { + return; + } + + messagingTemplate.convertAndSend(QUEUE_EVENTS, MetadataEvent.fromMetadataMap(metadataMap)); + } +} diff --git a/backend/api/websocket/src/main/java/co/airy/core/api/websocket/payload/ChannelEvent.java b/backend/api/websocket/src/main/java/co/airy/core/api/websocket/payload/ChannelEvent.java new file mode 100644 index 0000000000..dd83b8a00e --- /dev/null +++ b/backend/api/websocket/src/main/java/co/airy/core/api/websocket/payload/ChannelEvent.java @@ -0,0 +1,20 @@ +package co.airy.core.api.websocket.payload; + +import co.airy.model.channel.ChannelPayload; +import co.airy.model.message.dto.MessageResponsePayload; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.NoArgsConstructor; + +import java.io.Serializable; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +@EqualsAndHashCode(callSuper = false) +public class ChannelEvent extends Event implements Serializable { + private ChannelPayload payload; +} diff --git a/backend/api/websocket/src/main/java/co/airy/core/api/websocket/payload/Event.java b/backend/api/websocket/src/main/java/co/airy/core/api/websocket/payload/Event.java new file mode 100644 index 0000000000..9bee2ac1c9 --- /dev/null +++ b/backend/api/websocket/src/main/java/co/airy/core/api/websocket/payload/Event.java @@ -0,0 +1,13 @@ +package co.airy.core.api.websocket.payload; + +import com.fasterxml.jackson.annotation.JsonSubTypes; +import com.fasterxml.jackson.annotation.JsonTypeInfo; + +@JsonTypeInfo(use = JsonTypeInfo.Id.NAME, include = JsonTypeInfo.As.PROPERTY, property = "type") +@JsonSubTypes({ + @JsonSubTypes.Type(value = MessageEvent.class, name = "message"), + @JsonSubTypes.Type(value = MetadataEvent.class, name = "metadata"), + @JsonSubTypes.Type(value = ChannelEvent.class, name = "channel") +}) +public abstract class Event { +} diff --git a/backend/api/websocket/src/main/java/co/airy/core/api/websocket/payload/MessageEvent.java b/backend/api/websocket/src/main/java/co/airy/core/api/websocket/payload/MessageEvent.java new file mode 100644 index 0000000000..27245c0e01 --- /dev/null +++ b/backend/api/websocket/src/main/java/co/airy/core/api/websocket/payload/MessageEvent.java @@ -0,0 +1,44 @@ +package co.airy.core.api.websocket.payload; + +import co.airy.avro.communication.Message; +import co.airy.model.message.dto.MessageResponsePayload; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.NoArgsConstructor; + +import java.io.Serializable; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +@EqualsAndHashCode(callSuper = false) +public class MessageEvent extends Event implements Serializable { + private MessageEventPayload payload; + + @Data + @Builder + @NoArgsConstructor + @AllArgsConstructor + public static class MessageEventPayload { + private String conversationId; + private String channelId; + private MessageResponsePayload message; + } + + public static MessageEvent fromMessage(Message message) { + return MessageEvent.builder() + .payload( + MessageEventPayload.builder() + .channelId(message.getChannelId()) + .conversationId(message.getConversationId()) + .message(MessageResponsePayload.fromMessage(message)) + .build() + ) + .build(); + } +} + + diff --git a/backend/api/websocket/src/main/java/co/airy/core/api/websocket/payload/MetadataEvent.java b/backend/api/websocket/src/main/java/co/airy/core/api/websocket/payload/MetadataEvent.java new file mode 100644 index 0000000000..25acb79b20 --- /dev/null +++ b/backend/api/websocket/src/main/java/co/airy/core/api/websocket/payload/MetadataEvent.java @@ -0,0 +1,48 @@ +package co.airy.core.api.websocket.payload; + +import co.airy.avro.communication.Metadata; +import co.airy.model.metadata.dto.MetadataMap; +import co.airy.model.metadata.Subject; +import com.fasterxml.jackson.databind.JsonNode; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.NoArgsConstructor; + +import java.io.Serializable; +import java.util.Map; + +import static co.airy.model.metadata.MetadataObjectMapper.getMetadataPayload; +import static co.airy.model.metadata.MetadataRepository.getSubject; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +@EqualsAndHashCode(callSuper = false) +public class MetadataEvent extends Event implements Serializable { + private MetadataEventPayload payload; + + @Data + @Builder + @NoArgsConstructor + @AllArgsConstructor + public static class MetadataEventPayload { + private String subject; + private String identifier; + private JsonNode metadata; + } + + public static MetadataEvent fromMetadataMap(MetadataMap metadataMap) { + final Metadata someMetadata = metadataMap.values().iterator().next(); + final Subject subject = getSubject(someMetadata); + return new MetadataEvent( + MetadataEventPayload.builder() + .subject(subject.getNamespace()) + .identifier(subject.getIdentifier()) + .metadata(getMetadataPayload(metadataMap)) + .build() + ); + } +} diff --git a/backend/api/websocket/src/main/resources/application.properties b/backend/api/websocket/src/main/resources/application.properties new file mode 100644 index 0000000000..cba3b56051 --- /dev/null +++ b/backend/api/websocket/src/main/resources/application.properties @@ -0,0 +1,6 @@ +kafka.brokers=${KAFKA_BROKERS} +kafka.schema-registry-url=${KAFKA_SCHEMA_REGISTRY_URL} +kafka.cleanup=${KAFKA_CLEANUP:false} +kafka.commit-interval-ms=${KAFKA_COMMIT_INTERVAL_MS} + +auth.jwt-secret=${JWT_SECRET} diff --git a/backend/api/websocket/src/test/java/co/airy/core/api/websocket/WebSocketControllerTest.java b/backend/api/websocket/src/test/java/co/airy/core/api/websocket/WebSocketControllerTest.java new file mode 100644 index 0000000000..cd8ee513bf --- /dev/null +++ b/backend/api/websocket/src/test/java/co/airy/core/api/websocket/WebSocketControllerTest.java @@ -0,0 +1,199 @@ +package co.airy.core.api.websocket; + +import co.airy.avro.communication.Channel; +import co.airy.avro.communication.ChannelConnectionState; +import co.airy.avro.communication.DeliveryState; +import co.airy.avro.communication.Message; +import co.airy.avro.communication.Metadata; +import co.airy.avro.communication.SenderType; +import co.airy.core.api.websocket.payload.ChannelEvent; +import co.airy.core.api.websocket.payload.MessageEvent; +import co.airy.core.api.websocket.payload.MetadataEvent; +import co.airy.kafka.schema.application.ApplicationCommunicationChannels; +import co.airy.kafka.schema.application.ApplicationCommunicationMessages; +import co.airy.kafka.schema.application.ApplicationCommunicationMetadata; +import co.airy.kafka.test.KafkaTestHelper; +import co.airy.kafka.test.junit.SharedKafkaTestResource; +import co.airy.spring.core.AirySpringBootApplication; +import co.airy.spring.jwt.Jwt; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.PropertyNamingStrategy; +import org.apache.kafka.clients.producer.ProducerRecord; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.junit.jupiter.api.extension.RegisterExtension; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.messaging.converter.MappingJackson2MessageConverter; +import org.springframework.messaging.simp.stomp.StompHeaders; +import org.springframework.messaging.simp.stomp.StompSession; +import org.springframework.messaging.simp.stomp.StompSessionHandlerAdapter; +import org.springframework.test.context.TestPropertySource; +import org.springframework.test.context.junit.jupiter.SpringExtension; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.web.socket.WebSocketHttpHeaders; +import org.springframework.web.socket.client.standard.StandardWebSocketClient; +import org.springframework.web.socket.messaging.WebSocketStompClient; + +import java.lang.reflect.Type; +import java.time.Instant; +import java.util.UUID; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.TimeUnit; + +import static co.airy.core.api.websocket.WebSocketController.QUEUE_EVENTS; +import static co.airy.test.Timing.retryOnException; +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.equalTo; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.springframework.http.HttpHeaders.AUTHORIZATION; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@ExtendWith(SpringExtension.class) +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT, classes = {AirySpringBootApplication.class}) +@TestPropertySource(value = "classpath:test.properties") +@AutoConfigureMockMvc +public class WebSocketControllerTest { + @RegisterExtension + public static final SharedKafkaTestResource sharedKafkaTestResource = new SharedKafkaTestResource(); + private static KafkaTestHelper kafkaTestHelper; + + private static final ApplicationCommunicationMessages applicationCommunicationMessages = new ApplicationCommunicationMessages(); + private static final ApplicationCommunicationChannels applicationCommunicationChannels = new ApplicationCommunicationChannels(); + private static final ApplicationCommunicationMetadata applicationCommunicationMetadata = new ApplicationCommunicationMetadata(); + + + @Value("${local.server.port}") + private int port; + + @Autowired + private MockMvc mvc; + + @Autowired + private Jwt jwt; + + @BeforeAll + static void beforeAll() throws Exception { + kafkaTestHelper = new KafkaTestHelper(sharedKafkaTestResource, + applicationCommunicationMessages, + applicationCommunicationChannels, + applicationCommunicationMetadata + ); + kafkaTestHelper.beforeAll(); + } + + @AfterAll + static void afterAll() throws Exception { + kafkaTestHelper.afterAll(); + } + + @BeforeEach + void beforeEach() throws Exception { + retryOnException(() -> mvc.perform(get("/actuator/health")).andExpect(status().isOk()), "Application is not healthy"); + } + + @Test + void canReceiveMessageEvents() throws Exception { + final CompletableFuture future = subscribe(port, MessageEvent.class, QUEUE_EVENTS, jwt); + final Message message = Message.newBuilder() + .setId("messageId") + .setSource("facebook") + .setSentAt(Instant.now().toEpochMilli()) + .setUpdatedAt(null) + .setSenderId("sourceConversationId") + .setSenderType(SenderType.APP_USER) + .setDeliveryState(DeliveryState.DELIVERED) + .setConversationId("conversationId") + .setChannelId("channelId") + .setContent("{\"text\":\"hello world\"}") + .build(); + + kafkaTestHelper.produceRecord(new ProducerRecord<>(applicationCommunicationMessages.name(), message.getId(), message)); + + MessageEvent recMessage = future.get(30, TimeUnit.SECONDS); + assertNotNull(recMessage); + assertThat(recMessage.getPayload().getChannelId(), equalTo(message.getChannelId())); + assertThat(recMessage.getPayload().getMessage().getId(), equalTo(message.getId())); + assertThat(recMessage.getPayload().getMessage().getContent(), equalTo(message.getContent())); + } + + @Test + void canReceiveChannelEvents() throws Exception { + final CompletableFuture future = subscribe(port, ChannelEvent.class, QUEUE_EVENTS, jwt); + + final Channel channel = Channel.newBuilder() + .setId(UUID.randomUUID().toString()) + .setConnectionState(ChannelConnectionState.CONNECTED) + .setSource("sourceIdentifier") + .setSourceChannelId("sourceChannelId") + .build(); + + kafkaTestHelper.produceRecord(new ProducerRecord<>(applicationCommunicationChannels.name(), channel.getId(), channel)); + + ChannelEvent recChannel = future.get(30, TimeUnit.SECONDS); + assertNotNull(recChannel); + assertThat(recChannel.getPayload().getId(), equalTo(channel.getId())); + } + + @Test + void canReceiveMetadataEvents() throws Exception { + final CompletableFuture future = subscribe(port, MetadataEvent.class, QUEUE_EVENTS, jwt); + + final Metadata metadata = Metadata.newBuilder() + .setKey("contact.displayName") + .setValue("Grace") + .setSubject("conversation:123") + .setTimestamp(Instant.now().toEpochMilli()) + .build(); + + kafkaTestHelper.produceRecord(new ProducerRecord<>(applicationCommunicationMetadata.name(), "metadataId", metadata)); + + MetadataEvent recMetadata = future.get(30, TimeUnit.SECONDS); + assertNotNull(recMetadata); + assertThat(recMetadata.getPayload().getSubject(), equalTo("conversation")); + assertThat(recMetadata.getPayload().getIdentifier(), equalTo("123")); + assertThat(recMetadata.getPayload().getMetadata().get("contact").get("displayName").textValue(), equalTo(metadata.getValue())); + } + + private static StompSession connectToWs(int port, Jwt jwt) throws ExecutionException, InterruptedException { + final WebSocketStompClient stompClient = new WebSocketStompClient(new StandardWebSocketClient()); + MappingJackson2MessageConverter messageConverter = new MappingJackson2MessageConverter(); + ObjectMapper objectMapper = new ObjectMapper().setPropertyNamingStrategy(PropertyNamingStrategy.SNAKE_CASE); + messageConverter.setObjectMapper(objectMapper); + stompClient.setMessageConverter(messageConverter); + + StompHeaders connectHeaders = new StompHeaders(); + WebSocketHttpHeaders httpHeaders = new WebSocketHttpHeaders(); + connectHeaders.add(AUTHORIZATION, "Bearer " + jwt.tokenFor("userId")); + + return stompClient.connect("ws://localhost:" + port + "/ws.events", httpHeaders, connectHeaders, new StompSessionHandlerAdapter() { + }).get(); + } + + public static CompletableFuture subscribe(int port, Class payloadType, String topic, Jwt jwt) throws ExecutionException, InterruptedException { + final StompSession stompSession = connectToWs(port, jwt); + + final CompletableFuture completableFuture = new CompletableFuture<>(); + + stompSession.subscribe(topic, new StompSessionHandlerAdapter() { + @Override + public Type getPayloadType(StompHeaders headers) { + return payloadType; + } + + @Override + public void handleFrame(StompHeaders headers, Object payload) { + completableFuture.complete((T) payload); + } + }); + + return completableFuture; + } +} diff --git a/backend/api/websocket/src/test/resources/test.properties b/backend/api/websocket/src/test/resources/test.properties new file mode 100644 index 0000000000..3cdc76c0d0 --- /dev/null +++ b/backend/api/websocket/src/test/resources/test.properties @@ -0,0 +1,3 @@ +kafka.cleanup=true +kafka.commit-interval-ms=100 +auth.jwt-secret=this-needs-to-be-replaced-in-production-buffer:424242424242424242424242424242 diff --git a/backend/sources/twilio/events-router/src/test/java/co/airy/core/sources/twilio/EventsRouterTest.java b/backend/sources/twilio/events-router/src/test/java/co/airy/core/sources/twilio/EventsRouterTest.java index 0576759226..b5f5ee0861 100644 --- a/backend/sources/twilio/events-router/src/test/java/co/airy/core/sources/twilio/EventsRouterTest.java +++ b/backend/sources/twilio/events-router/src/test/java/co/airy/core/sources/twilio/EventsRouterTest.java @@ -94,8 +94,7 @@ void canRouteTwilioMessage() throws Exception { testHelper.produceRecords(List.of( new ProducerRecord<>(sourceTwilioEvents.name(), UUID.randomUUID().toString(), event), new ProducerRecord<>(sourceTwilioEvents.name(), UUID.randomUUID().toString(), broken) - ) - ); + )); List messages = testHelper.consumeValues(1, applicationCommunicationMessages.name()); assertEquals(1, messages.size(), "Expected 1 new message"); diff --git a/docs/docs/api/websocket.md b/docs/docs/api/websocket.md index 162867ae7c..7cf3c109d2 100644 --- a/docs/docs/api/websocket.md +++ b/docs/docs/api/websocket.md @@ -14,99 +14,77 @@ near real-time updates**. The WebSocket server uses the [STOMP](https://en.wikipedia.org/wiki/Streaming_Text_Oriented_Messaging_Protocol) -protocol endpoint at `/ws.communication`. +protocol endpoint at `/ws.events`. -To execute the handshake with `/ws.communication` you must set an +To execute the handshake with `/ws.events` you must set an `Authorization` header where the value is the authorization token obtained [from the API](/api/introduction#authentication). -## Outbound Queues +## Event Types -Outbound queues follow the pattern `/queue/:event_type[/:action}]` and deliver -JSON encoded payloads. +All event updates are sent to the `/events` queue as JSON encoded payloads. The `type` +field informs the client of the kind of update that is encoded in the payload. ### Message -`/queue/message` - -Incoming payloads notify connected clients that a message was created or -updated. - -**Sample payload** - ```json5 { - "conversation_id": "{UUID}", - "channel_id": "{UUID}", - "message": { - "id": "{UUID}", - "content": '{"text":"Hello World"}', - // source message payload - "delivery_state": "{String}", - // delivery state of message, one of PENDING, FAILED, DELIVERED - "sender_type": "{string/enum}", - // See glossary - "sent_at": "{string}", - //'yyyy-MM-dd'T'HH:mm:ss.SSSZ' date in UTC form, to be localized by clients - "source": "{String}" - // one of the possible sources + "type": "message", + + "payload": { + "conversation_id": "{UUID}", + "channel_id": "{UUID}", + "message": { + "id": "{UUID}", + "content": '{"text":"Hello World"}', + // source message payload + "delivery_state": "pending|failed|delivered", + // delivery state of message, one of pending, failed, delivered + "sender_type": "{string/enum}", + // See glossary + "sent_at": "{string}", + //'yyyy-MM-dd'T'HH:mm:ss.SSSZ' date in UTC form, to be localized by clients + "source": "{String}" + // one of the possible sources + } } } ``` -### Unread count - -`/queue/unread-count` - -Incoming payloads notify connected clients of the unread message count for a -specific conversation at the time of delivery. Clients should keep track of the -latest time the unread count for a specific conversation was updated and update -the value only for a more recent count. +### Metadata **Sample payload** ```json5 { - conversation_id: "{UUID}", - //unique conversation id - unread_message_count: 42, - //the number of unreaded messages in this conversation - timestamp: "{string}" - //'yyyy-MM-dd'T'HH:mm:ss.SSSZ' date in UTC form, to be localized by clients + "type": "metadata", + + "payload": { + "subject": "conversation|channel|message", + "identifier": "conversation/channel/message id", + "metadata": { + // nested metadata object. I.e. for a conversation: + "contact": { + "displayName": "Grace" + }, + "isUserTyping": true + } + } } ``` -### Channel connected - -`/queue/channel/connected` - -Incoming payloads notify connected clients whenever a channel was connected or -updated. - -**Sample payload** +### Channel ```json5 { - "id": "{UUID}", - "source": "facebook", - "source_channel_id": "fb-page-id-1" -} -``` - ---- - -### Channel disconnected - -Incoming payloads notify connected clients whenever a channel was disconnected. - -`/queue/channel/disconnected` + "type": "channel", -**Sample payload** - -```json5 -{ - "id": "{UUID}", - "source": "facebook", - "source_channel_id": "fb-page-id-1" + "payload": { + "id": "{UUID}", + "name": "my page 1", + "source": "facebook", + "source_channel_id": "fb-page-id-1", + "connected": true // or false + } } ``` diff --git a/infrastructure/helm-chart/charts/apps/charts/api/charts/api-websocket/Chart.yaml b/infrastructure/helm-chart/charts/apps/charts/api/charts/api-websocket/Chart.yaml new file mode 100644 index 0000000000..44818c9c5b --- /dev/null +++ b/infrastructure/helm-chart/charts/apps/charts/api/charts/api-websocket/Chart.yaml @@ -0,0 +1,5 @@ +apiVersion: v1 +appVersion: "1.0" +description: A Helm chart for the Api Websocket app +name: api-websocket +version: 1.0 diff --git a/infrastructure/helm-chart/charts/apps/charts/api/charts/api-websocket/templates/deployment.yaml b/infrastructure/helm-chart/charts/apps/charts/api/charts/api-websocket/templates/deployment.yaml new file mode 100644 index 0000000000..709cdab6f2 --- /dev/null +++ b/infrastructure/helm-chart/charts/apps/charts/api/charts/api-websocket/templates/deployment.yaml @@ -0,0 +1,65 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: api-websocket + namespace: {{ .Values.global.namespace }} + labels: + app: api-websocket + type: api +spec: + replicas: 0 + selector: + matchLabels: + app: api-websocket + strategy: + rollingUpdate: + maxSurge: 1 + maxUnavailable: 1 + type: RollingUpdate + template: + metadata: + labels: + app: api-websocket + spec: + containers: + - name: app + image: "{{ .Values.global.containerRegistry}}/{{ .Values.image }}:{{ .Values.global.appImageTag }}" + imagePullPolicy: Always + env: + - name: KAFKA_BROKERS + valueFrom: + configMapKeyRef: + name: kafka-config + key: KAFKA_BROKERS + - name: KAFKA_SCHEMA_REGISTRY_URL + valueFrom: + configMapKeyRef: + name: kafka-config + key: KAFKA_SCHEMA_REGISTRY_URL + - name: KAFKA_COMMIT_INTERVAL_MS + valueFrom: + configMapKeyRef: + name: kafka-config + key: KAFKA_COMMIT_INTERVAL_MS + - name: SERVICE_NAME + value: api-websocket + - name: JWT_SECRET + valueFrom: + configMapKeyRef: + name: api-config + key: JWT_SECRET + - name: ALLOWED_ORIGINS + valueFrom: + configMapKeyRef: + name: api-config + key: ALLOWED_ORIGINS + livenessProbe: + httpGet: + path: /actuator/health + port: 8080 + httpHeaders: + - name: Health-Check + value: health-check + initialDelaySeconds: 60 + periodSeconds: 10 + failureThreshold: 3 diff --git a/infrastructure/helm-chart/charts/apps/charts/api/charts/api-websocket/templates/service.yaml b/infrastructure/helm-chart/charts/apps/charts/api/charts/api-websocket/templates/service.yaml new file mode 100644 index 0000000000..c90f109bf4 --- /dev/null +++ b/infrastructure/helm-chart/charts/apps/charts/api/charts/api-websocket/templates/service.yaml @@ -0,0 +1,13 @@ +apiVersion: v1 +kind: Service +metadata: + name: api-websocket + namespace: {{ .Values.global.namespace }} +spec: + ports: + - port: 80 + targetPort: 8080 + protocol: TCP + type: NodePort + selector: + app: api-websocket diff --git a/infrastructure/helm-chart/charts/apps/charts/api/charts/api-websocket/values.yaml b/infrastructure/helm-chart/charts/apps/charts/api/charts/api-websocket/values.yaml new file mode 100644 index 0000000000..542d24bf95 --- /dev/null +++ b/infrastructure/helm-chart/charts/apps/charts/api/charts/api-websocket/values.yaml @@ -0,0 +1 @@ +image: api/websocket diff --git a/infrastructure/helm-chart/templates/ingress.yaml b/infrastructure/helm-chart/templates/ingress.yaml index a9c5721a5e..5e5c0b6100 100644 --- a/infrastructure/helm-chart/templates/ingress.yaml +++ b/infrastructure/helm-chart/templates/ingress.yaml @@ -29,6 +29,13 @@ spec: name: api-communication port: number: 80 + - path: /ws.events + pathType: Prefix + backend: + service: + name: api-websocket + port: + number: 80 - path: /conversations.list pathType: Prefix backend: diff --git a/lib/typescript/httpclient/mappers/channelMapper.ts b/lib/typescript/httpclient/mappers/channelMapper.ts index 625eb2452a..86ec7f5174 100644 --- a/lib/typescript/httpclient/mappers/channelMapper.ts +++ b/lib/typescript/httpclient/mappers/channelMapper.ts @@ -2,7 +2,7 @@ import {Channel} from '../model'; import {ChannelApiPayload} from '../payload/ChannelApiPayload'; export const channelMapper = (payload: ChannelApiPayload): Channel => { - let channel = { + const channel = { ...payload, sourceChannelId: payload.source_channel_id, metadata: { diff --git a/lib/typescript/httpclient/payload/MessagePayload.ts b/lib/typescript/httpclient/payload/MessagePayload.ts index c643532d82..089f86df79 100644 --- a/lib/typescript/httpclient/payload/MessagePayload.ts +++ b/lib/typescript/httpclient/payload/MessagePayload.ts @@ -6,5 +6,5 @@ export interface MessagePayload { delivery_state: MessageState; sender_type: SenderType; sent_at: Date; - metadata?: Map; + metadata: any; } From a4528bb567318c53c361bf405ee0d7df02f6dd66 Mon Sep 17 00:00:00 2001 From: Paulo Diniz Date: Thu, 11 Feb 2021 09:01:51 +0100 Subject: [PATCH 08/10] [#910] Add message metadata API documentation (#937) --- docs/docs/api/endpoints/messages.md | 12 ++++++++++-- docs/docs/api/websocket.md | 7 +++++-- docs/docs/sources/chat-plugin.md | 18 +++++++++++++++--- 3 files changed, 30 insertions(+), 7 deletions(-) diff --git a/docs/docs/api/endpoints/messages.md b/docs/docs/api/endpoints/messages.md index bc966e90b8..05bf8b6edd 100644 --- a/docs/docs/api/endpoints/messages.md +++ b/docs/docs/api/endpoints/messages.md @@ -39,8 +39,12 @@ latest. // See glossary "sent_at": "{string}", //'yyyy-MM-dd'T'HH:mm:ss.SSSZ' date in UTC form, to be localized by clients - "source": "{String}" + "source": "{String}", // one of the possible sources + "metadata": { + "sentFrom": "iPhone" + } + // metadata object of the message } ], "pagination_data": { @@ -81,7 +85,11 @@ Sends a message to a conversation and returns a payload. Whatever is put on the // See glossary "sent_at": "{string}", //'yyyy-MM-dd'T'HH:mm:ss.SSSZ' date in UTC form, to be localized by clients - "source": "{String}" + "source": "{String}", // one of the possible sources + "metadata": { + "sentFrom": "iPhone" + } + // metadata object of the message } ``` diff --git a/docs/docs/api/websocket.md b/docs/docs/api/websocket.md index 7cf3c109d2..dff6bc1d3f 100644 --- a/docs/docs/api/websocket.md +++ b/docs/docs/api/websocket.md @@ -30,7 +30,6 @@ field informs the client of the kind of update that is encoded in the payload. ```json5 { "type": "message", - "payload": { "conversation_id": "{UUID}", "channel_id": "{UUID}", @@ -44,8 +43,12 @@ field informs the client of the kind of update that is encoded in the payload. // See glossary "sent_at": "{string}", //'yyyy-MM-dd'T'HH:mm:ss.SSSZ' date in UTC form, to be localized by clients - "source": "{String}" + "source": "{String}", // one of the possible sources + "metadata": { + "sentFrom": "iPhone" + } + // metadata object of the message } } } diff --git a/docs/docs/sources/chat-plugin.md b/docs/docs/sources/chat-plugin.md index 45d6e78fa5..032ada16d4 100644 --- a/docs/docs/sources/chat-plugin.md +++ b/docs/docs/sources/chat-plugin.md @@ -104,8 +104,12 @@ previous conversation using the [resume endpoint](#get-a-resume-token). // delivery state of message, one of PENDING, FAILED, DELIVERED "sender_type": "{string/enum}", // See glossary - "sent_at": "{string}" + "sent_at": "{string}", //'yyyy-MM-dd'T'HH:mm:ss.SSSZ' date in UTC form, to be localized by clients + "metadata": { + "sentFrom": "iPhone" + } + // metadata object of the message } ] } @@ -160,8 +164,12 @@ header. // delivery state of message, one of PENDING, FAILED, DELIVERED sender_type: "{string/enum}", // See glossary - sent_at: "{string}" + sent_at: "{string}", //'yyyy-MM-dd'T'HH:mm:ss.SSSZ' date in UTC form, to be localized by clients + "metadata": { + "sentFrom": "iPhone" + } + // metadata object of the message } ``` @@ -189,8 +197,12 @@ The WebSocket connection endpoint is at `/ws.chatplugin`. // delivery state of message, one of PENDING, FAILED, DELIVERED sender_type: "{string/enum}", // See glossary - sent_at: "{string}" + sent_at: "{string}", //'yyyy-MM-dd'T'HH:mm:ss.SSSZ' date in UTC form, to be localized by clients + "metadata": { + "sentFrom": "iPhone" + } + // metadata object of the message } } ``` From 085f20575787472147b2f1e5adc67b7839e34954 Mon Sep 17 00:00:00 2001 From: AudreyKj <38159391+AudreyKj@users.noreply.github.com> Date: Thu, 11 Feb 2021 10:06:07 +0100 Subject: [PATCH 09/10] Feature/861 render quick replies from facebook (#942) * added quick replies from facebook, working version * added attachement to quick replies and refined media component * updated render facebook * fixed lint --- .../{ => RichCard}/Media/index.module.scss | 0 .../components/{ => RichCard}/Media/index.tsx | 2 +- .../render/components/RichCard/index.tsx | 2 +- .../render/components/Video/index.module.scss | 52 ++++++++++++++++ .../render/components/Video/index.tsx | 35 +++++++++++ .../providers/chatplugin/ChatPluginRender.tsx | 2 - .../providers/facebook/FacebookRender.tsx | 61 ++++++++++++++++--- .../components/QuickReplies/index.module.scss | 41 +++++++++++++ .../components/QuickReplies/index.tsx | 40 ++++++++++++ .../providers/facebook/facebookModel.ts | 28 ++++++++- 10 files changed, 250 insertions(+), 13 deletions(-) rename lib/typescript/render/components/{ => RichCard}/Media/index.module.scss (100%) rename lib/typescript/render/components/{ => RichCard}/Media/index.tsx (87%) create mode 100644 lib/typescript/render/components/Video/index.module.scss create mode 100644 lib/typescript/render/components/Video/index.tsx create mode 100644 lib/typescript/render/providers/facebook/components/QuickReplies/index.module.scss create mode 100644 lib/typescript/render/providers/facebook/components/QuickReplies/index.tsx diff --git a/lib/typescript/render/components/Media/index.module.scss b/lib/typescript/render/components/RichCard/Media/index.module.scss similarity index 100% rename from lib/typescript/render/components/Media/index.module.scss rename to lib/typescript/render/components/RichCard/Media/index.module.scss diff --git a/lib/typescript/render/components/Media/index.tsx b/lib/typescript/render/components/RichCard/Media/index.tsx similarity index 87% rename from lib/typescript/render/components/Media/index.tsx rename to lib/typescript/render/components/RichCard/Media/index.tsx index 5746fa4598..675875b41f 100644 --- a/lib/typescript/render/components/Media/index.tsx +++ b/lib/typescript/render/components/RichCard/Media/index.tsx @@ -1,6 +1,6 @@ import React from 'react'; import styles from './index.module.scss'; -import {MediaHeight} from '../../providers/chatplugin/chatPluginModel'; +import {MediaHeight} from '../../../providers/chatplugin/chatPluginModel'; export type MediaRenderProps = { height: MediaHeight; diff --git a/lib/typescript/render/components/RichCard/index.tsx b/lib/typescript/render/components/RichCard/index.tsx index 9229e885ff..79f7048472 100644 --- a/lib/typescript/render/components/RichCard/index.tsx +++ b/lib/typescript/render/components/RichCard/index.tsx @@ -1,6 +1,6 @@ import React from 'react'; import styles from './index.module.scss'; -import {Media, MediaRenderProps} from '../Media'; +import {Media, MediaRenderProps} from './Media'; import {DefaultMessageRenderingProps} from '../index'; type Suggestions = [ diff --git a/lib/typescript/render/components/Video/index.module.scss b/lib/typescript/render/components/Video/index.module.scss new file mode 100644 index 0000000000..4dbca49856 --- /dev/null +++ b/lib/typescript/render/components/Video/index.module.scss @@ -0,0 +1,52 @@ +@import 'assets/scss/fonts.scss'; +@import 'assets/scss/colors.scss'; + +.wrapper { + display: flex; + flex: none; +} + +.item { + display: flex; + align-self: flex-end; + width: 100%; + overflow-wrap: break-word; + word-break: break-word; +} + +.itemMember { + margin-top: 5px; + justify-content: flex-end; + width: 100%; + text-align: right; +} + +.itemMemberVideo { + display: inline-flex; + position: relative; + text-align: left; + border-radius: 8px; + overflow: hidden; +} + +.container { + display: flex; + flex-direction: row; +} + +.itemUser { + align-self: flex-start; + text-align: left; + position: relative; +} + +.itemUserVideo { + display: inline-flex; + margin-top: 5px; + border-radius: 8px; + overflow: hidden; +} + +.video { + max-width: 100%; +} diff --git a/lib/typescript/render/components/Video/index.tsx b/lib/typescript/render/components/Video/index.tsx new file mode 100644 index 0000000000..4ebcc9ab18 --- /dev/null +++ b/lib/typescript/render/components/Video/index.tsx @@ -0,0 +1,35 @@ +import React from 'react'; +import styles from './index.module.scss'; +import {DefaultMessageRenderingProps} from '../index'; + +type VideoRenderProps = DefaultMessageRenderingProps & { + videoUrl: string; +}; + +export const Video = ({fromContact, videoUrl}: VideoRenderProps) => ( +
    +
    + {!fromContact ? ( +
    +
    + +
    +
    + ) : ( +
    +
    +
    + +
    +
    +
    + )} +
    +
    +); diff --git a/lib/typescript/render/providers/chatplugin/ChatPluginRender.tsx b/lib/typescript/render/providers/chatplugin/ChatPluginRender.tsx index 46fd171e9c..7012766648 100644 --- a/lib/typescript/render/providers/chatplugin/ChatPluginRender.tsx +++ b/lib/typescript/render/providers/chatplugin/ChatPluginRender.tsx @@ -37,8 +37,6 @@ function render(content: ContentUnion, props: MessageRenderProps) { suggestions={content.suggestions} /> ); - - // TODO render more chatplugin models } } diff --git a/lib/typescript/render/providers/facebook/FacebookRender.tsx b/lib/typescript/render/providers/facebook/FacebookRender.tsx index d55f319110..f28d141c28 100644 --- a/lib/typescript/render/providers/facebook/FacebookRender.tsx +++ b/lib/typescript/render/providers/facebook/FacebookRender.tsx @@ -3,7 +3,9 @@ import {isFromContact, Message} from '../../../httpclient/model'; import {getDefaultMessageRenderingProps, MessageRenderProps} from '../../shared'; import {Text} from '../../components/Text'; import {Image} from '../../components/Image'; -import {SimpleAttachment, ButtonAttachment, ContentUnion, GenericAttachment} from './facebookModel'; +import {Video} from '../../components/Video'; +import {QuickReplies} from './components/QuickReplies'; +import {AttachmentUnion, SimpleAttachment, ContentUnion, ButtonAttachment, GenericAttachment} from './facebookModel'; import {ButtonTemplate} from './components/ButtonTemplate'; import {GenericTemplate} from './components/GenericTemplate'; @@ -21,15 +23,28 @@ function render(content: ContentUnion, props: MessageRenderProps) { case 'image': return ; + case 'video': + return