Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

add discord integration #480

Open
wants to merge 4 commits into
base: develop
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .github/workflows/django.yml
Original file line number Diff line number Diff line change
Expand Up @@ -38,4 +38,10 @@ jobs:
MEMCACHEDCLOUD_PASSWORD: " "
GITCOIN_PASSPORT_SCORER_ID: ${{ secrets.GITCOIN_PASSPORT_SCORER_ID }}
GITCOIN_PASSPORT_API_KEY: ${{ secrets.GITCOIN_PASSPORT_API_KEY }}
DISCORD_CLIENT_ID: ${{ secrets.DISCORD_CLIENT_ID }}
DISCORD_CLIENT_SECRET: ${{ secrets.DISCORD_CLIENT_SECRET }}
DISCORD_REDIRECT_URI: "http://your-domain.com/api/discord/callback"
DISCORD_AUTH_URL: "https://discord.com/api/oauth2/authorize"
DISCORD_TOKEN_URL: "https://discord.com/api/oauth2/token"
DISCORD_API_URL: "https://discord.com/api"
DEPLOYMENT_ENV: "dev"
27 changes: 27 additions & 0 deletions authentication/migrations/0038_discordconnection.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
# Generated by Django 4.0.4 on 2024-06-24 12:04

from django.db import migrations, models
import django.db.models.deletion


class Migration(migrations.Migration):

dependencies = [
('authentication', '0037_alter_wallet_wallet_type'),
]

operations = [
migrations.CreateModel(
name='DiscordConnection',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('created_at', models.DateTimeField(auto_now_add=True, null=True)),
('access_token', models.CharField(blank=True, max_length=255, null=True)),
('refresh_token', models.CharField(blank=True, max_length=255, null=True)),
('user_profile', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='%(class)s', to='authentication.userprofile')),
],
options={
'abstract': False,
},
),
]
12 changes: 12 additions & 0 deletions authentication/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
from authentication.thirdpartydrivers import (
BaseThirdPartyDriver,
BrightIDConnectionDriver,
DiscordDriver,
ENSDriver,
GitcoinPassportDriver,
TwitterDriver,
Expand Down Expand Up @@ -250,3 +251,14 @@ def name(self):

def is_connected(self):
return bool(self.name)


class DiscordConnection(BaseThirdPartyConnection):
title = "Discord"
access_token = models.CharField(max_length=255, null=True, blank=True)
refresh_token = models.CharField(max_length=255, null=True, blank=True)

driver = DiscordDriver()

def is_connected(self):
return bool(self.access_token and self.refresh_token)
71 changes: 71 additions & 0 deletions authentication/tests.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,11 +20,13 @@
from rest_framework.test import APITestCase

from authentication.models import (
DiscordConnection,
ENSConnection,
GitcoinPassportConnection,
UserProfile,
Wallet,
)
from core.thirdpartyapp.discord import DiscordUtils
from faucet.models import ClaimReceipt

# get address as username and signed address as password and verify signature
Expand Down Expand Up @@ -745,3 +747,72 @@ def test_ens_disconnect_successful(self):
).count(),
0,
)


class TestDiscordConnection(APITestCase):
def setUp(self):
self.user = create_new_user()

def test_discord_connection_creation(self):
connection = DiscordConnection.objects.create(
user_profile=self.user,
access_token="test_access_token",
refresh_token="test_refresh_token",
)
self.assertEqual(DiscordConnection.objects.count(), 1)
self.assertEqual(connection.user_profile, self.user)
self.assertEqual(connection.access_token, "test_access_token")
self.assertEqual(connection.refresh_token, "test_refresh_token")

def test_discord_connection_update(self):
connection = DiscordConnection.objects.create(
user_profile=self.user,
access_token="old_access_token",
refresh_token="old_refresh_token",
)
connection.access_token = "new_access_token"
connection.refresh_token = "new_refresh_token"
connection.save()

updated_connection = DiscordConnection.objects.get(user_profile=self.user)
self.assertEqual(updated_connection.access_token, "new_access_token")
self.assertEqual(updated_connection.refresh_token, "new_refresh_token")


