Skip to content

Commit

Permalink
Merge pull request #60 from Gurubase/task/discord-slack-bots
Browse files Browse the repository at this point in the history
Task/discord slack bots
  • Loading branch information
fatihbaltaci authored Jan 29, 2025
2 parents 4b9efe4 + a9d1867 commit c789fce
Show file tree
Hide file tree
Showing 41 changed files with 972 additions and 363 deletions.
11 changes: 11 additions & 0 deletions src/gurubase-backend/.vscode/launch.json
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,17 @@
"django_celery_beat.schedulers:DatabaseScheduler",
],
"justMyCode": false,
},
{
"name": "Discord Listener",
"type": "debugpy",
"request": "launch",
"program": "${workspaceFolder}/backend/manage.py",
"args": [
"discordListener"
],
"django": true,
"justMyCode": false,
}
]
}
20 changes: 9 additions & 11 deletions src/gurubase-backend/INTEGRATION_README.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,10 @@
6. Then, pick the appropriate scopes and permissions
- Identify
- Bot
- Send Messages

![alt text](discord-bot-permissions.png)
![alt text](discord-bot-permissions-2.png)
7. Select your redirect url
- `${frontend_url}/OAuth`
8. Pick appropriate bot permissions
Expand Down Expand Up @@ -83,24 +87,18 @@
- `channels.join`
- `channels:read`
- `chat:write`
- `groups:history`
- `groups:read`
- `im:history`
- `im:read`
- `mpim:read`
- User
- `channels:history`
- `channels:read`
- `chat:write`
- `groups:history`
- `groups:read`
- `im:history`

These are the final and minimal scopes
![Slack Bot Permissions](slack-bot-permissions.png)
10. Then, go to Event Subscriptions
11. Enable it
12. Set this as request url:
- `${backend_url}/slack/events`
13. Go to Event Subscriptions and add these as Subscribe to bot events:
- message.channels
- `message.channels`
- `message.groups`
14. Go to Manage Distribution
15. Click and confirm "Remove Hard Coded Information"
16. Activate Public Distribution
Expand Down
6 changes: 3 additions & 3 deletions src/gurubase-backend/backend/core/admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -93,9 +93,9 @@ def sitemap_reason_link(self, obj):
return obj.sitemap_reason

def link(self, obj):
if obj.guru_type:
return format_html(f'<a href="{settings.BASE_URL}/g/{obj.guru_type.slug}/{obj.slug}" target="_blank">{obj.slug}</a>')
return ""
if not obj.guru_type:
return ""
return format_html(f'<a href="{obj.frontend_url}" target="_blank">{obj.slug}</a>')

def binge_link(self, obj):
if obj.binge:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,8 @@ def format_question_response(question_obj):
'date_updated': format_date_updated(question_obj.date_updated),
'trust_score': format_trust_score(question_obj.trust_score),
'references': format_references(question_obj.references, api=True),
'session_id': question_obj.binge.id if question_obj.binge else None
'session_id': question_obj.binge.id if question_obj.binge else None,
'question_url': question_obj.frontend_url
}

@staticmethod
Expand Down
13 changes: 10 additions & 3 deletions src/gurubase-backend/backend/core/integrations.py
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,7 @@ def exchange_token(self, code: str) -> dict:
}
response = requests.post(token_url, data=data)
if not response.ok:
logger.error(f"Discord API error: {response.text}")
raise ValueError(f"Discord API error: {response.text}")
return response.json()

Expand Down Expand Up @@ -164,7 +165,7 @@ def _list_channels() -> list:
for c in channels
if c['type'] == 0 # 0 is text channel
]
return text_channels
return sorted(text_channels, key=lambda x: x['name'])

return self.handle_api_call(_list_channels)

Expand Down Expand Up @@ -249,7 +250,11 @@ def _list_channels() -> list:
cursor = None

while True:
params = {'limit': 100}
params = {
'limit': 100,
'types': 'public_channel,private_channel', # Include both public and private channels
'exclude_archived': True
}
if cursor:
params['cursor'] = cursor

Expand All @@ -262,6 +267,7 @@ def _list_channels() -> list:
data = response.json()

if not data.get('ok', False):
logger.error(f"Slack API error: {data}")
raise ValueError(f"Slack API error: {data.get('error')}")

