Skip to content

Commit

Permalink
Support updating existing channels for Facebook and Instagram, remove…
Browse files Browse the repository at this point in the history
… old refresh token views
  • Loading branch information
norkans7 committed Nov 26, 2024
1 parent 6671985 commit 499274e
Show file tree
Hide file tree
Showing 11 changed files with 78 additions and 624 deletions.
1 change: 1 addition & 0 deletions temba/channels/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
93 changes: 11 additions & 82 deletions temba/channels/types/facebookapp/tests.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down Expand Up @@ -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"

Expand All @@ -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}"}]}),
),
]

Expand All @@ -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}})),
Expand Down Expand Up @@ -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")

Expand Down
16 changes: 5 additions & 11 deletions temba/channels/types/facebookapp/type.py
Original file line number Diff line number Diff line change
@@ -1,14 +1,13 @@
import requests

from django.conf import settings
from django.urls import re_path
from django.utils.translation import gettext_lazy as _

from temba.contacts.models import URN
from temba.triggers.models import Trigger

from ...models import Channel, ChannelType
from .views import ClaimView, RefreshToken
from .views import ClaimView


class FacebookAppType(ChannelType):
Expand All @@ -21,6 +20,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]
Expand All @@ -32,15 +32,9 @@ class FacebookAppType(ChannelType):
) % {"link": '<a target="_blank" href="http://facebook.com">Facebook</a>'}
claim_view = ClaimView

menu_items = [dict(label=_("Reconnect Facebook Page"), view_name="channels.types.facebookapp.refresh_token")]

def get_urls(self):
return [
self.get_claim_url(),
re_path(
r"^(?P<uuid>[a-z0-9\-]+)/refresh_token/$", RefreshToken.as_view(channel_type=self), name="refresh_token"
),
]
menu_items = [
dict(label=_("Reconnect Facebook Page"), view_name="channels.types.facebookapp.claim", obj_view=False)
]

def deactivate(self, channel):
config = channel.config
Expand Down
138 changes: 13 additions & 125 deletions temba/channels/types/facebookapp/views.py
Original file line number Diff line number Diff line change
@@ -1,16 +1,15 @@
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.utils.text import truncate

from ...models import Channel
from ...views import ChannelTypeMixin, ClaimViewMixin
from ...views import ClaimViewMixin


class ClaimView(ClaimViewMixin, SmartFormView):
Expand Down Expand Up @@ -129,127 +128,16 @@ 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
)

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")
)

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"

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
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:
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)
self.object = Channel.create(
self.request.org, self.request.user, None, self.channel_type, name=name, address=page_id, config=config
)

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"])
return super().form_valid(form)
Loading

0 comments on commit 499274e

Please sign in to comment.