class TestDiscordUtils(APITestCase):
@patch("core.thirdpartyapp.discord.DiscordUtils.get_authorization_url")
def test_get_authorization_url(self, mock_get_auth_url):
mock_get_auth_url.return_value = "https://discord.com/api/oauth2/\
authorize?client_id=123&redirect_uri=http://localhost:8000/callback\
&response_type=code&scope=identify%20guilds"
url = DiscordUtils.get_authorization_url()
self.assertIn("client_id", url)
self.assertIn("redirect_uri", url)
self.assertIn("response_type=code", url)
self.assertIn("scope=identify%20guilds", url)

@patch("core.thirdpartyapp.discord.DiscordUtils.get_tokens")
def test_get_tokens(self, mock_get_tokens):
mock_get_tokens.return_value = ("access_token", "refresh_token")
access_token, refresh_token = DiscordUtils.get_tokens("test_code")
self.assertEqual(access_token, "access_token")
self.assertEqual(refresh_token, "refresh_token")

@patch("core.thirdpartyapp.discord.DiscordUtils.get_user_info")
def test_get_user_info(self, mock_get_user_info):
mock_user_info = {
"id": "12345",
"username": "testuser",
"discriminator": "1234",
"avatar": "avatar_hash",
}
mock_get_user_info.return_value = mock_user_info
user_info = DiscordUtils.get_user_info("test_access_token")
self.assertEqual(user_info, mock_user_info)

@patch("core.thirdpartyapp.discord.DiscordUtils.get_user_guilds")
def test_get_user_guilds(self, mock_get_user_guilds):
mock_guilds = [{"id": "1", "name": "Guild 1"}, {"id": "2", "name": "Guild 2"}]
mock_get_user_guilds.return_value = mock_guilds
guilds = DiscordUtils.get_user_guilds("test_access_token")
self.assertEqual(guilds, mock_guilds)
1 change: 1 addition & 0 deletions authentication/thirdpartydrivers/__init__.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
from .abstract import BaseThirdPartyDriver
from .bright_id import BrightIDConnectionDriver
from .discord import DiscordDriver
from .ens import ENSDriver
from .gitcoin_passport import GitcoinPassportDriver
from .twitter import TwitterDriver
10 changes: 10 additions & 0 deletions authentication/thirdpartydrivers/discord.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
from authentication.thirdpartydrivers.abstract import BaseThirdPartyDriver
from core.thirdpartyapp import DiscordUtils


class DiscordDriver(BaseThirdPartyDriver):
def get_user_guilds(self, access_token: str) -> None | dict:
return DiscordUtils.get_user_guilds(access_token=access_token)

def get_user_info(self, access_token: str) -> None | dict:
return DiscordUtils.get_user_info(access_token=access_token)
8 changes: 8 additions & 0 deletions authentication/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@
CheckUserExistsView,
CheckUsernameView,
ConnectBrightIDView,
DiscordOAuthCallbackView,
DiscordOAuthView,
ENSConnectionView,
ENSDisconnectionView,
GetProfileView,
Expand Down Expand Up @@ -93,4 +95,10 @@
ENSDisconnectionView.as_view(),
name="disconnect-ens",
),
path("discord/", DiscordOAuthView.as_view(), name="discord-oauth"),
path(
"discord/callback/",
DiscordOAuthCallbackView.as_view(),
name="discord-oauth-callback",
),
]
56 changes: 55 additions & 1 deletion authentication/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@
)
from authentication.models import (
BrightIDConnection,
DiscordConnection,
ENSConnection,
GitcoinPassportSaveError,
TwitterConnection,
Expand All @@ -51,7 +52,7 @@
thirdparty_connection_serializer,
)
from core.filters import IsOwnerFilterBackend
from core.thirdpartyapp import TwitterUtils
from core.thirdpartyapp import DiscordUtils, TwitterUtils