channels.extend([
Expand All @@ -277,7 +283,7 @@ def _list_channels() -> list:
if not cursor:
break

return channels
return sorted(channels, key=lambda x: x['name'])

return self.handle_api_call(_list_channels)

Expand Down Expand Up @@ -312,6 +318,7 @@ def _revoke_token() -> None:
response_data = response.json()

if not response_data.get('ok', False):
logger.error(f"Slack API error: {response_data}")
raise ValueError(f"Slack API error: {response_data.get('error')}")

return self.handle_api_call(_revoke_token)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,10 @@ def format_response(self, response):
metadata_length += len("\n**References**:")
for ref in response['references']:
metadata_length += len(f"\n• [{ref['title']}](<{ref['link']}>)")

# Add space for frontend link
metadata_length += len("\n\nView on <span class=\"guru-text\">Guru</span>base for a better UX: ")
metadata_length += 100 # Approximate length for the URL

# Calculate max length for content to stay within Discord's 2000 char limit
max_content_length = 1900 - metadata_length # Leave some buffer
Expand All @@ -79,6 +83,10 @@ def format_response(self, response):
formatted_msg.append("\n**References**:")
for ref in response['references']:
formatted_msg.append(f"\n• [{ref['title']}](<{ref['link']}>)")

# Add frontend link if question_url is present
if response.get('question_url'):
formatted_msg.append(f"\n[View on Gurubase for a better UX]({response['question_url']})")

return "\n".join(formatted_msg)

Expand Down Expand Up @@ -215,6 +223,10 @@ async def on_message(message):
try:
# Check if the current channel is allowed
channel_id = str(message.channel.id)
# If message is from a thread, get the parent channel id
if isinstance(message.channel, discord.Thread):
channel_id = str(message.channel.parent_id)

channels = await sync_to_async(lambda: integration.channels)()
channel_allowed = False
for channel in channels:
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
# Generated by Django 4.2.13 on 2025-01-27 07:41

from django.db import migrations


class Migration(migrations.Migration):

dependencies = [
('core', '0022_merge_0021_apikey_name_0021_merge_20250120_0950'),
('core', '0028_alter_integration_unique_together'),
]

operations = [
]
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
# Generated by Django 4.2.13 on 2025-01-28 12:27

from django.db import migrations


class Migration(migrations.Migration):

dependencies = [
('core', '0029_merge_20250126_1945'),
('core', '0029_merge_20250127_0741'),
]

operations = [
]
13 changes: 13 additions & 0 deletions src/gurubase-backend/backend/core/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,19 @@ class Source(models.TextChoices):
user = models.ForeignKey(User, on_delete=models.SET_NULL, null=True, blank=True)
times = models.JSONField(default=dict, blank=True, null=False)

@property
def frontend_url(self):
"""Returns the frontend URL for this question."""
from django.conf import settings
if not self.guru_type:
return ""

if self.binge:
root_slug = self.binge.root_question.slug if self.binge.root_question else self.slug
return f"{settings.BASE_URL}/g/{self.guru_type.slug}/{root_slug}/binge/{self.binge.id}?question_slug={self.slug}"

return f"{settings.BASE_URL}/g/{self.guru_type.slug}/{self.slug}"

def __str__(self):
return f"{self.id} - {self.slug}"

Expand Down
104 changes: 103 additions & 1 deletion src/gurubase-backend/backend/core/signals.py
Original file line number Diff line number Diff line change
Expand Up @@ -815,7 +815,109 @@ def create_api_key_for_integration(sender, instance, **kwargs):
)
instance.api_key = api_key

@receiver(pre_save, sender=Integration)
def manage_slack_channels(sender, instance, **kwargs):
"""Manage Slack channel membership when channels are updated."""
if instance.type != Integration.Type.SLACK:
return

try:
# Get the old instance if it exists
if instance.id:
old_instance = Integration.objects.get(id=instance.id)
old_channels = {
channel['id']: channel.get('allowed', False)
for channel in old_instance.channels
}
else:
old_channels = {}

new_channels = {
channel['id']: channel.get('allowed', False)
for channel in instance.channels
}

# Skip if no changes to channels
if old_channels == new_channels:
return

from slack_sdk import WebClient
client = WebClient(token=instance.access_token)

# Leave channels that are no longer allowed
channels_to_leave = [
channel_id for channel_id, was_allowed in old_channels.items()
if was_allowed and (
channel_id not in new_channels or # Channel removed
not new_channels[channel_id] # Channel no longer allowed
)
]

# Join newly allowed channels
channels_to_join = [
channel_id for channel_id, is_allowed in new_channels.items()
if is_allowed and (
channel_id not in old_channels or # New channel
not old_channels[channel_id] # Previously not allowed
)
]

# Leave channels
for channel_id in channels_to_leave:
try:
client.conversations_leave(channel=channel_id)
except Exception as e:
logger.warning(f"Failed to leave Slack channel {channel_id}: {e}", exc_info=True)

# Join channels
for channel_id in channels_to_join:
try:
client.conversations_join(channel=channel_id)
except Exception as e:
logger.warning(f"Failed to join Slack channel {channel_id}: {e}", exc_info=True)

except Integration.DoesNotExist:
pass # This is a new integration
except Exception as e:
logger.warning(f"Failed to manage Slack channels for integration {instance.id}: {e}", exc_info=True)

@receiver(pre_delete, sender=Integration)
def delete_api_key_for_integration(sender, instance, **kwargs):
def handle_integration_deletion(sender, instance, **kwargs):
"""
Wrapper signal to handle all cleanup operations when an integration is deleted.
Order of operations:
1. Platform-specific cleanup (Discord: leave guild, Slack: leave channels)
2. Revoke access token
3. Delete API key
"""
# Step 1: Platform-specific cleanup
if instance.type == Integration.Type.DISCORD:
try:
def leave_guild():
headers = {
'Authorization': f'Bot {settings.DISCORD_BOT_TOKEN}'
}
guild_id = instance.external_id
url = f'https://discord.com/api/v10/users/@me/guilds/{guild_id}'

response = requests.delete(url, headers=headers)
if response.status not in [200, 204]:
response_data = response.json()
logger.warning(f"Failed to leave Discord guild {guild_id}: {response_data}")

leave_guild()
except Exception as e:
logger.warning(f"Failed to leave Discord guild for integration {instance.id}: {e}", exc_info=True)


# Step 2: Revoke access token
try:
from .integrations import IntegrationFactory
strategy = IntegrationFactory.get_strategy(instance.type, instance)
strategy.revoke_access_token()
except Exception as e:
logger.warning(f"Failed to revoke access token for integration {instance.id}: {e}", exc_info=True)
# Continue with deletion even if token revocation fails

if instance.api_key:
instance.api_key.delete()
Loading

0 comments on commit c789fce

Please sign in to comment.