From a3bf5e723acf01c0146e8dbde61530374f096485 Mon Sep 17 00:00:00 2001 From: Alexander Metzger Date: Sun, 25 Feb 2024 19:51:18 -0800 Subject: [PATCH] functional --- ...ation_wa_business_access_token_and_more.py | 43 +++++++ bots/models.py | 38 +++++- bots/tasks.py | 1 + daras_ai_v2/facebook_bots.py | 37 +++--- daras_ai_v2/settings.py | 2 + recipes/VideoBots.py | 10 +- routers/facebook_api.py | 109 +++++++++++++++++- 7 files changed, 222 insertions(+), 18 deletions(-) create mode 100644 bots/migrations/0060_botintegration_wa_business_access_token_and_more.py diff --git a/bots/migrations/0060_botintegration_wa_business_access_token_and_more.py b/bots/migrations/0060_botintegration_wa_business_access_token_and_more.py new file mode 100644 index 000000000..d259e050f --- /dev/null +++ b/bots/migrations/0060_botintegration_wa_business_access_token_and_more.py @@ -0,0 +1,43 @@ +# Generated by Django 4.2.7 on 2024-02-22 22:10 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('bots', '0059_savedrun_is_api_call'), + ] + + operations = [ + migrations.AddField( + model_name='botintegration', + name='wa_business_access_token', + field=models.TextField(blank=True, default=None, help_text="Bot's WhatsApp Business access token (mandatory) -- has these scopes: ['whatsapp_business_management', 'whatsapp_business_messaging', 'public_profile']", null=True), + ), + migrations.AddField( + model_name='botintegration', + name='wa_business_account_name', + field=models.TextField(blank=True, default='', help_text="Bot's WhatsApp Business API account name (only for display)"), + ), + migrations.AddField( + model_name='botintegration', + name='wa_business_message_template_namespace', + field=models.TextField(blank=True, default='', help_text="Bot's WhatsApp Business API message template namespace"), + ), + migrations.AddField( + model_name='botintegration', + name='wa_business_name', + field=models.TextField(blank=True, default='', help_text="Bot's WhatsApp Business API name (only for display)"), + ), + migrations.AddField( + model_name='botintegration', + name='wa_business_user_id', + field=models.TextField(blank=True, default=None, help_text="Bot's WhatsApp Business API user id (mandatory)", null=True), + ), + migrations.AddField( + model_name='botintegration', + name='wa_business_waba_id', + field=models.TextField(blank=True, default=None, help_text="Bot's WhatsApp Business API WABA id (mandatory) -- this is the one seen on https://business.facebook.com/settings/whatsapp-business-accounts/", null=True), + ), + ] diff --git a/bots/models.py b/bots/models.py index 3a440ab73..a872ed7e2 100644 --- a/bots/models.py +++ b/bots/models.py @@ -519,6 +519,40 @@ class BotIntegration(models.Model): help_text="Bot's WhatsApp phone number id (mandatory)", ) + wa_business_access_token = models.TextField( + blank=True, + default=None, + null=True, + help_text="Bot's WhatsApp Business access token (mandatory) -- has these scopes: ['whatsapp_business_management', 'whatsapp_business_messaging', 'public_profile']", + ) + wa_business_waba_id = models.TextField( + blank=True, + default=None, + null=True, + help_text="Bot's WhatsApp Business API WABA id (mandatory) -- this is the one seen on https://business.facebook.com/settings/whatsapp-business-accounts/", + ) + wa_business_user_id = models.TextField( + blank=True, + default=None, + null=True, + help_text="Bot's WhatsApp Business API user id (mandatory)", + ) + wa_business_name = models.TextField( + blank=True, + default="", + help_text="Bot's WhatsApp Business API name (only for display)", + ) + wa_business_account_name = models.TextField( + blank=True, + default="", + help_text="Bot's WhatsApp Business API account name (only for display)", + ) + wa_business_message_template_namespace = models.TextField( + blank=True, + default="", + help_text="Bot's WhatsApp Business API message template namespace", + ) + slack_team_id = models.CharField( max_length=256, blank=True, @@ -935,7 +969,9 @@ def to_df_format( else None ), # only show first feedback as per Sean's request "Analysis JSON": message.analysis_result, - "Run Time": message.saved_run.run_time if message.saved_run else 0, # user messages have no run/run_time + "Run Time": ( + message.saved_run.run_time if message.saved_run else 0 + ), # user messages have no run/run_time } rows.append(row) df = pd.DataFrame.from_records( diff --git a/bots/tasks.py b/bots/tasks.py index a6c3cebe8..3978b429d 100644 --- a/bots/tasks.py +++ b/bots/tasks.py @@ -117,6 +117,7 @@ def send_broadcast_msg( documents=documents, bot_number=bi.wa_phone_number_id, user_number=convo.wa_phone_number.as_e164, + access_token=bi.wa_business_access_token, ) case Platform.SLACK: msg_id = SlackBot.send_msg_to( diff --git a/daras_ai_v2/facebook_bots.py b/daras_ai_v2/facebook_bots.py index abf33b8a0..83373adf8 100644 --- a/daras_ai_v2/facebook_bots.py +++ b/daras_ai_v2/facebook_bots.py @@ -11,9 +11,9 @@ WA_MSG_MAX_SIZE = 1024 -WHATSAPP_AUTH_HEADER = { - "Authorization": f"Bearer {settings.WHATSAPP_ACCESS_TOKEN}", -} + +def get_wa_auth_header(access_token: str | None = None): + return {"Authorization": f"Bearer {access_token or settings.WHATSAPP_ACCESS_TOKEN}"} class WhatsappBot(BotInterface): @@ -29,6 +29,7 @@ def __init__(self, message: dict, metadata: dict): self.input_type = message["type"] bi = BotIntegration.objects.get(wa_phone_number_id=self.bot_id) + self.access_token = bi.wa_business_access_token self.convo = Conversation.objects.get_or_create( bot_integration=bi, wa_phone_number="+" + self.user_id, @@ -54,7 +55,7 @@ def get_input_audio(self) -> str | None: except KeyError: return None # download file from whatsapp - data, mime_type = retrieve_wa_media_by_id(media_id) + data, mime_type = retrieve_wa_media_by_id(media_id, self.access_token) data, _ = audio_bytes_to_wav(data) mime_type = "audio/wav" # upload file to firebase @@ -80,7 +81,7 @@ def get_input_documents(self) -> list[str] | None: def _download_wa_media(self, media_id: str) -> str: # download file from whatsapp - data, mime_type = retrieve_wa_media_by_id(media_id) + data, mime_type = retrieve_wa_media_by_id(media_id, self.access_token) # upload file to firebase return upload_file_from_bytes( filename=self.nice_filename(mime_type), @@ -116,10 +117,13 @@ def send_msg( video=video, documents=documents, buttons=buttons, + access_token=self.access_token, ) def mark_read(self): - wa_mark_read(self.bot_id, self.input_message["id"]) + wa_mark_read( + self.bot_id, self.input_message["id"], access_token=self.access_token + ) @classmethod def send_msg_to( @@ -133,6 +137,7 @@ def send_msg_to( ## whatsapp specific bot_number: str, user_number: str, + access_token: str | None = None, ) -> str | None: # see https://developers.facebook.com/docs/whatsapp/api/messages/media/ @@ -158,6 +163,7 @@ def send_msg_to( } for doc in splits[:-1] ], + access_token=access_token, ) messages = [] @@ -239,21 +245,24 @@ def send_msg_to( bot_number=bot_number, user_number=user_number, messages=messages, + access_token=access_token, ) -def retrieve_wa_media_by_id(media_id: str) -> (bytes, str): +def retrieve_wa_media_by_id( + media_id: str, access_token: str | None = None +) -> (bytes, str): # get media info r1 = requests.get( f"https://graph.facebook.com/v16.0/{media_id}/", - headers=WHATSAPP_AUTH_HEADER, + headers=get_wa_auth_header(access_token), ) raise_for_status(r1) media_info = r1.json() # download media r2 = requests.get( media_info["url"], - headers=WHATSAPP_AUTH_HEADER, + headers=get_wa_auth_header(access_token), ) raise_for_status(r2) content = r2.content @@ -280,13 +289,15 @@ def _build_msg_buttons(buttons: list[ReplyButton], msg: dict) -> dict: } -def send_wa_msgs_raw(*, bot_number, user_number, messages: list) -> str | None: +def send_wa_msgs_raw( + *, bot_number, user_number, messages: list, access_token: str | None = None +) -> str | None: msg_id = None for msg in messages: print(f"send_wa_msgs_raw: {msg=}") r = requests.post( f"https://graph.facebook.com/v16.0/{bot_number}/messages", - headers=WHATSAPP_AUTH_HEADER, + headers=get_wa_auth_header(access_token), json={ "messaging_product": "whatsapp", "to": user_number, @@ -304,11 +315,11 @@ def send_wa_msgs_raw(*, bot_number, user_number, messages: list) -> str | None: return msg_id -def wa_mark_read(bot_number: str, message_id: str): +def wa_mark_read(bot_number: str, message_id: str, access_token: str | None = None): # send read receipt r = requests.post( f"https://graph.facebook.com/v16.0/{bot_number}/messages", - headers=WHATSAPP_AUTH_HEADER, + headers=get_wa_auth_header(access_token), json={ "messaging_product": "whatsapp", "status": "read", diff --git a/daras_ai_v2/settings.py b/daras_ai_v2/settings.py index 512fb3834..545bce2aa 100644 --- a/daras_ai_v2/settings.py +++ b/daras_ai_v2/settings.py @@ -280,6 +280,8 @@ FB_APP_ID = config("FB_APP_ID", "") FB_APP_SECRET = config("FB_APP_SECRET", "") FB_WEBHOOK_TOKEN = config("FB_WEBHOOK_TOKEN", "") +FB_WHATSAPP_CONFIG_ID = config("FB_WHATSAPP_CONFIG_ID", "") +WHATSAPP_2FA_PIN = config("WHATSAPP_2FA_PIN", "190604") WHATSAPP_ACCESS_TOKEN = config("WHATSAPP_ACCESS_TOKEN", None) SLACK_VERIFICATION_TOKEN = config("SLACK_VERIFICATION_TOKEN", "") SLACK_CLIENT_ID = config("SLACK_CLIENT_ID", "") diff --git a/recipes/VideoBots.py b/recipes/VideoBots.py index 2d0bc26e0..58b747a64 100644 --- a/recipes/VideoBots.py +++ b/recipes/VideoBots.py @@ -979,7 +979,7 @@ def render_selected_tab(self, selected_tab): show_landbot_widget() def messenger_bot_integration(self): - from routers.facebook_api import ig_connect_url, fb_connect_url + from routers.facebook_api import ig_connect_url, fb_connect_url, wa_connect_url from routers.slack_api import slack_connect_url from recipes.VideoBotsStats import VideoBotsStatsPage @@ -1018,6 +1018,14 @@ def messenger_bot_integration(self): ℹ️ +
+ + +   + Add Your Whatsapp Number + +
+

