diff --git a/temba/channels/models.py b/temba/channels/models.py index 05ca36af726..5fd61ed6546 100644 --- a/temba/channels/models.py +++ b/temba/channels/models.py @@ -94,6 +94,7 @@ class Category(Enum): beta_only = False unique_addresses = False + matching_addresses_updates = False # the courier handling URL, will be wired automatically for use in templates, but wired to a null handler courier_url = None diff --git a/temba/channels/types/facebookapp/tests.py b/temba/channels/types/facebookapp/tests.py index dc8a71d2875..ccc101ed0a6 100644 --- a/temba/channels/types/facebookapp/tests.py +++ b/temba/channels/types/facebookapp/tests.py @@ -25,7 +25,7 @@ def setUp(self): address="12345", role="SR", schemes=["facebook"], - config={"auth_token": "09876543"}, + config={"auth_token": "09876543", "page_name": "FirstName"}, ) @override_settings(FACEBOOK_APPLICATION_ID="FB_APP_ID", FACEBOOK_APPLICATION_SECRET="FB_APP_SECRET") @@ -172,6 +172,11 @@ def test_claim_long_name(self, mock_get, mock_post): @patch("requests.post") @patch("requests.get") def test_claim_already_connected(self, mock_get, mock_post): + channel = Channel.objects.get(address="12345", channel_type="FBA") + self.assertEqual(channel.config[Channel.CONFIG_AUTH_TOKEN], "09876543") + self.assertEqual(channel.config[Channel.CONFIG_PAGE_NAME], "FirstName") + self.assertEqual(channel.name, "Facebook") + token = "x" * 200 name = "Temba" @@ -180,7 +185,7 @@ def test_claim_already_connected(self, mock_get, mock_post): MockResponse(200, json.dumps({"access_token": f"long-life-user-{token}"})), MockResponse( 200, - json.dumps({"data": [{"name": name, "id": "12345", "access_token": f"page-long-life-{token}"}]}), + json.dumps({"data": [{"name": name, "id": "12345", "access_token": f"page-long-life-update-{token}"}]}), ), ] @@ -206,7 +211,10 @@ def test_claim_already_connected(self, mock_get, mock_post): post_data["page_name"] = name response = self.client.post(url, post_data, follow=True) - self.assertContains(response, "This channel is already connected in this workspace.") + channel = Channel.objects.get(address="12345", channel_type="FBA") + self.assertEqual(channel.config[Channel.CONFIG_AUTH_TOKEN], f"page-long-life-update-{token}") + self.assertEqual(channel.config[Channel.CONFIG_PAGE_NAME], "Temba") + self.assertEqual(channel.name, "Facebook") mock_get.side_effect = [ MockResponse(200, json.dumps({"data": {"user_id": "098765", "expired_at": 100}})), @@ -246,85 +254,6 @@ def test_release(self, mock_delete): "https://graph.facebook.com/v18.0/12345/subscribed_apps", params={"access_token": "09876543"} ) - @override_settings(FACEBOOK_APPLICATION_ID="FB_APP_ID", FACEBOOK_APPLICATION_SECRET="FB_APP_SECRET") - @patch("requests.post") - @patch("requests.get") - def test_refresh_token(self, mock_get, mock_post): - token = "x" * 200 - - url = reverse("channels.types.facebookapp.refresh_token", args=(self.channel.uuid,)) - - self.login(self.admin) - - mock_post.return_value = MockResponse(200, json.dumps({"success": True})) - - mock_get.side_effect = [ - MockResponse(400, json.dumps({"error": "token invalid"})), - ] - - response = self.client.get(url) - self.assertContains(response, "Reconnect Facebook Page") - self.assertEqual(response.context["facebook_app_id"], "FB_APP_ID") - self.assertEqual(response.context["refresh_url"], url) - self.assertTrue(response.context["error_connect"]) - - mock_get.side_effect = [MockResponse(200, json.dumps({"data": {"is_valid": False}}))] - response = self.client.get(url) - self.assertContains(response, "Reconnect Facebook Page") - self.assertEqual(response.context["facebook_app_id"], "FB_APP_ID") - self.assertEqual(response.context["refresh_url"], url) - self.assertTrue(response.context["error_connect"]) - - mock_get.side_effect = [ - MockResponse(200, json.dumps({"data": {"is_valid": True}})), - MockResponse(200, json.dumps({"access_token": f"long-life-user-{token}"})), - MockResponse( - 200, - json.dumps({"data": [{"name": "Temba", "id": "12345", "access_token": f"page-long-life-{token}"}]}), - ), - ] - - response = self.client.get(url) - self.assertContains(response, "Reconnect Facebook Page") - self.assertEqual(response.context["facebook_app_id"], "FB_APP_ID") - self.assertEqual(response.context["refresh_url"], url) - self.assertFalse(response.context["error_connect"]) - - post_data = response.context["form"].initial - post_data["fb_user_id"] = "098765" - post_data["user_access_token"] = token - - response = self.client.post(url, post_data, follow=True) - - # assert our channel got created - channel = Channel.objects.get(address="12345", channel_type="FBA") - self.assertEqual(channel.config[Channel.CONFIG_AUTH_TOKEN], f"page-long-life-{token}") - self.assertEqual(channel.config[Channel.CONFIG_PAGE_NAME], "Temba") - self.assertEqual(channel.address, "12345") - - self.assertEqual(response.request["PATH_INFO"], reverse("channels.channel_read", args=[channel.uuid])) - - mock_get.assert_any_call( - "https://graph.facebook.com/oauth/access_token", - params={ - "grant_type": "fb_exchange_token", - "client_id": "FB_APP_ID", - "client_secret": "FB_APP_SECRET", - "fb_exchange_token": token, - }, - ) - mock_get.assert_any_call( - "https://graph.facebook.com/v18.0/098765/accounts", params={"access_token": f"long-life-user-{token}"} - ) - - mock_post.assert_any_call( - "https://graph.facebook.com/v18.0/12345/subscribed_apps", - data={ - "subscribed_fields": "messages,message_deliveries,messaging_optins,messaging_optouts,messaging_postbacks,message_reads,messaging_referrals,messaging_handovers" - }, - params={"access_token": f"page-long-life-{token}"}, - ) - def test_new_conversation_triggers(self): flow = self.create_flow("Test") @@ -394,3 +323,45 @@ def test_get_error_ref_url(self): "https://developers.facebook.com/docs/messenger-platform/error-codes", FacebookAppType().get_error_ref_url(None, "190"), ) + + @override_settings(FACEBOOK_APPLICATION_ID="FB_APP_ID", FACEBOOK_APPLICATION_SECRET="FB_APP_SECRET") + @patch("requests.get") + def test_check_credentials(self, mock_get): + check_credentials_url = reverse("channels.types.facebookapp.check_credentials", args=(self.channel.uuid,)) + + self.login(self.admin) + + mock_get.return_value = MockResponse(200, json.dumps({"success": True, "data": {}})) + response = self.client.get(check_credentials_url) + self.assertContains(response, "Reconnect Facebook Page") + self.assertContains( + response, "Error with token, you need to reconnect the Facebook page by clicking the button below" + ) + self.assertEqual( + response.context["update_token_url"], f"{reverse("channels.types.facebookapp.claim")}?update=1" + ) + self.assertFalse(response.context["valid_token"]) + + mock_get.return_value = MockResponse(200, json.dumps({"success": True, "data": {"is_valid": True}})) + + response = self.client.get(check_credentials_url) + self.assertContains(response, "Reconnect Facebook Page") + self.assertContains(response, "Everything looks good. No need to reconnect") + self.assertEqual( + response.context["update_token_url"], f"{reverse("channels.types.facebookapp.claim")}?update=1" + ) + self.assertTrue(response.context["valid_token"]) + + @override_settings(FACEBOOK_APPLICATION_ID="FB_APP_ID", FACEBOOK_APPLICATION_SECRET="FB_APP_SECRET") + @patch("requests.get") + def test_type_check_credentials(self, mock_get): + self.assertFalse(FacebookAppType().check_credentials({})) + + mock_get.return_value = MockResponse(200, json.dumps({"success": True, "data": {}})) + self.assertFalse(FacebookAppType().check_credentials({Channel.CONFIG_AUTH_TOKEN: "Token"})) + + mock_get.return_value = MockResponse(400, json.dumps({"error": True})) + self.assertFalse(FacebookAppType().check_credentials({Channel.CONFIG_AUTH_TOKEN: "Token"})) + + mock_get.return_value = MockResponse(200, json.dumps({"success": True, "data": {"is_valid": True}})) + self.assertTrue(FacebookAppType().check_credentials({Channel.CONFIG_AUTH_TOKEN: "Token"})) diff --git a/temba/channels/types/facebookapp/type.py b/temba/channels/types/facebookapp/type.py index 2a27aba83f0..398ae5011f2 100644 --- a/temba/channels/types/facebookapp/type.py +++ b/temba/channels/types/facebookapp/type.py @@ -8,7 +8,7 @@ from temba.triggers.models import Trigger from ...models import Channel, ChannelType -from .views import ClaimView, RefreshToken +from .views import CheckCredentials, ClaimView class FacebookAppType(ChannelType): @@ -21,6 +21,7 @@ class FacebookAppType(ChannelType): category = ChannelType.Category.SOCIAL_MEDIA unique_addresses = True + matching_addresses_updates = True courier_url = r"^fba/receive" schemes = [URN.FACEBOOK_SCHEME] @@ -32,13 +33,15 @@ class FacebookAppType(ChannelType): ) % {"link": 'Facebook'} claim_view = ClaimView - menu_items = [dict(label=_("Reconnect Facebook Page"), view_name="channels.types.facebookapp.refresh_token")] + menu_items = [dict(label=_("Check Credentials"), view_name="channels.types.facebookapp.check_credentials")] def get_urls(self): return [ self.get_claim_url(), re_path( - r"^(?P[a-z0-9\-]+)/refresh_token/$", RefreshToken.as_view(channel_type=self), name="refresh_token" + r"^(?P[a-z0-9\-]+)/check_credentials/$", + CheckCredentials.as_view(channel_type=self), + name="check_credentials", ), ] @@ -87,3 +90,21 @@ def get_redact_values(self, channel) -> tuple: # pragma: needs cover def get_error_ref_url(self, channel, code: str) -> str: return "https://developers.facebook.com/docs/messenger-platform/error-codes" + + def check_credentials(self, config: dict) -> bool: + app_id = settings.FACEBOOK_APPLICATION_ID + app_secret = settings.FACEBOOK_APPLICATION_SECRET + url = "https://graph.facebook.com/v18.0/debug_token" + + if Channel.CONFIG_AUTH_TOKEN not in config: + return False + + params = { + "access_token": f"{app_id}|{app_secret}", + "input_token": config[Channel.CONFIG_AUTH_TOKEN], + } + resp = requests.get(url, params=params) + + if resp.status_code == 200: + return resp.json().get("data", dict()).get("is_valid", False) + return False diff --git a/temba/channels/types/facebookapp/views.py b/temba/channels/types/facebookapp/views.py index 30628074bcb..4bdcbc61a43 100644 --- a/temba/channels/types/facebookapp/views.py +++ b/temba/channels/types/facebookapp/views.py @@ -1,12 +1,12 @@ import requests -from smartmin.views import SmartFormView, SmartModelActionView +from smartmin.views import SmartFormView from django import forms from django.conf import settings from django.urls import reverse from django.utils.translation import gettext_lazy as _ -from temba.orgs.views.mixins import OrgObjPermsMixin +from temba.orgs.views.base import BaseReadView from temba.utils.text import truncate from ...models import Channel @@ -117,6 +117,7 @@ def get_context_data(self, **kwargs): claim_error = context["form"].errors["__all__"][0] context["claim_error"] = claim_error + context["update_existing"] = self.request.GET.get("update") == "1" return context def form_valid(self, form): @@ -129,127 +130,35 @@ def form_valid(self, form): Channel.CONFIG_PAGE_NAME: name, } - self.object = Channel.create( - self.request.org, self.request.user, None, self.channel_type, name=name, address=page_id, config=config - ) + existing_channel = Channel.objects.filter( + org=self.request.org, address=page_id, channel_type=self.channel_type.code + ).first() + if existing_channel: + existing_channel.config = config + existing_channel.save() + self.object = existing_channel + else: + self.object = Channel.create( + self.request.org, self.request.user, None, self.channel_type, name=name, address=page_id, config=config + ) return super().form_valid(form) -class RefreshToken(ChannelTypeMixin, OrgObjPermsMixin, SmartModelActionView, SmartFormView): - class Form(forms.Form): - user_access_token = forms.CharField(min_length=32, required=True, help_text=_("The User Access Token")) - fb_user_id = forms.CharField( - required=True, help_text=_("The Facebook User ID of the admin that connected the channel") - ) - +class CheckCredentials(ChannelTypeMixin, BaseReadView): slug_url_kwarg = "uuid" - success_url = "uuid@channels.channel_read" - form_class = Form permission = "channels.channel_claim" fields = () - template_name = "channels/types/facebookapp/refresh_token.html" - title = _("Reconnect Facebook Page") - menu_path = "/settings/workspace" + template_name = "channels/types/facebookapp/check_credentials.html" def derive_menu_path(self): return f"/settings/channels/{self.get_object().uuid}" - def get_context_data(self, **kwargs): - context = super().get_context_data(**kwargs) - context["refresh_url"] = reverse("channels.types.facebookapp.refresh_token", args=(self.object.uuid,)) - - app_id = settings.FACEBOOK_APPLICATION_ID - app_secret = settings.FACEBOOK_APPLICATION_SECRET - - context["facebook_app_id"] = app_id - - context["facebook_login_messenger_config_id"] = settings.FACEBOOK_LOGIN_MESSENGER_CONFIG_ID - - url = "https://graph.facebook.com/v18.0/debug_token" - params = { - "access_token": f"{app_id}|{app_secret}", - "input_token": self.object.config[Channel.CONFIG_AUTH_TOKEN], - } - resp = requests.get(url, params=params) - - error_connect = False - if resp.status_code != 200: - error_connect = True - else: - valid_token = resp.json().get("data", dict()).get("is_valid", False) - if not valid_token: - error_connect = True - - context["error_connect"] = error_connect - - return context - def get_queryset(self): return self.request.org.channels.filter(is_active=True, channel_type=self.channel_type.code) - def execute_action(self): - form = self.form - channel = self.object - - auth_token = form.data["user_access_token"] - fb_user_id = form.data["fb_user_id"] - - page_id = channel.address - - app_id = settings.FACEBOOK_APPLICATION_ID - app_secret = settings.FACEBOOK_APPLICATION_SECRET - - # get user long lived access token - url = "https://graph.facebook.com/oauth/access_token" - params = { - "grant_type": "fb_exchange_token", - "client_id": app_id, - "client_secret": app_secret, - "fb_exchange_token": auth_token, - } - - response = requests.get(url, params=params) - - if response.status_code != 200: # pragma: no cover - raise Exception("Failed to get a user long lived token") - - long_lived_auth_token = response.json().get("access_token", "") - - if long_lived_auth_token == "": # pragma: no cover - raise Exception("Empty user access token!") - - url = f"https://graph.facebook.com/v18.0/{fb_user_id}/accounts" - params = {"access_token": long_lived_auth_token} - - response = requests.get(url, params=params) - - if response.status_code != 200: # pragma: no cover - raise Exception("Failed to get a page long lived token") - - response_json = response.json() - - page_access_token = "" - for elt in response_json["data"]: - if elt["id"] == str(page_id): - page_access_token = elt["access_token"] - name = elt["name"] - break - - if page_access_token == "": # pragma: no cover - raise Exception("Empty page access token!") - - url = f"https://graph.facebook.com/v18.0/{page_id}/subscribed_apps" - params = {"access_token": page_access_token} - data = { - "subscribed_fields": "messages,message_deliveries,messaging_optins,messaging_optouts,messaging_postbacks,message_reads,messaging_referrals,messaging_handovers" - } - - response = requests.post(url, data=data, params=params) - - if response.status_code != 200: # pragma: no cover - raise Exception("Failed to subscribe to app for webhook events") - - channel.config[Channel.CONFIG_AUTH_TOKEN] = page_access_token - channel.config[Channel.CONFIG_PAGE_NAME] = name - channel.save(update_fields=["config"]) + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + context["update_token_url"] = f"{reverse("channels.types.facebookapp.claim")}?update=1" + context["valid_token"] = self.object.type.check_credentials(self.object.config) + return context diff --git a/temba/channels/types/instagram/tests.py b/temba/channels/types/instagram/tests.py index 6d5f332ff2b..58210db95a4 100644 --- a/temba/channels/types/instagram/tests.py +++ b/temba/channels/types/instagram/tests.py @@ -26,7 +26,7 @@ def setUp(self): address="019283", role="SR", schemes=["instagram"], - config={"auth_token": "09876543", "page_id": "123456"}, + config={"auth_token": "09876543", "page_name": "FirstName", "page_id": "123456"}, ) @override_settings(FACEBOOK_APPLICATION_ID="FB_APP_ID", FACEBOOK_APPLICATION_SECRET="FB_APP_SECRET") @@ -206,6 +206,12 @@ def test_claim_long_name(self, mock_get, mock_post): @patch("requests.post") @patch("requests.get") def test_claim_already_connected(self, mock_get, mock_post): + channel = Channel.objects.get(address="019283", channel_type="IG") + self.assertEqual(channel.config[Channel.CONFIG_AUTH_TOKEN], "09876543") + self.assertEqual(channel.config[Channel.CONFIG_PAGE_NAME], "FirstName") + self.assertEqual(channel.config["page_id"], "123456") + self.assertEqual(channel.name, "Instagram") + name = "Temba" mock_get.side_effect = [ @@ -219,7 +225,7 @@ def test_claim_already_connected(self, mock_get, mock_post): { "name": name, "id": "123456", - "access_token": self.long_life_page_token, + "access_token": self.long_life_page_token + "-updated", } ] } @@ -253,7 +259,11 @@ def test_claim_already_connected(self, mock_get, mock_post): post_data["page_name"] = name response = self.client.post(url, post_data, follow=True) - self.assertContains(response, "This channel is already connected in this workspace.") + channel = Channel.objects.get(address="019283", channel_type="IG") + self.assertEqual(channel.config[Channel.CONFIG_AUTH_TOKEN], self.long_life_page_token + "-updated") + self.assertEqual(channel.config[Channel.CONFIG_PAGE_NAME], "Temba") + self.assertEqual(channel.config["page_id"], 123456) + self.assertEqual(channel.name, "Instagram") mock_get.side_effect = [ MockResponse(200, json.dumps({"data": {"user_id": "098765", "expired_at": 100}})), @@ -341,100 +351,47 @@ def test_release(self, mock_delete): params={"access_token": "09876543"}, ) + def test_get_error_ref_url(self): + self.assertEqual( + "https://developers.facebook.com/docs/instagram-api/reference/error-codes", + InstagramType().get_error_ref_url(None, "36000"), + ) + @override_settings(FACEBOOK_APPLICATION_ID="FB_APP_ID", FACEBOOK_APPLICATION_SECRET="FB_APP_SECRET") - @patch("requests.post") @patch("requests.get") - def test_refresh_token(self, mock_get, mock_post): - token = "x" * 200 - - url = reverse("channels.types.instagram.refresh_token", args=(self.channel.uuid,)) + def test_check_credentials(self, mock_get): + check_credentials_url = reverse("channels.types.instagram.check_credentials", args=(self.channel.uuid,)) self.login(self.admin) - mock_post.return_value = MockResponse(200, json.dumps({"success": True})) - - mock_get.side_effect = [ - MockResponse(400, json.dumps({"error": "token invalid"})), - ] - - response = self.client.get(url) + mock_get.return_value = MockResponse(200, json.dumps({"success": True, "data": {}})) + response = self.client.get(check_credentials_url) self.assertContains(response, "Reconnect Instagram Business Account") - self.assertEqual(response.context["facebook_app_id"], "FB_APP_ID") - self.assertEqual(response.context["refresh_url"], url) - self.assertTrue(response.context["error_connect"]) - - mock_get.side_effect = [MockResponse(200, json.dumps({"data": {"is_valid": False}}))] - response = self.client.get(url) - self.assertContains(response, "Reconnect Instagram Business Account") - self.assertEqual(response.context["facebook_app_id"], "FB_APP_ID") - self.assertEqual(response.context["refresh_url"], url) - self.assertTrue(response.context["error_connect"]) + self.assertContains( + response, + "Error with token, you need to reconnect the Instagram Business Account by clicking the button below", + ) + self.assertEqual(response.context["update_token_url"], f"{reverse("channels.types.instagram.claim")}?update=1") + self.assertFalse(response.context["valid_token"]) - mock_get.side_effect = [ - MockResponse(200, json.dumps({"data": {"is_valid": True}})), - MockResponse(200, json.dumps({"access_token": self.long_life_page_token})), - MockResponse( - 200, - json.dumps( - { - "data": [ - { - "name": "Temba", - "id": "123456", - "access_token": self.long_life_page_token, - } - ] - } - ), - ), - ] + mock_get.return_value = MockResponse(200, json.dumps({"success": True, "data": {"is_valid": True}})) - response = self.client.get(url) + response = self.client.get(check_credentials_url) self.assertContains(response, "Reconnect Instagram Business Account") - self.assertEqual(response.context["facebook_app_id"], "FB_APP_ID") - self.assertEqual(response.context["refresh_url"], url) - self.assertFalse(response.context["error_connect"]) - - post_data = response.context["form"].initial - post_data["fb_user_id"] = "098765" - post_data["user_access_token"] = token - - response = self.client.post(url, post_data, follow=True) - - # assert our channel got created - channel = Channel.objects.get(address="019283", channel_type="IG") - self.assertEqual(channel.config[Channel.CONFIG_AUTH_TOKEN], self.long_life_page_token) - self.assertEqual(channel.config[Channel.CONFIG_PAGE_NAME], "Temba") - self.assertEqual(channel.address, "019283") - - self.assertEqual( - response.request["PATH_INFO"], - reverse("channels.channel_read", args=[channel.uuid]), - ) + self.assertContains(response, "Everything looks good. No need to reconnect") + self.assertEqual(response.context["update_token_url"], f"{reverse("channels.types.instagram.claim")}?update=1") + self.assertTrue(response.context["valid_token"]) - mock_get.assert_any_call( - "https://graph.facebook.com/oauth/access_token", - params={ - "grant_type": "fb_exchange_token", - "client_id": "FB_APP_ID", - "client_secret": "FB_APP_SECRET", - "fb_exchange_token": self.token, - }, - ) + @override_settings(FACEBOOK_APPLICATION_ID="FB_APP_ID", FACEBOOK_APPLICATION_SECRET="FB_APP_SECRET") + @patch("requests.get") + def test_type_check_credentials(self, mock_get): + self.assertFalse(InstagramType().check_credentials({})) - mock_get.assert_any_call( - "https://graph.facebook.com/v18.0/098765/accounts", - params={"access_token": self.long_life_page_token}, - ) + mock_get.return_value = MockResponse(200, json.dumps({"success": True, "data": {}})) + self.assertFalse(InstagramType().check_credentials({Channel.CONFIG_AUTH_TOKEN: "Token"})) - mock_post.assert_any_call( - "https://graph.facebook.com/v18.0/123456/subscribed_apps", - data={"subscribed_fields": "messages,messaging_postbacks"}, - params={"access_token": self.long_life_page_token}, - ) + mock_get.return_value = MockResponse(400, json.dumps({"error": True})) + self.assertFalse(InstagramType().check_credentials({Channel.CONFIG_AUTH_TOKEN: "Token"})) - def test_get_error_ref_url(self): - self.assertEqual( - "https://developers.facebook.com/docs/instagram-api/reference/error-codes", - InstagramType().get_error_ref_url(None, "36000"), - ) + mock_get.return_value = MockResponse(200, json.dumps({"success": True, "data": {"is_valid": True}})) + self.assertTrue(InstagramType().check_credentials({Channel.CONFIG_AUTH_TOKEN: "Token"})) diff --git a/temba/channels/types/instagram/type.py b/temba/channels/types/instagram/type.py index a36a2e73110..e7e132528ea 100644 --- a/temba/channels/types/instagram/type.py +++ b/temba/channels/types/instagram/type.py @@ -7,7 +7,7 @@ from temba.contacts.models import URN from ...models import Channel, ChannelType -from .views import ClaimView, RefreshToken +from .views import CheckCredentials, ClaimView class InstagramType(ChannelType): @@ -20,6 +20,7 @@ class InstagramType(ChannelType): category = ChannelType.Category.SOCIAL_MEDIA unique_addresses = True + matching_addresses_updates = True courier_url = r"^ig/receive" schemes = [URN.INSTAGRAM_SCHEME] @@ -29,15 +30,15 @@ class InstagramType(ChannelType): } claim_view = ClaimView - menu_items = [dict(label=_("Reconnect Business Account"), view_name="channels.types.instagram.refresh_token")] + menu_items = [dict(label=_("Reconnect Business Account"), view_name="channels.types.instagram.check_credentials")] def get_urls(self): return [ self.get_claim_url(), re_path( - r"^(?P[a-z0-9\-]+)/refresh_token/$", - RefreshToken.as_view(channel_type=self), - name="refresh_token", + r"^(?P[a-z0-9\-]+)/check_credentials/$", + CheckCredentials.as_view(channel_type=self), + name="check_credentials", ), ] @@ -56,3 +57,21 @@ def get_redact_values(self, channel) -> tuple: # pragma: needs cover def get_error_ref_url(self, channel, code: str) -> str: return "https://developers.facebook.com/docs/instagram-api/reference/error-codes" + + def check_credentials(self, config: dict) -> bool: + app_id = settings.FACEBOOK_APPLICATION_ID + app_secret = settings.FACEBOOK_APPLICATION_SECRET + url = "https://graph.facebook.com/v18.0/debug_token" + + if Channel.CONFIG_AUTH_TOKEN not in config: + return False + + params = { + "access_token": f"{app_id}|{app_secret}", + "input_token": config[Channel.CONFIG_AUTH_TOKEN], + } + resp = requests.get(url, params=params) + + if resp.status_code == 200: + return resp.json().get("data", dict()).get("is_valid", False) + return False diff --git a/temba/channels/types/instagram/views.py b/temba/channels/types/instagram/views.py index 0d943692cc2..66f87ffe4f8 100644 --- a/temba/channels/types/instagram/views.py +++ b/temba/channels/types/instagram/views.py @@ -1,7 +1,7 @@ import logging import requests -from smartmin.views import SmartFormView, SmartModelActionView +from smartmin.views import SmartFormView, SmartReadView from django import forms from django.conf import settings @@ -140,6 +140,8 @@ def get_context_data(self, **kwargs): if context["form"].errors: claim_error = context["form"].errors["__all__"][0] context["claim_error"] = claim_error + + context["update_existing"] = self.request.GET.get("update") == "1" return context def form_valid(self, form): @@ -154,142 +156,41 @@ def form_valid(self, form): "page_id": page_id, } - self.object = Channel.create( - self.request.org, - self.request.user, - None, - self.channel_type, - name=name, - address=ig_user_id, - config=config, - ) + existing_channel = Channel.objects.filter( + org=self.request.org, address=ig_user_id, channel_type=self.channel_type.code + ).first() + if existing_channel: + existing_channel.config = config + existing_channel.save(update_fields=("name", "config")) + self.object = existing_channel + else: + self.object = Channel.create( + self.request.org, + self.request.user, + None, + self.channel_type, + name=name, + address=ig_user_id, + config=config, + ) return super().form_valid(form) -class RefreshToken(ChannelTypeMixin, OrgObjPermsMixin, SmartModelActionView, SmartFormView): - class Form(forms.Form): - user_access_token = forms.CharField(min_length=32, required=True, help_text=_("The User Access Token")) - fb_user_id = forms.CharField( - required=True, - help_text=_("The Facebook User ID of the admin that connected the channel"), - ) - +class CheckCredentials(ChannelTypeMixin, OrgObjPermsMixin, SmartReadView): slug_url_kwarg = "uuid" - success_url = "uuid@channels.channel_read" - form_class = Form permission = "channels.channel_claim" fields = () - template_name = "channels/types/instagram/refresh_token.html" - title = _("Reconnect Instagram Business Account") + template_name = "channels/types/instagram/check_credentials.html" def derive_menu_path(self): return f"/settings/channels/{self.get_object().uuid}" - def get_context_data(self, **kwargs): - context = super().get_context_data(**kwargs) - context["refresh_url"] = reverse("channels.types.instagram.refresh_token", args=(self.object.uuid,)) - - app_id = settings.FACEBOOK_APPLICATION_ID - app_secret = settings.FACEBOOK_APPLICATION_SECRET - - context["facebook_app_id"] = app_id - - context["facebook_login_instagram_config_id"] = settings.FACEBOOK_LOGIN_INSTAGRAM_CONFIG_ID - - url = "https://graph.facebook.com/v18.0/debug_token" - params = { - "access_token": f"{app_id}|{app_secret}", - "input_token": self.object.config[Channel.CONFIG_AUTH_TOKEN], - } - resp = requests.get(url, params=params) - - error_connect = False - if resp.status_code != 200: - error_connect = True - else: - valid_token = resp.json().get("data", dict()).get("is_valid", False) - if not valid_token: - error_connect = True - - context["error_connect"] = error_connect - - return context - def get_queryset(self): return self.request.org.channels.filter(is_active=True, channel_type=self.channel_type.code) - def execute_action(self): - form = self.form - channel = self.object - - auth_token = form.data["user_access_token"] - fb_user_id = form.data["fb_user_id"] - - page_id = channel.config.get("page_id") - - if page_id is None: - raise Exception("Failed to get channel page ID") # pragma: needs cover - - app_id = settings.FACEBOOK_APPLICATION_ID - app_secret = settings.FACEBOOK_APPLICATION_SECRET - - # get user long lived access token - url = "https://graph.facebook.com/oauth/access_token" - params = { - "grant_type": "fb_exchange_token", - "client_id": app_id, - "client_secret": app_secret, - "fb_exchange_token": auth_token, - } - - response = requests.get(url, params=params) - - if response.status_code != 200: # pragma: no cover - raise Exception("Failed to get a user long lived token") - - long_lived_auth_token = response.json().get("access_token", "") - - if long_lived_auth_token == "": # pragma: no cover - raise Exception("Empty user access token!") - - url = f"https://graph.facebook.com/v18.0/{fb_user_id}/accounts" - params = {"access_token": long_lived_auth_token} - - page_access_token = "" - - while True: - response = requests.get(url, params=params) - response_json = response.json() - - if response.status_code != 200: # pragma: no cover - raise Exception("Failed to get a page long lived token") - - for page in response_json.get("data", []): - if page["id"] == str(page_id): - page_access_token = page["access_token"] - name = page["name"] - break - - if page_access_token != "": - break - - next_ = response_json["paging"].get("next", None) # pragma: needs cover - if next_: # pragma: needs cover - url = next_ - - else: # pragma: needs cover - break - - url = f"https://graph.facebook.com/v18.0/{page_id}/subscribed_apps" - params = {"access_token": page_access_token} - data = {"subscribed_fields": "messages,messaging_postbacks"} - - response = requests.post(url, data=data, params=params) - - if response.status_code != 200: # pragma: no cover - raise Exception("Failed to subscribe to app for webhook events") - - channel.config[Channel.CONFIG_AUTH_TOKEN] = page_access_token - channel.config[Channel.CONFIG_PAGE_NAME] = name - channel.save(update_fields=["config"]) + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + context["update_token_url"] = f"{reverse("channels.types.instagram.claim")}?update=1" + context["valid_token"] = self.object.type.check_credentials(self.object.config) + return context diff --git a/temba/channels/views.py b/temba/channels/views.py index 3a1373855b0..cf96db34097 100644 --- a/temba/channels/views.py +++ b/temba/channels/views.py @@ -98,9 +98,11 @@ def clean(self): schemes__overlap=list(self.channel_type.schemes), ).first() if existing: - if existing.org == self.request.org: + if existing.org != self.request.org: + raise forms.ValidationError(_("This channel is already connected in another workspace.")) + + if not self.channel_type.matching_addresses_updates: raise forms.ValidationError(_("This channel is already connected in this workspace.")) - raise forms.ValidationError(_("This channel is already connected in another workspace.")) return super().clean() diff --git a/templates/channels/types/facebookapp/check_credentials.html b/templates/channels/types/facebookapp/check_credentials.html new file mode 100644 index 00000000000..c2a264f53b4 --- /dev/null +++ b/templates/channels/types/facebookapp/check_credentials.html @@ -0,0 +1,18 @@ +{% extends "smartmin/read.html" %} +{% load smartmin temba humanize channels i18n tz %} + +{% block content %} +
+