class UserProfileCountView(ListAPIView):
Expand Down Expand Up @@ -693,3 +694,56 @@ def get(self, request, *args, **kwargs):
twitter_connection.save(update_fields=("access_token", "access_token_secret"))

return Response({}, HTTP_200_OK)


class DiscordOAuthView(APIView):
permission_classes = [IsAuthenticated]

def get_user_profile(self):
return self.request.user.profile

def get(self, request, *args, **kwargs):
try:
url = DiscordUtils.get_authorization_url()
except Exception as e:
logging.error(f"Could not connect to Discord: {e}")
raise APIException("Discord did not respond")

try:
discord_connection = DiscordConnection.objects.get(
user_profile=self.get_user_profile()
)
discord_connection.access_token = None
discord_connection.refresh_token = None
discord_connection.save(update_fields=("access_token", "refresh_token"))
except DiscordConnection.DoesNotExist:
discord_connection = DiscordConnection(user_profile=self.get_user_profile())
discord_connection.save()

return Response({"url": url}, status=HTTP_200_OK)


class DiscordOAuthCallbackView(APIView):
def get(self, request, *args, **kwargs):
code = request.query_params.get("code")
if code is None:
raise ParseError("You must provide an authorization code")

try:
access_token, refresh_token = DiscordUtils.get_tokens(code)
except Exception as e:
logging.error(f"Could not connect to Discord: {e}")
raise ParseError("Could not connect to Discord")

try:
discord_connection = DiscordConnection.objects.get(
user_profile__user=request.user
)
except DiscordConnection.DoesNotExist:
raise ParseError("DiscordConnection not found")

discord_connection.access_token = access_token
discord_connection.refresh_token = refresh_token
discord_connection.save(update_fields=("access_token", "refresh_token"))

return Response({}, HTTP_200_OK)
1 change: 1 addition & 0 deletions core/thirdpartyapp/__init__.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
from .discord import DiscordUtils
from .EAS import EASUtils
from .ens import ENSUtil # noqa: F401
from .farcaster import FarcasterUtil # noqa: F401
Expand Down
52 changes: 52 additions & 0 deletions core/thirdpartyapp/discord.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import os
import urllib.parse

import requests

DISCORD_CLIENT_ID = os.getenv("DISCORD_CLIENT_ID")
DISCORD_CLIENT_SECRET = os.getenv("DISCORD_CLIENT_SECRET")
DISCORD_REDIRECT_URI = os.getenv("DISCORD_REDIRECT_URI")
DISCORD_AUTH_URL = os.getenv("DISCORD_AUTH_URL")
DISCORD_TOKEN_URL = os.getenv("DISCORD_TOKEN_URL")
DISCORD_API_URL = os.getenv("DISCORD_API_URL")


class DiscordUtils:
@staticmethod
def get_authorization_url():
params = {
"client_id": DISCORD_CLIENT_ID,
"redirect_uri": DISCORD_REDIRECT_URI,
"response_type": "code",
"scope": "identify guilds",
}
return f"{DISCORD_AUTH_URL}?{urllib.parse.urlencode(params)}"

@staticmethod
def get_tokens(code):
data = {
"client_id": DISCORD_CLIENT_ID,
"client_secret": DISCORD_CLIENT_SECRET,
"grant_type": "authorization_code",
"code": code,
"redirect_uri": DISCORD_REDIRECT_URI,
}
headers = {"Content-Type": "application/x-www-form-urlencoded"}
r = requests.post(DISCORD_TOKEN_URL, data=data, headers=headers)
r.raise_for_status()
tokens = r.json()
return tokens["access_token"], tokens["refresh_token"]

@staticmethod
def get_user_info(access_token):
headers = {"Authorization": f"Bearer {access_token}"}
r = requests.get(f"{DISCORD_API_URL}/users/@me", headers=headers)
r.raise_for_status()
return r.json()

@staticmethod
def get_user_guilds(access_token):
headers = {"Authorization": f"Bearer {access_token}"}
r = requests.get(f"{DISCORD_API_URL}/users/@me/guilds", headers=headers)
r.raise_for_status()
return r.json()
Loading