To connect a phone number, make sure it is not reserved for some other use on Whatsapp or connected to a different Whatsapp account. If your business needs exceed the capacity of a free Whatsapp account and/or you don't want to manage the Whatsapp business yourself, contact us for a quote on a managed Whatsapp number through Gooey.

""", unsafe_allow_html=True, ) diff --git a/routers/facebook_api.py b/routers/facebook_api.py index af409c8fd..e910211d6 100644 --- a/routers/facebook_api.py +++ b/routers/facebook_api.py @@ -7,7 +7,7 @@ from starlette.requests import Request from starlette.responses import HTMLResponse, Response -from bots.models import BotIntegration +from bots.models import BotIntegration, Platform from daras_ai_v2 import settings, db from daras_ai_v2.bots import _on_msg, request_json from daras_ai_v2.exceptions import raise_for_status @@ -17,6 +17,91 @@ router = APIRouter() +@router.get("/__/fb/connect_whatsapp/") +def fb_connect_whatsapp_redirect(request: Request): + if not request.user or request.user.is_anonymous: + redirect_url = furl("/login", query_params={"next": request.url}) + return RedirectResponse(str(redirect_url)) + + retry_button = f'Retry' + + code = request.query_params.get("code") + if not code: + return HTMLResponse( + f"

Oh No! Something went wrong here.

Error: {dict(request.query_params)}

" + + retry_button, + status_code=400, + ) + user_access_token = _get_access_token_from_code(code, wa_connect_redirect_url) + + # get WABA ID + r = requests.get( + f"https://graph.facebook.com/v19.0/debug_token?input_token={user_access_token}&access_token={settings.FB_APP_ID}|{settings.FB_APP_SECRET}", + # headers={"Authorization": f"Bearer {settings.WHATSAPP_ACCESS_TOKEN}"}, + ) + r.raise_for_status() + # {'data': {'app_id': 'XXXX', 'type': 'SYSTEM_USER', 'application': 'gooey.ai', 'data_access_expires_at': 0, 'expires_at': 0, 'is_valid': True, 'issued_at': 1708634537, 'scopes': ['whatsapp_business_management', 'whatsapp_business_messaging', 'public_profile'], 'granular_scopes': [{'scope': 'whatsapp_business_management', 'target_ids': ['XXXX']}, {'scope': 'whatsapp_business_messaging', 'target_ids': ['XXXX']}], 'user_id': 'XXXX'}} + waba_obj = r.json()["data"] + user_id = waba_obj["user_id"] + waba_id = waba_obj["granular_scopes"][0]["target_ids"][0] + + # get WABA details + r = requests.get( + f"https://graph.facebook.com/v19.0/{waba_id}?access_token={user_access_token}", + ) + r.raise_for_status() + # {'id': 'XXXX', 'name': 'Test', 'timezone_id': '1', 'message_template_namespace': 'XXXX'} + waba_details = r.json() + account_name = waba_details["name"] + message_template_namespace = waba_details["message_template_namespace"] + + # get WABA phone numbers + r = requests.get( + f"https://graph.facebook.com/v19.0/{waba_id}/phone_numbers?access_token={user_access_token}", + ) + r.raise_for_status() + # {'data': [{'verified_name': 'XXXX', 'code_verification_status': 'VERIFIED', 'display_phone_number': 'XXXX', 'quality_rating': 'UNKNOWN', 'platform_type': 'NOT_APPLICABLE', 'throughput': {'level': 'NOT_APPLICABLE'}, 'last_onboarded_time': '2024-02-22T20:42:16+0000', 'id': 'XXXX'}], 'paging': {'cursors': {'before': 'XXXX', 'after': 'XXXX'}}} + phone_numbers = r.json()["data"] + + for phone_number in phone_numbers: + business_name = phone_number["verified_name"] + display_phone_number = phone_number["display_phone_number"] + phone_number_id = phone_number["id"] + + options = dict( + billing_account_uid=request.user.uid, + platform=Platform.WHATSAPP, + name=f"{business_name} - {account_name}", + wa_phone_number=display_phone_number, + wa_business_access_token=user_access_token, + wa_business_waba_id=waba_id, + wa_business_user_id=user_id, + wa_business_name=business_name, + wa_business_account_name=account_name, + wa_business_message_template_namespace=message_template_namespace, + ) + bi, created = BotIntegration.objects.get_or_create( + wa_phone_number_id=phone_number_id, defaults=options + ) + if not created: + BotIntegration.objects.filter(id=bi.id).update(**options) + else: + # register the phone number for Whatsapp + r = requests.post( + f"https://graph.facebook.com/v19.0/{phone_number_id}/register?access_token={user_access_token}", + json={ + "messaging_product": "whatsapp", + "pin": settings.WHATSAPP_2FA_PIN, + }, + ) + print(r.json()) + r.raise_for_status() + + return HTMLResponse( + f"Sucessfully Connected to whatsapp! You may now close this page." + ) + + @router.get("/__/fb/connect/") def fb_connect_redirect(request: Request): if not request.user or request.user.is_anonymous: @@ -116,6 +201,24 @@ def fb_webhook( return Response("OK") +wa_connect_redirect_url = str( + furl(settings.APP_BASE_URL) + / router.url_path_for(fb_connect_whatsapp_redirect.__name__) +) + +wa_connect_url = str( + furl( + "https://www.facebook.com/v18.0/dialog/oauth", + query_params={ + "client_id": settings.FB_APP_ID, + "display": "page", + "redirect_uri": wa_connect_redirect_url, + "response_type": "code", + "config_id": settings.FB_WHATSAPP_CONFIG_ID, + }, + ) +) + fb_connect_redirect_url = str( furl(settings.APP_BASE_URL) / router.url_path_for(fb_connect_redirect.__name__) ) @@ -162,12 +265,12 @@ def fb_webhook( ) -def _get_access_token_from_code(code: str) -> str: +def _get_access_token_from_code(code: str, redirect_uri: str | None = None) -> str: r = requests.get( "https://graph.facebook.com/v15.0/oauth/access_token", params={ "client_id": settings.FB_APP_ID, - "redirect_uri": fb_connect_redirect_url, + "redirect_uri": redirect_uri or fb_connect_redirect_url, "client_secret": settings.FB_APP_SECRET, "code": code, },