+ {% if not valid_token %} + {% trans "Error with token, you need to reconnect the Facebook page by clicking the button below" %} + {% else %} + {% trans "Everything looks good. No need to reconnect" %} + {% endif %} +

+
+
{% trans "Go Back" %}
+ {% trans "Reconnect Facebook Page" %} +
+
+{% endblock content %} diff --git a/templates/channels/types/facebookapp/claim.html b/templates/channels/types/facebookapp/claim.html index 5d1993cc720..28a92d64ba7 100644 --- a/templates/channels/types/facebookapp/claim.html +++ b/templates/channels/types/facebookapp/claim.html @@ -2,9 +2,15 @@ {% load i18n compress temba %} {% block pre-form %} - {% blocktrans trimmed with name=branding.name %} - You can connect your Facebook page to {{ name }} in just a few simple steps. - {% endblocktrans %} + {% if update_existing %} + {% blocktrans trimmed %} + Your existing channel has invalid credentials, you need to go through the steps below again to get the credentials updated. + {% endblocktrans %} + {% else %} + {% blocktrans trimmed with name=branding.name %} + You can connect your Facebook page to {{ name }} in just a few simple steps. + {% endblocktrans %} + {% endif %} {% if claim_error %} {{ claim_error }} @@ -23,7 +29,13 @@
-
{% trans "Add Facebook Page" %}
+
+ {% if update_existing %} + {% trans "Connect Facebook Page" %} + {% else %} + {% trans "Add Facebook Page" %} + {% endif %} +
-{% endblock form %} -{% block extra-script %} - {{ block.super }} - - - -{% endblock extra-script %} diff --git a/templates/channels/types/instagram/check_credentials.html b/templates/channels/types/instagram/check_credentials.html new file mode 100644 index 00000000000..89bafe7ab21 --- /dev/null +++ b/templates/channels/types/instagram/check_credentials.html @@ -0,0 +1,18 @@ +{% extends "smartmin/read.html" %} +{% load smartmin temba humanize channels i18n tz %} + +{% block content %} +
+

