From 7c9f36d49f22612da45de6e7560f51bcbcf5958e Mon Sep 17 00:00:00 2001 From: sysang Date: Fri, 9 Sep 2022 16:49:31 +0700 Subject: [PATCH] Implement Cwwhatsapp connector --- Makefile | 10 +- botserver-action/actions.py | 51 +++-- botserver-app/addons/channels/chatwoot.py | 39 ++-- .../addons/channels/cwwebsite_output.py | 2 + .../addons/channels/cwwhatsapp_output.py | 179 ++++++++++++++++++ botserver-app/addons/channels/output.py | 32 ++++ .../local.chatwoot.nginx.template | 6 +- incoming_message_base_service.rb | 114 +++++++++++ send_on_twilio_service.rb | 44 +++++ telegram.rb | 4 +- telegram_events_job.rb | 3 +- whatsapp_cloud_service.rb | 123 ++++++++++++ 12 files changed, 570 insertions(+), 37 deletions(-) create mode 100644 botserver-app/addons/channels/cwwhatsapp_output.py create mode 100644 botserver-app/addons/channels/output.py create mode 100644 incoming_message_base_service.rb create mode 100644 send_on_twilio_service.rb create mode 100644 whatsapp_cloud_service.rb diff --git a/Makefile b/Makefile index 0a99872..b7412c4 100644 --- a/Makefile +++ b/Makefile @@ -86,10 +86,12 @@ install_ssl_certificate: # touch file2 patch_chatwoot: - sudo docker cp incoming_message_service.rb rasachatbot-sidekiq-1:/app/app/services/telegram/incoming_message_service.rb - sudo docker cp telegram.rb rasachatbot-sidekiq-1:/app/app/models/channel/telegram.rb + docker cp incoming_message_service.rb rasachatbot-sidekiq-1:/app/app/services/telegram/incoming_message_service.rb + docker cp telegram.rb rasachatbot-sidekiq-1:/app/app/models/channel/telegram.rb + docker cp incoming_message_base_service.rb rasachatbot-sidekiq-1:/app/app/services/whatsapp/incoming_message_base_service.rb + docker cp whatsapp_cloud_service.rb rasachatbot-sidekiq-1:/app/app/services/whatsapp/providers/whatsapp_cloud_service.rb sidekig: make patch_chatwoot - sudo docker restart rasachatbot-sidekiq-1 - sudo docker logs --tail=100 -f rasachatbot-sidekiq-1 + docker restart rasachatbot-sidekiq-1 + docker logs --tail=100 -f rasachatbot-sidekiq-1 diff --git a/botserver-action/actions.py b/botserver-action/actions.py index 7ca67d6..9bea509 100644 --- a/botserver-action/actions.py +++ b/botserver-action/actions.py @@ -189,7 +189,24 @@ def name(self) -> Text: return "botacts_utter_inform_searching_inprogress" def run(self, dispatcher: CollectingDispatcher, tracker: Tracker, domain: Dict[Text, Any]) -> List[Dict[Text, Any]]: - dispatcher.utter_message(response='utter_inform_searching_inprogress') + slots = tracker.slots + query_payload = slots.get('search_result_query', '') + if not query_payload: + botmemo_booking_progress = FSMBotmemeBookingProgress(slots) + bkinfo = botmemo_booking_progress.form + data = { + 'destination': bkinfo.get('bkinfo_area'), + 'checkin': bkinfo.get('bkinfo_checkin_time'), + 'staying': bkinfo.get('bkinfo_duration') if bkinfo.get('bkinfo_checkin_time') != bkinfo.get('bkinfo_duration') else '', + 'bed_type': bkinfo.get('bkinfo_bed_type'), + 'max_price': bkinfo.get('bkinfo_price'), + } + message = """ + I'm going to search for hotel room based on your information, destination: {destination}, check-in: {checkin}, staying: {staying}, bed type: {bed_type}, max price: {max_price}. It takes for a while, hold on! + """.format(**data) + + dispatcher.utter_message(text=message) + # dispatcher.utter_message(response='utter_inform_searching_inprogress') return [FollowupAction(name='botacts_search_hotel_rooms')] @@ -218,8 +235,10 @@ async def run(self, dispatcher: CollectingDispatcher, tracker: Tracker, domain: web_channels = ['socketio', 'rasa', 'cwwebsite'] telegram_channels = ['telegram', 'cwtelegram'] + fb_channels = ['facebook', 'cwfacebook'] + whatsapp_channels = ['whatsapp', 'cwwhatsapp'] - limit_num = 5 if channel == 'facebook' else 2 if channel in web_channels else 1 + limit_num = 5 if channel in fb_channels else 2 if channel in web_channels else 1 if bkinfo_orderby: @@ -287,12 +306,14 @@ async def run(self, dispatcher: CollectingDispatcher, tracker: Tracker, domain: total_rooms = reduce(lambda x, y: x+y, hotels.values()) room_num = len(total_rooms) - if SortbyDictionary.SORTBY_REVIEW_SCORE == bkinfo_orderby: - dispatcher.utter_message(response="utter_about_to_show_hotel_list_by_review_score", room_num=room_num) - elif SortbyDictionary.SORTBY_PRICE == bkinfo_orderby: - dispatcher.utter_message(response="utter_about_to_show_hotel_list_by_price", room_num=room_num) - else: - dispatcher.utter_message(response="utter_about_to_show_hotel_list_by_popularity", room_num=room_num) + # do not repeat if user keep exploring the same filter + if page_number == 1: + if SortbyDictionary.SORTBY_REVIEW_SCORE == bkinfo_orderby: + dispatcher.utter_message(response="utter_about_to_show_hotel_list_by_review_score", room_num=room_num) + elif SortbyDictionary.SORTBY_PRICE == bkinfo_orderby: + dispatcher.utter_message(response="utter_about_to_show_hotel_list_by_price", room_num=room_num) + else: + dispatcher.utter_message(response="utter_about_to_show_hotel_list_by_popularity", room_num=room_num) start, end, remains = paginate(index=page_number, limit=limit_num, total=room_num) logger.info('[INFO] paginating search result by index=%s, limit=%s, total=%s -> (%s, %s, %s)', page_number, limit_num, room_num, start, end, remains) @@ -341,7 +362,7 @@ async def run(self, dispatcher: CollectingDispatcher, tracker: Tracker, domain: fb_buttons = [] teleg_buttons = [] - if channel == 'facebook': + if channel in fb_channels: if photos_presentation_url: fb_buttons.append({ @@ -369,7 +390,7 @@ async def run(self, dispatcher: CollectingDispatcher, tracker: Tracker, domain: elif channel in web_channels: room_description = "⚑ {room_display_index}: {room_name}, {room_bed_type}, ☝ {room_min_price:.2f} {room_price_currency}." . format(**data) room_photos = "To view room photos: \n{photos_url}" . format(**data) - hotel_description = "❖ More information: {hotel_name} ★★★ Score: {review_score} ★★★ Address: {address}, {city}, {country}, {nearest_beach_name}" . format(**data) + hotel_description = "❖ Hotel information: {hotel_name} ★★★ Score: {review_score} ★★★ Address: {address}, {city}, {country}, {nearest_beach_name}" . format(**data) # button = { "title": 'Pick Room ⚑ {room_display_index}'.format(**data), "payload": btn_payload} button = { "title": room_description, "payload": btn_payload} @@ -380,8 +401,8 @@ async def run(self, dispatcher: CollectingDispatcher, tracker: Tracker, domain: logger.info("[INFO] send message to web channel, hotel_description: %s", hotel_description) - elif channel in telegram_channels: - hotel_description = "❖ More information: {hotel_name} ★★★ Score: {review_score} ★★★ Address: {address}, {city}, {country}, {nearest_beach_name}" . format(**data) + elif channel in telegram_channels or channel in whatsapp_channels: + hotel_description = "❖ Hotel information: {hotel_name} ★★★ Score: {review_score} ★★★ Address: {address}, {city}, {country}, {nearest_beach_name}" . format(**data) button = { "title": 'Pick Room ⚑ {room_display_index}'.format(**data), "payload": btn_payload} teleg_buttons.append(button) @@ -400,7 +421,7 @@ async def run(self, dispatcher: CollectingDispatcher, tracker: Tracker, domain: else: dispatcher.utter_message(text=hotel_description) - logger.info("[INFO] send message to telegram channel, room_description: ", room_description) + logger.info("[INFO] send message to telegram/whatsapp channel, room_description: %s", room_description) # End of `for room in total_rooms[start:end]:` @@ -418,7 +439,7 @@ async def run(self, dispatcher: CollectingDispatcher, tracker: Tracker, domain: next_button_title = '❯❯ next {remains} room(s)'.format(**query) next_button_payload = '/{intent}{next}'.format(**query) - if channel == 'facebook': + if channel in fb_channels: fb_buttons = [] if query.get('prev'): @@ -435,7 +456,7 @@ async def run(self, dispatcher: CollectingDispatcher, tracker: Tracker, domain: buttons.append({'title': next_button_title, 'payload': next_button_payload}) dispatcher.utter_message(response="utter_instruct_to_choose_room", buttons=buttons) - elif channel in telegram_channels: + elif channel in telegram_channels or channel in whatsapp_channels: if query.get('prev'): teleg_buttons.append({'title': prev_button_title, 'payload': prev_button_payload}) if query.get('next'): diff --git a/botserver-app/addons/channels/chatwoot.py b/botserver-app/addons/channels/chatwoot.py index 94b4aee..772fe88 100644 --- a/botserver-app/addons/channels/chatwoot.py +++ b/botserver-app/addons/channels/chatwoot.py @@ -24,6 +24,7 @@ from .cwwebsite_output import CwwebsiteOutput from .cwtelegram_output import CwteltegramOutput +from .cwwhatsapp_output import CwwhatsappOutput logger = logging.getLogger(__name__) @@ -62,24 +63,25 @@ def redis_check_cache(data): def create_handler(message, chatwoot_url, cfg, on_new_message) -> Callable: def select_output_channel(channel, chatwoot_url, cfg, **kwargs): - out_channel = None - if channel == 'cwwebsite': - out_channel = CwwebsiteOutput( - chatwoot_url=chatwoot_url, - bot_token=cfg.get("bot_token"), - botagent_account_id=cfg.get("botagent_account_id"), - conversation_id=kwargs.get("conversation_id"), - ) + output_channel_cls = None + if channel == 'cwwebsite': + output_channel_cls = CwwebsiteOutput elif channel == 'cwtelegram': - out_channel = CwteltegramOutput( + output_channel_cls = CwteltegramOutput + elif channel == 'cwwhatsapp': + output_channel_cls = CwwhatsappOutput + + if output_channel_cls: + return output_channel_cls( chatwoot_url=chatwoot_url, bot_token=cfg.get("bot_token"), botagent_account_id=cfg.get("botagent_account_id"), conversation_id=kwargs.get("conversation_id"), ) - return out_channel + + return None async def process_message() -> None: logger.info('[DEBUG] (process_message) message: %s', message) @@ -141,9 +143,10 @@ def from_credentials(cls, credentials: Optional[Dict[Text, Any]]) -> InputChanne logger.info('[INFO] chatwoot connector (channel), from_credentials: %s', credentials) return cls( - credentials.get("chatwoot_url"), - credentials.get("website"), - credentials.get("telegram"), + chatwoot_url=credentials.get("chatwoot_url"), + website=credentials.get("website"), + telegram=credentials.get("telegram"), + whatsapp=credentials.get("whatsapp"), ) def __init__( @@ -151,11 +154,13 @@ def __init__( chatwoot_url, website: Dict[Text, Any], telegram: Dict[Text, Any], + whatsapp: Dict[Text, Any], ) -> None: self.chatwoot_url = chatwoot_url self.website = website self.telegram = telegram + self.whatsapp = whatsapp def blueprint( self, on_new_message: Callable[[UserMessage], Awaitable[None]] @@ -235,6 +240,14 @@ async def webhook(request: Request) -> HTTPResponse: ctx_cfg=json.dumps(self.telegram), ) + custom_webhook.add_route( + handler=webhook, + uri="/cwwhatsapp", + methods=["POST"], + ctx_chatwoot_url=self.chatwoot_url, + ctx_cfg=json.dumps(self.whatsapp), + ) + return custom_webhook diff --git a/botserver-app/addons/channels/cwwebsite_output.py b/botserver-app/addons/channels/cwwebsite_output.py index a0cf74a..e95b4ce 100644 --- a/botserver-app/addons/channels/cwwebsite_output.py +++ b/botserver-app/addons/channels/cwwebsite_output.py @@ -6,6 +6,8 @@ from typing import Text, Dict, Any, Optional, Callable, Awaitable, NoReturn, List, Iterable +from rasa.core.channels.channel import OutputChannel + logger = logging.getLogger(__name__) diff --git a/botserver-app/addons/channels/cwwhatsapp_output.py b/botserver-app/addons/channels/cwwhatsapp_output.py new file mode 100644 index 0000000..3c74dec --- /dev/null +++ b/botserver-app/addons/channels/cwwhatsapp_output.py @@ -0,0 +1,179 @@ +import requests +import json +import logging +import asyncio +import aiohttp +import time + +from typing import Text, Dict, Any, Optional, Callable, Awaitable, NoReturn, List, Iterable + +from .output import send_message_to_chatwoot + + +logger = logging.getLogger(__name__) + +class CwwhatsappOutput: + + @classmethod + def name(cls) -> Text: + """Every output channel needs a name to identify it.""" + return cls.__name__ + + async def send_response(self, recipient_id: Text, message: Dict[Text, Any]) -> None: + """Send a message to the client.""" + logger.info('[DEV] send_response: %s', message) + + if message.get("buttons") and message.get("text"): + await asyncio.sleep(0.35) + await self.send_text_with_buttons(message.pop("text"), message.pop("buttons"), **message) + + if message.get("image") and message.get("text"): + await self.send_text_with_image(message.pop("text"), message.pop("image"), **message) + + if message.get("text"): + await asyncio.sleep(0.35) + await self.send_text_message(message.pop("text"), **message) + + # if there is an image we handle it separately as an attachment + if message.get("image"): + await self.send_image_url(message.pop("image"), **message) + + if message.get("attachment"): + await self.send_attachment(message.pop("attachment"), **message) + + if message.get("elements"): + await self.send_elements(message.pop("elements"), **message) + + if message.get("custom"): + await self.send_custom_json(message.pop("custom"), **message) + + def __init__(self, chatwoot_url, bot_token, botagent_account_id, conversation_id) -> None: + self.chatwoot_url = chatwoot_url + self.bot_token = bot_token + self.botagent_account_id = botagent_account_id + self.conversation_id = conversation_id + + async def _send_message(self, message: Text) -> None: + """Send a message through this channel.""" + + await send_message_to_chatwoot( + url=self.chatwoot_url, + botagent_account_id=self.botagent_account_id, + conversation_id=self.conversation_id, + bot_token=self.bot_token, + message=message, + ) + + async def send_text_message( self, text: Text, **kwargs: Any) -> None: + """Send a message through this channel.""" + + for message_part in text.strip().split("\n\n"): + await self._send_message({ + "type": "text", + "text": { "body": message_part} + }) + + async def send_text_with_image( self, text: Text, image: Text, **kwargs: Any) -> None: + """Send a message through this channel.""" + message_parts = text.strip().split("\n\n") + + for message_part in message_parts[0:-1]: + await self._send_message({ + "type": "text", + "text": { "body": message_part} + }) + + last_message_part = message_parts[-1] + message = { + 'type': 'image', + 'image': { + 'link': image, + 'caption': last_message_part + } + } + await self._send_message(message) + + async def send_image_url( self, image: Text, **kwargs: Any) -> None: + """Sends an image to the output""" + + message = { + "type": "image", + "image": {"link": image} + } + await self._send_message(message) + + async def send_button(self, button: Dict) -> None: + message = { + 'type': 'interactive', + 'interactive': { + 'type': 'button', + 'action': { + 'button': { + 'type': 'payload', + 'payload': button['payload'], + 'text': button['title'], + } + } + } + } + await self._send_message(message) + + async def send_text_with_buttons(self, text: Text, buttons: List[Dict[Text, Any]], **kwargs: Any,) -> None: + """Sends buttons to the output.""" + + message_parts = text.strip().split("\n\n") or [text] + + for message_part in message_parts[0:-1]: + await self._send_message({ + "type": "text", + "text": { "body": message_part} + }) + + action_btns = [] + for button in buttons: + action_btns.append({ + "type": "reply", + "reply": { + "id": button["payload"], + "title": button["title"] + } + }) + + last_message_part = message_parts[-1] + message = { + "type": "interactive", + "interactive": { + "type": "button", + "body": { + "text": last_message_part, + }, + "action": { + "buttons": action_btns + } + } + } + + await self._send_message(message) + + async def send_elements(self, elements: Iterable[Dict[Text, Any]], **kwargs: Any) -> None: + """Sends elements to the output.""" + + for element in elements: + message = { + "attachment": { + "type": "template", + "payload": {"template_type": "generic", "elements": element}, + } + } + + await self._send_message(message) + + async def send_custom_json(self, json_message: Dict[Text, Any], **kwargs: Any) -> None: + """Sends custom json to the output""" + + raise NotImplementedError("send_custom_json") + + async def send_attachment(self, attachment: Dict[Text, Any], **kwargs: Any) -> None: + """Sends an attachment to the user.""" + + raise NotImplementedError("send_custom_json") diff --git a/botserver-app/addons/channels/output.py b/botserver-app/addons/channels/output.py new file mode 100644 index 0000000..93ac0af --- /dev/null +++ b/botserver-app/addons/channels/output.py @@ -0,0 +1,32 @@ +import json +import logging +import asyncio +import aiohttp + +from typing import Text, Dict, Any, Optional, Callable, Awaitable, NoReturn, List, Iterable + + +logger = logging.getLogger(__name__) + +async def send_message_to_chatwoot(url: Text, botagent_account_id: Any, conversation_id: Any, message: Dict[Text, Text], bot_token: Text) -> Awaitable: + """Send a message through this channel.""" + + url = f"{url}/api/v1/accounts/{botagent_account_id}/conversations/{conversation_id}/messages" + headers = { + "Content-Type": "application/json", + "Accept": "application/json", + "api_access_token": f"{bot_token}" + } + data = { + 'content': json.dumps(message), + 'message_type': 'outgoing', + } + + logger.info('[DEBUG] make request to send message to chatwoot server, url: %s, headers: %s, data: %s', url, headers, data) + + # TDOD: check if error + timeout = aiohttp.ClientTimeout(total=60) + async with aiohttp.ClientSession(timeout=timeout) as session: + async with session.post(url, json=data, headers=headers) as resp: + logger.info('[DEBUG] make request to send message to chatwoot server, response: %s', resp) + diff --git a/httpserver-nginx/nginx-config-files/local.chatwoot.nginx.template b/httpserver-nginx/nginx-config-files/local.chatwoot.nginx.template index 3929757..b198fec 100755 --- a/httpserver-nginx/nginx-config-files/local.chatwoot.nginx.template +++ b/httpserver-nginx/nginx-config-files/local.chatwoot.nginx.template @@ -3,8 +3,10 @@ upstream docker-chatwoot { } server { - listen 8080; - server_name cs.rasachatbot.sysang; + listen 8080; + + #server_name cs.rasachatbot.sysang; + server_name 9f04-115-74-186-32.ap.ngrok.io; # Nginx strips out underscore in headers by default # Chatwoot relies on underscore in headers for API diff --git a/incoming_message_base_service.rb b/incoming_message_base_service.rb new file mode 100644 index 0000000..507f8a8 --- /dev/null +++ b/incoming_message_base_service.rb @@ -0,0 +1,114 @@ +# source: https://github.com/chatwoot/chatwoot/blob/v2.8.1/app/services/whatsapp/incoming_message_base_service.rb +# docker cp incoming_message_base_service.rb rasachatbot-sidekiq-1:/app/app/services/whatsapp/incoming_message_base_service.rb + +# Mostly modeled after the intial implementation of the service based on 360 Dialog +# https://docs.360dialog.com/whatsapp-api/whatsapp-api/media +# https://developers.facebook.com/docs/whatsapp/api/media/ +class Whatsapp::IncomingMessageBaseService + pattr_initialize [:inbox!, :params!] + + def perform + processed_params + + set_contact + return unless @contact + + set_conversation + + return if @processed_params[:messages].blank? + + @message = @conversation.messages.build( + content: message_content(@processed_params[:messages].first), + account_id: @inbox.account_id, + inbox_id: @inbox.id, + message_type: :incoming, + sender: @contact, + source_id: @processed_params[:messages].first[:id].to_s + ) + attach_files + @message.save! + end + + private + + def processed_params + @processed_params ||= params + end + + def message_content(message) + # TODO: map interactive messages back to button messages in chatwoot + message.dig(:text, :body) || + message.dig(:button, :text) || + # message.dig(:interactive, :button_reply, :title) || + message.dig(:interactive, :button_reply, :id) || + message.dig(:interactive, :list_reply, :title) + end + + def account + @account ||= inbox.account + end + + def set_contact + contact_params = @processed_params[:contacts]&.first + return if contact_params.blank? + + contact_inbox = ::ContactBuilder.new( + source_id: contact_params[:wa_id], + inbox: inbox, + contact_attributes: { name: contact_params.dig(:profile, :name), phone_number: "+#{@processed_params[:messages].first[:from]}" } + ).perform + + @contact_inbox = contact_inbox + @contact = contact_inbox.contact + end + + def conversation_params + { + account_id: @inbox.account_id, + inbox_id: @inbox.id, + contact_id: @contact.id, + contact_inbox_id: @contact_inbox.id + } + end + + def set_conversation + @conversation = @contact_inbox.conversations.last + return if @conversation + + @conversation = ::Conversation.create!(conversation_params) + end + + def file_content_type(file_type) + return :image if %w[image sticker].include?(file_type) + return :audio if %w[audio voice].include?(file_type) + return :video if ['video'].include?(file_type) + + :file + end + + def message_type + @processed_params[:messages].first[:type] + end + + def attach_files + return if %w[text button interactive].include?(message_type) + + attachment_payload = @processed_params[:messages].first[message_type.to_sym] + attachment_file = download_attachment_file(attachment_payload) + + @message.content ||= attachment_payload[:caption] + @message.attachments.new( + account_id: @message.account_id, + file_type: file_content_type(message_type), + file: { + io: attachment_file, + filename: attachment_file.original_filename, + content_type: attachment_file.content_type + } + ) + end + + def download_attachment_file(attachment_payload) + Down.download(inbox.channel.media_url(attachment_payload[:id]), headers: inbox.channel.api_headers) + end +end diff --git a/send_on_twilio_service.rb b/send_on_twilio_service.rb new file mode 100644 index 0000000..77ba96a --- /dev/null +++ b/send_on_twilio_service.rb @@ -0,0 +1,44 @@ +# source: https://raw.githubusercontent.com/chatwoot/chatwoot/afe31a3156d730955b732fc0c2fdfa5bea040fd4/app/services/twilio/send_on_twilio_service.rb +# docker cp send_on_twilio_service.rb rasachatbot-sidekiq-1:/app/app/services/twilio/send_on_twilio_service.rb + +class Twilio::SendOnTwilioService < Base::SendOnChannelService + private + + def channel_class + Channel::TwilioSms + end + + def perform_reply + begin + twilio_message = channel.send_message(**message_params) + rescue Twilio::REST::TwilioError => e + ChatwootExceptionTracker.new(e, user: message.sender, account: message.account).capture_exception + end + message.update!(source_id: twilio_message.sid) if twilio_message + end + + def message_params + { + body: message.content, + to: contact_inbox.source_id, + media_url: attachments + } + end + + def attachments + message.attachments.map(&:download_url) + end + + def inbox + @inbox ||= message.inbox + end + + def channel + @channel ||= inbox.channel + end + + def outgoing_message? + message.outgoing? || message.template? + end +end + diff --git a/telegram.rb b/telegram.rb index 864341b..8521ed1 100644 --- a/telegram.rb +++ b/telegram.rb @@ -1,5 +1,5 @@ -# app/models/channel/telegram.rb -# sudo docker cp telegram.rb rasachatbot-sidekiq-1:/app/app/models/channel/telegram.rb +# source: app/models/channel/telegram.rb +# docker cp telegram.rb rasachatbot-sidekiq-1:/app/app/models/channel/telegram.rb # == Schema Information # diff --git a/telegram_events_job.rb b/telegram_events_job.rb index 3249dbe..5b54f60 100644 --- a/telegram_events_job.rb +++ b/telegram_events_job.rb @@ -1,4 +1,5 @@ -# sudo docker cp telegram_events_job.rb rasachatbot-sidekiq-1:/app/app/jobs/webhooks/telegram_events_job.rb +# source: app/jobs/webhooks/telegram_events_job.rb +# docker cp telegram_events_job.rb rasachatbot-sidekiq-1:/app/app/jobs/webhooks/telegram_events_job.rb class Webhooks::TelegramEventsJob < ApplicationJob queue_as :default diff --git a/whatsapp_cloud_service.rb b/whatsapp_cloud_service.rb new file mode 100644 index 0000000..581ef3c --- /dev/null +++ b/whatsapp_cloud_service.rb @@ -0,0 +1,123 @@ +# source: https://raw.githubusercontent.com/chatwoot/chatwoot/v2.8.1/app/services/whatsapp/providers/whatsapp_cloud_service.rb +# docker cp whatsapp_cloud_service.rb rasachatbot-sidekiq-1:/app/app/services/whatsapp/providers/whatsapp_cloud_service.rb + +class Whatsapp::Providers::WhatsappCloudService < Whatsapp::Providers::BaseService + def send_message(phone_number, message) + if message.attachments.present? + send_attachment_message(phone_number, message) + else + send_text_message(phone_number, message) + end + end + + def send_template(phone_number, template_info) + response = HTTParty.post( + "#{phone_id_path}/messages", + headers: api_headers, + body: { + messaging_product: 'whatsapp', + to: phone_number, + template: template_body_parameters(template_info), + type: 'template' + }.to_json + ) + + process_response(response) + end + + def sync_templates + response = HTTParty.get("#{business_account_path}/message_templates?access_token=#{whatsapp_channel.provider_config['api_key']}") + whatsapp_channel.update(message_templates: response['data'], message_templates_last_updated: Time.now.utc) if response.success? + end + + def validate_provider_config? + response = HTTParty.get("#{business_account_path}/message_templates?access_token=#{whatsapp_channel.provider_config['api_key']}") + response.success? + end + + def api_headers + { 'Authorization' => "Bearer #{whatsapp_channel.provider_config['api_key']}", 'Content-Type' => 'application/json' } + end + + def media_url(media_id) + "https://graph.facebook.com/v13.0/#{media_id}" + end + + private + + # TODO: See if we can unify the API versions and for both paths and make it consistent with out facebook app API versions + def phone_id_path + "https://graph.facebook.com/v13.0/#{whatsapp_channel.provider_config['phone_number_id']}" + end + + def business_account_path + "https://graph.facebook.com/v14.0/#{whatsapp_channel.provider_config['business_account_id']}" + end + + def send_text_message(phone_number, message) + body = { + messaging_product: 'whatsapp', + to: phone_number, + text: { body: message.content }, + type: 'text' + } + begin + data = JSON.parse(message.content) + body = body.merge(data) + rescue + end + + response = HTTParty.post( + "#{phone_id_path}/messages", + headers: api_headers, + body: body.to_json + ) + + process_response(response) + end + + def send_attachment_message(phone_number, message) + attachment = message.attachments.first + type = %w[image audio video].include?(attachment.file_type) ? attachment.file_type : 'document' + attachment_url = attachment.download_url + type_content = { + 'link': attachment_url + } + type_content['caption'] = message.content if type != 'audio' + response = HTTParty.post( + "#{phone_id_path}/messages", + headers: api_headers, + body: { + messaging_product: 'whatsapp', + 'to' => phone_number, + 'type' => type, + type.to_s => type_content + }.to_json + ) + + process_response(response) + end + + def process_response(response) + if response.success? + response['messages'].first['id'] + else + Rails.logger.error response.body + nil + end + end + + def template_body_parameters(template_info) + { + name: template_info[:name], + language: { + policy: 'deterministic', + code: template_info[:lang_code] + }, + components: [{ + type: 'body', + parameters: template_info[:parameters] + }] + } + end +end