+ {% if not valid_token %} + {% trans "Error with token, you need to reconnect the Instagram Business Account by clicking the button below" %} + {% else %} + {% trans "Everything looks good. No need to reconnect" %} + {% endif %} +

+ +
+{% endblock content %} diff --git a/templates/channels/types/instagram/claim.html b/templates/channels/types/instagram/claim.html index 9f84fb0fa0e..9f017b5cedd 100644 --- a/templates/channels/types/instagram/claim.html +++ b/templates/channels/types/instagram/claim.html @@ -2,9 +2,15 @@ {% load i18n compress temba %} {% block pre-form %} - {% blocktrans trimmed with name=branding.name %} - You can connect your Instagram business account to {{ name }} in just a few simple steps. - {% endblocktrans %} + {% if update_existing %} + {% blocktrans trimmed %} + Your existing channel has invalid credentials, you need to go through the steps below again to get the credentials updated. + {% endblocktrans %} + {% else %} + {% blocktrans trimmed with name=branding.name %} + You can connect your Instagram business account to {{ name }} in just a few simple steps. + {% endblocktrans %} + {% endif %} {% if claim_error %} {{ claim_error }} @@ -28,7 +34,13 @@
-
{% trans "Add Instagram Business Account" %}
+
+ {% if update_existing %} + {% trans "Connect Instagram Business Account" %} + {% else %} + {% trans "Add Instagram Business Account" %} + {% endif %} +
-{% endblock form %} -{% block extra-script %} - {{ block.super }} - - - -{% endblock extra-script %}