diff --git a/backend/apps/ai/common/constants.py b/backend/apps/ai/common/constants.py index cce67fc739..47a0b4f2b5 100644 --- a/backend/apps/ai/common/constants.py +++ b/backend/apps/ai/common/constants.py @@ -5,3 +5,4 @@ DEFAULT_SIMILARITY_THRESHOLD = 0.4 DELIMITER = "\n\n" MIN_REQUEST_INTERVAL_SECONDS = 1.2 +QUEUE_RESPONSE_TIME_MINUTES = 1 diff --git a/backend/apps/slack/MANIFEST.yaml b/backend/apps/slack/MANIFEST.yaml index 05a75c86eb..d6617e94b3 100644 --- a/backend/apps/slack/MANIFEST.yaml +++ b/backend/apps/slack/MANIFEST.yaml @@ -95,6 +95,11 @@ features: description: OWASP users list usage_hint: should_escape: false + - command: /ai + url: https://nest.owasp.org/integrations/slack/commands/ + description: AI-powered OWASP Nest assistant + usage_hint: + should_escape: false oauth_config: scopes: user: @@ -103,6 +108,7 @@ oauth_config: - mpim:read - users:read bot: + - app_mentions:read - channels:read - chat:write - commands @@ -115,6 +121,7 @@ oauth_config: - users:read - groups:write - channels:manage + - channels:history settings: event_subscriptions: request_url: https://nest.owasp.org/integrations/slack/events/ @@ -123,7 +130,9 @@ settings: - team_join bot_events: - app_home_opened + - app_mention - member_joined_channel + - message.channels - team_join interactivity: is_enabled: true diff --git a/backend/apps/slack/admin/conversation.py b/backend/apps/slack/admin/conversation.py index 2e0d946147..935cb05b9d 100644 --- a/backend/apps/slack/admin/conversation.py +++ b/backend/apps/slack/admin/conversation.py @@ -27,6 +27,7 @@ class ConversationAdmin(admin.ModelAdmin): "is_private", "is_archived", "is_general", + "is_nest_bot_assistant_enabled", ) }, ), diff --git a/backend/apps/slack/apps.py b/backend/apps/slack/apps.py index be6e6e5ba2..422b72ba25 100644 --- a/backend/apps/slack/apps.py +++ b/backend/apps/slack/apps.py @@ -25,6 +25,13 @@ class SlackConfig(AppConfig): else None ) + def ready(self): + """Configure Slack events when the app is ready.""" + super().ready() + from apps.slack.events import configure_slack_events + + configure_slack_events() + if SlackConfig.app: diff --git a/backend/apps/slack/commands/__init__.py b/backend/apps/slack/commands/__init__.py index 46592a7c4b..e2c5154a6a 100644 --- a/backend/apps/slack/commands/__init__.py +++ b/backend/apps/slack/commands/__init__.py @@ -2,6 +2,7 @@ from apps.slack.commands.command import CommandBase from . import ( + ai, board, chapters, committees, diff --git a/backend/apps/slack/commands/ai.py b/backend/apps/slack/commands/ai.py new file mode 100644 index 0000000000..255b84f498 --- /dev/null +++ b/backend/apps/slack/commands/ai.py @@ -0,0 +1,23 @@ +"""Slack bot AI command.""" + +from apps.slack.commands.command import CommandBase + + +class Ai(CommandBase): + """Slack bot /ai command.""" + + def render_blocks(self, command: dict): + """Get the rendered blocks. + + Args: + command (dict): The Slack command payload. + + Returns: + list: A list of Slack blocks representing the AI response. + + """ + from apps.slack.common.handlers.ai import get_blocks + + return get_blocks( + query=command["text"].strip(), + ) diff --git a/backend/apps/slack/common/handlers/ai.py b/backend/apps/slack/common/handlers/ai.py new file mode 100644 index 0000000000..ef0452e7b8 --- /dev/null +++ b/backend/apps/slack/common/handlers/ai.py @@ -0,0 +1,61 @@ +"""Handler for AI-powered Slack functionality.""" + +from __future__ import annotations + +import logging + +from apps.ai.agent.tools.rag.rag_tool import RagTool +from apps.slack.blocks import markdown + +logger = logging.getLogger(__name__) + + +def get_blocks(query: str) -> list[dict]: + """Get AI response blocks. + + Args: + query (str): The user's question. + presentation (EntityPresentation | None): Configuration for entity presentation. + + Returns: + list: A list of Slack blocks representing the AI response. + + """ + ai_response = process_ai_query(query.strip()) + + if ai_response: + return [markdown(ai_response)] + return get_error_blocks() + + +def process_ai_query(query: str) -> str | None: + """Process the AI query using the RAG tool. + + Args: + query (str): The user's question. + + Returns: + str | None: The AI response or None if error occurred. + + """ + rag_tool = RagTool( + chat_model="gpt-4o", + embedding_model="text-embedding-3-small", + ) + + return rag_tool.query(question=query) + + +def get_error_blocks() -> list[dict]: + """Get error response blocks. + + Returns: + list: A list of Slack blocks with error message. + + """ + return [ + markdown( + "⚠️ Unfortunately, I'm unable to answer your question at this time.\n" + "Please try again later or contact support if the issue persists." + ) + ] diff --git a/backend/apps/slack/common/question_detector.py b/backend/apps/slack/common/question_detector.py new file mode 100644 index 0000000000..5ca94cb50f --- /dev/null +++ b/backend/apps/slack/common/question_detector.py @@ -0,0 +1,57 @@ +"""Question detection utilities for Slack OWASP bot.""" + +from __future__ import annotations + +import logging +import re + +from apps.slack.constants import OWASP_KEYWORDS + +logger = logging.getLogger(__name__) + + +class QuestionDetector: + """Utility class for detecting OWASP-related questions.""" + + def __init__(self): + """Initialize the question detector.""" + self.owasp_keywords = OWASP_KEYWORDS + + self.question_patterns = [ + r"\?", + r"^(what|how|why|when|where|which|who|can|could|would|should|is|are|does|do|did)", + r"(help|explain|tell me|show me|guide|tutorial|example)", + r"(recommend|suggest|advice|opinion)", + ] + + self.compiled_patterns = [ + re.compile(pattern, re.IGNORECASE) for pattern in self.question_patterns + ] + + def is_owasp_question(self, text: str) -> bool: + """Check if text contains an OWASP-related question.""" + if not text or not text.strip(): + return False + + text_lower = text.lower().strip() + + is_a_question = self.is_question(text_lower) + if not is_a_question: + return False + + return self.contains_owasp_keywords(text_lower) + + def is_question(self, text: str) -> bool: + """Check if text appears to be a question.""" + return any(pattern.search(text) for pattern in self.compiled_patterns) + + def contains_owasp_keywords(self, text: str) -> bool: + """Check if text contains OWASP-related keywords.""" + words = re.findall(r"\b\w+\b", text) + text_words = set(words) + + intersection = self.owasp_keywords.intersection(text_words) + if intersection: + return True + + return any(" " in keyword and keyword in text for keyword in self.owasp_keywords) diff --git a/backend/apps/slack/constants.py b/backend/apps/slack/constants.py index d4a53f22f4..005fc8dabb 100644 --- a/backend/apps/slack/constants.py +++ b/backend/apps/slack/constants.py @@ -22,6 +22,63 @@ OWASP_SPONSORSHIP_CHANNEL_ID = "#C08EGFDD9L2" OWASP_THREAT_MODELING_CHANNEL_ID = "#C1CS3C6AF" +OWASP_KEYWORDS = { + "owasp", + "security", + "vulnerability", + "vulnerabilities", + "zap", + "appsec", + "devsecops", + "nettacker", + "nest", + "threat modeling", + "top 10", + "top10", + "webgoat", + "dependency", + "secure", + "penetration", + "project", + "chapter", + "event", + "committee", + "defect dojo", + "juice shop", + "red team", + "injection", + "xss", + "csrf", + "authentication", + "authorization", + "encryption", + "cryptography", + "threat", + "risk", + "assessment", + "code review", + "static analysis", + "dynamic analysis", + "firewall", + "application security", + "web security", + "mobile security", + "api security", + "devops", + "secure coding", + "security testing", + "security tools", + "security framework", + "security guideline", + "security standard", + "security best practice", + "security policy", + "security bug", + "security patch", + "security update", + "security fix", +} + OWASP_WORKSPACE_ID = "T04T40NHX" VIEW_PROJECTS_ACTION = "view_projects_action" diff --git a/backend/apps/slack/events/__init__.py b/backend/apps/slack/events/__init__.py index faf30995a1..f5fb7e015f 100644 --- a/backend/apps/slack/events/__init__.py +++ b/backend/apps/slack/events/__init__.py @@ -1,7 +1,20 @@ -from apps.slack.apps import SlackConfig -from apps.slack.events import app_home_opened, team_join, url_verification -from apps.slack.events.event import EventBase -from apps.slack.events.member_joined_channel import catch_all, contribute, gsoc, project_nest +def configure_slack_events(): + """Configure Slack events after Django apps are ready.""" + from apps.slack.apps import SlackConfig + from apps.slack.events import ( + app_home_opened, + app_mention, + message_posted, + team_join, + url_verification, + ) + from apps.slack.events.event import EventBase + from apps.slack.events.member_joined_channel import ( + catch_all, + contribute, + gsoc, + project_nest, + ) -if SlackConfig.app: - EventBase.configure_events() + if SlackConfig.app: + EventBase.configure_events() diff --git a/backend/apps/slack/events/app_mention.py b/backend/apps/slack/events/app_mention.py new file mode 100644 index 0000000000..aeb33243e9 --- /dev/null +++ b/backend/apps/slack/events/app_mention.py @@ -0,0 +1,43 @@ +"""Slack app mention event handler.""" + +import logging + +from apps.slack.common.handlers.ai import get_blocks +from apps.slack.events.event import EventBase + +logger = logging.getLogger(__name__) + + +class AppMention(EventBase): + """Handles app mention events when the bot is mentioned in a channel.""" + + event_type = "app_mention" + + def handle_event(self, event, client): + """Handle an incoming app mention event.""" + channel_id = event.get("channel") + text = event.get("text", "") + + query = text + for mention in event.get("blocks", []): + if mention.get("type") == "rich_text": + for element in mention.get("elements", []): + if element.get("type") == "rich_text_section": + for text_element in element.get("elements", []): + if text_element.get("type") == "text": + query = text_element.get("text", "").strip() + break + + if not query: + logger.warning("No query found in app mention") + return + + logger.info("Handling app mention") + + reply_blocks = get_blocks(query=query) + client.chat_postMessage( + channel=channel_id, + blocks=reply_blocks, + text=query, + thread_ts=event.get("thread_ts") or event.get("ts"), + ) diff --git a/backend/apps/slack/events/message_posted.py b/backend/apps/slack/events/message_posted.py new file mode 100644 index 0000000000..5b38c3077b --- /dev/null +++ b/backend/apps/slack/events/message_posted.py @@ -0,0 +1,73 @@ +"""Slack message event template.""" + +import logging +from datetime import timedelta + +import django_rq + +from apps.ai.common.constants import QUEUE_RESPONSE_TIME_MINUTES +from apps.slack.common.question_detector import QuestionDetector +from apps.slack.events.event import EventBase +from apps.slack.models import Conversation, Member, Message +from apps.slack.services.message_auto_reply import generate_ai_reply_if_unanswered + +logger = logging.getLogger(__name__) + + +class MessagePosted(EventBase): + """Handles new messages posted in channels.""" + + event_type = "message" + + def __init__(self): + """Initialize MessagePosted event handler.""" + self.question_detector = QuestionDetector() + + def handle_event(self, event, client): + """Handle an incoming message event.""" + if event.get("subtype") or event.get("bot_id"): + logger.info("Ignored message due to subtype, bot_id, or thread_ts.") + return + + if event.get("thread_ts"): + try: + Message.objects.filter( + slack_message_id=event.get("thread_ts"), + conversation__slack_channel_id=event.get("channel"), + ).update(has_replies=True) + except Message.DoesNotExist: + logger.warning("Thread message not found.") + return + + channel_id = event.get("channel") + user_id = event.get("user") + text = event.get("text", "") + + try: + conversation = Conversation.objects.get( + slack_channel_id=channel_id, + is_nest_bot_assistant_enabled=True, + ) + except Conversation.DoesNotExist: + logger.warning("Conversation not found or assistant not enabled.") + return + + if not self.question_detector.is_owasp_question(text): + return + + try: + author = Member.objects.get(slack_user_id=user_id, workspace=conversation.workspace) + except Member.DoesNotExist: + user_info = client.users_info(user=user_id) + author = Member.update_data(user_info["user"], conversation.workspace, save=True) + logger.info("Created new member") + + message = Message.update_data( + data=event, conversation=conversation, author=author, save=True + ) + + django_rq.get_queue("ai").enqueue_in( + timedelta(minutes=QUEUE_RESPONSE_TIME_MINUTES), + generate_ai_reply_if_unanswered, + message.id, + ) diff --git a/backend/apps/slack/migrations/0019_conversation_is_nest_bot_assistant_enabled.py b/backend/apps/slack/migrations/0019_conversation_is_nest_bot_assistant_enabled.py new file mode 100644 index 0000000000..597856c6ea --- /dev/null +++ b/backend/apps/slack/migrations/0019_conversation_is_nest_bot_assistant_enabled.py @@ -0,0 +1,17 @@ +# Generated by Django 5.2.5 on 2025-08-19 10:31 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("slack", "0018_conversation_sync_messages"), + ] + + operations = [ + migrations.AddField( + model_name="conversation", + name="is_nest_bot_assistant_enabled", + field=models.BooleanField(default=False, verbose_name="Is Nest Bot Assistant Enabled"), + ), + ] diff --git a/backend/apps/slack/models/conversation.py b/backend/apps/slack/models/conversation.py index e58c6b2fba..9735786c24 100644 --- a/backend/apps/slack/models/conversation.py +++ b/backend/apps/slack/models/conversation.py @@ -27,6 +27,9 @@ class Meta: is_group = models.BooleanField(verbose_name="Is group", default=False) is_im = models.BooleanField(verbose_name="Is IM", default=False) is_mpim = models.BooleanField(verbose_name="Is MPIM", default=False) + is_nest_bot_assistant_enabled = models.BooleanField( + verbose_name="Is Nest Bot Assistant Enabled", default=False + ) is_private = models.BooleanField(verbose_name="Is private", default=False) is_shared = models.BooleanField(verbose_name="Is shared", default=False) name = models.CharField(verbose_name="Name", max_length=100, default="") diff --git a/backend/apps/slack/services/__init__.py b/backend/apps/slack/services/__init__.py new file mode 100644 index 0000000000..4920d87173 --- /dev/null +++ b/backend/apps/slack/services/__init__.py @@ -0,0 +1 @@ +"""Slack services package.""" diff --git a/backend/apps/slack/services/message_auto_reply.py b/backend/apps/slack/services/message_auto_reply.py new file mode 100644 index 0000000000..c9895b5c88 --- /dev/null +++ b/backend/apps/slack/services/message_auto_reply.py @@ -0,0 +1,48 @@ +"""Slack service tasks for background processing.""" + +import logging + +from django_rq import job +from slack_sdk import WebClient +from slack_sdk.errors import SlackApiError + +from apps.slack.common.handlers.ai import get_blocks, process_ai_query +from apps.slack.models import Message + +logger = logging.getLogger(__name__) + + +@job("ai") +def generate_ai_reply_if_unanswered(message_id: int): + """Check if a message is still unanswered and generate AI reply.""" + try: + message = Message.objects.get(pk=message_id) + except Message.DoesNotExist: + return + + if not message.conversation.is_nest_bot_assistant_enabled: + return + + try: + client = WebClient(token=message.conversation.workspace.bot_token) + result = client.conversations_replies( + channel=message.conversation.slack_channel_id, + ts=message.slack_message_id, + limit=1, + ) + if result.get("messages") and result["messages"][0].get("reply_count", 0) > 0: + return + + except SlackApiError: + logger.exception("Error checking for replies for message") + + ai_response_text = process_ai_query(query=message.text) + if not ai_response_text: + return + + client.chat_postMessage( + channel=message.conversation.slack_channel_id, + blocks=get_blocks(ai_response_text), + text=ai_response_text, + thread_ts=message.slack_message_id, + ) diff --git a/backend/apps/slack/templates/commands/ai.jinja b/backend/apps/slack/templates/commands/ai.jinja new file mode 100644 index 0000000000..e71e62a33f --- /dev/null +++ b/backend/apps/slack/templates/commands/ai.jinja @@ -0,0 +1,12 @@ +*Ask OWASP AI Assistant* + +Use this command to ask questions about OWASP projects, OWASP chapters, and community information using AI-powered knowledge base of OWASP. + +*Examples:* +• `{{ COMMAND }} What are the OWASP Top 10 vulnerabilities?` +• `{{ COMMAND }} How do I contribute to an OWASP project?` +• `{{ COMMAND }} When is the next OWASP appsec days event?` + +{{ DIVIDER }} + +{{ FEEDBACK_SHARING_INVITE }} diff --git a/backend/poetry.lock b/backend/poetry.lock index ef6f0704d2..085ac9967b 100644 --- a/backend/poetry.lock +++ b/backend/poetry.lock @@ -487,7 +487,7 @@ version = "8.2.1" description = "Composable command line interface toolkit" optional = false python-versions = ">=3.10" -groups = ["dev"] +groups = ["main", "dev"] files = [ {file = "click-8.2.1-py3-none-any.whl", hash = "sha256:61a3265b914e850b85317d0b3109c7f8cd35a670f963866005d6ef1d5175a12b"}, {file = "click-8.2.1.tar.gz", hash = "sha256:27c491cc05d968d271d5a1db13e3b5a184636d9d930f148c50b038f0d0646202"}, @@ -610,6 +610,22 @@ files = [ [package.extras] toml = ["tomli ; python_full_version <= \"3.11.0a6\""] +[[package]] +name = "croniter" +version = "6.0.0" +description = "croniter provides iteration for datetime object with cron like format" +optional = false +python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,>=2.6" +groups = ["main"] +files = [ + {file = "croniter-6.0.0-py2.py3-none-any.whl", hash = "sha256:2f878c3856f17896979b2a4379ba1f09c83e374931ea15cc835c5dd2eee9b368"}, + {file = "croniter-6.0.0.tar.gz", hash = "sha256:37c504b313956114a983ece2c2b07790b1f1094fe9d81cc94739214748255577"}, +] + +[package.dependencies] +python-dateutil = "*" +pytz = ">2021.1" + [[package]] name = "cryptography" version = "45.0.7" @@ -826,6 +842,27 @@ redis = ">=4.0.2" [package.extras] hiredis = ["redis[hiredis] (>=4.0.2)"] +[[package]] +name = "django-rq" +version = "3.1" +description = "An app that provides django integration for RQ (Redis Queue)" +optional = false +python-versions = ">=3.8" +groups = ["main"] +files = [ + {file = "django_rq-3.1-py3-none-any.whl", hash = "sha256:9c8a725aa3f43251a5571ec51d7b65a01613358574d01a5101861480963e59b7"}, + {file = "django_rq-3.1.tar.gz", hash = "sha256:8d7b9137b85b8df18b1cdf06244eb71b39f43ad020c0a0c7d49723f8940074ae"}, +] + +[package.dependencies] +django = ">=3.2" +redis = ">=3.5" +rq = ">=2" + +[package.extras] +prometheus = ["prometheus-client (>=0.4.0)"] +sentry = ["sentry-sdk (>=1.0.0)"] + [[package]] name = "django-storages" version = "1.14.6" @@ -3079,6 +3116,18 @@ files = [ [package.extras] cli = ["click (>=5.0)"] +[[package]] +name = "pytz" +version = "2025.2" +description = "World timezone definitions, modern and historical" +optional = false +python-versions = "*" +groups = ["main"] +files = [ + {file = "pytz-2025.2-py2.py3-none-any.whl", hash = "sha256:5ddf76296dd8c44c26eb8f4b6f35488f3ccbf6fbbd7adee0b7262d43f0ec2f00"}, + {file = "pytz-2025.2.tar.gz", hash = "sha256:360b9e3dbb49a209c21ad61809c7fb453643e048b38924c765813546746e81c3"}, +] + [[package]] name = "pyyaml" version = "6.0.2" @@ -3602,6 +3651,23 @@ files = [ {file = "rpds_py-0.27.1.tar.gz", hash = "sha256:26a1c73171d10b7acccbded82bf6a586ab8203601e565badc74bbbf8bc5a10f8"}, ] +[[package]] +name = "rq" +version = "2.6.0" +description = "RQ is a simple, lightweight, library for creating background jobs, and processing them." +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "rq-2.6.0-py3-none-any.whl", hash = "sha256:be5ccc0f0fc5f32da0999648340e31476368f08067f0c3fce6768d00064edbb5"}, + {file = "rq-2.6.0.tar.gz", hash = "sha256:92ad55676cda14512c4eea5782f398a102dc3af108bea197c868c4c50c5d3e81"}, +] + +[package.dependencies] +click = ">=5" +croniter = "*" +redis = ">=3.5,<6 || >6" + [[package]] name = "ruff" version = "0.12.12" @@ -3770,6 +3836,62 @@ optional = false python-versions = ">=3.7" groups = ["main"] files = [ + {file = "SQLAlchemy-2.0.43-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:21ba7a08a4253c5825d1db389d4299f64a100ef9800e4624c8bf70d8f136e6ed"}, + {file = "SQLAlchemy-2.0.43-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:11b9503fa6f8721bef9b8567730f664c5a5153d25e247aadc69247c4bc605227"}, + {file = "SQLAlchemy-2.0.43-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:07097c0a1886c150ef2adba2ff7437e84d40c0f7dcb44a2c2b9c905ccfc6361c"}, + {file = "SQLAlchemy-2.0.43-cp37-cp37m-musllinux_1_2_aarch64.whl", hash = "sha256:cdeff998cb294896a34e5b2f00e383e7c5c4ef3b4bfa375d9104723f15186443"}, + {file = "SQLAlchemy-2.0.43-cp37-cp37m-musllinux_1_2_x86_64.whl", hash = "sha256:bcf0724a62a5670e5718957e05c56ec2d6850267ea859f8ad2481838f889b42c"}, + {file = "SQLAlchemy-2.0.43-cp37-cp37m-win32.whl", hash = "sha256:c697575d0e2b0a5f0433f679bda22f63873821d991e95a90e9e52aae517b2e32"}, + {file = "SQLAlchemy-2.0.43-cp37-cp37m-win_amd64.whl", hash = "sha256:d34c0f6dbefd2e816e8f341d0df7d4763d382e3f452423e752ffd1e213da2512"}, + {file = "sqlalchemy-2.0.43-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:70322986c0c699dca241418fcf18e637a4369e0ec50540a2b907b184c8bca069"}, + {file = "sqlalchemy-2.0.43-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:87accdbba88f33efa7b592dc2e8b2a9c2cdbca73db2f9d5c510790428c09c154"}, + {file = "sqlalchemy-2.0.43-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c00e7845d2f692ebfc7d5e4ec1a3fd87698e4337d09e58d6749a16aedfdf8612"}, + {file = "sqlalchemy-2.0.43-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:022e436a1cb39b13756cf93b48ecce7aa95382b9cfacceb80a7d263129dfd019"}, + {file = "sqlalchemy-2.0.43-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:c5e73ba0d76eefc82ec0219d2301cb33bfe5205ed7a2602523111e2e56ccbd20"}, + {file = "sqlalchemy-2.0.43-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:9c2e02f06c68092b875d5cbe4824238ab93a7fa35d9c38052c033f7ca45daa18"}, + {file = "sqlalchemy-2.0.43-cp310-cp310-win32.whl", hash = "sha256:e7a903b5b45b0d9fa03ac6a331e1c1d6b7e0ab41c63b6217b3d10357b83c8b00"}, + {file = "sqlalchemy-2.0.43-cp310-cp310-win_amd64.whl", hash = "sha256:4bf0edb24c128b7be0c61cd17eef432e4bef507013292415f3fb7023f02b7d4b"}, + {file = "sqlalchemy-2.0.43-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:52d9b73b8fb3e9da34c2b31e6d99d60f5f99fd8c1225c9dad24aeb74a91e1d29"}, + {file = "sqlalchemy-2.0.43-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:f42f23e152e4545157fa367b2435a1ace7571cab016ca26038867eb7df2c3631"}, + {file = "sqlalchemy-2.0.43-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4fb1a8c5438e0c5ea51afe9c6564f951525795cf432bed0c028c1cb081276685"}, + {file = "sqlalchemy-2.0.43-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:db691fa174e8f7036afefe3061bc40ac2b770718be2862bfb03aabae09051aca"}, + {file = "sqlalchemy-2.0.43-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:fe2b3b4927d0bc03d02ad883f402d5de201dbc8894ac87d2e981e7d87430e60d"}, + {file = "sqlalchemy-2.0.43-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:4d3d9b904ad4a6b175a2de0738248822f5ac410f52c2fd389ada0b5262d6a1e3"}, + {file = "sqlalchemy-2.0.43-cp311-cp311-win32.whl", hash = "sha256:5cda6b51faff2639296e276591808c1726c4a77929cfaa0f514f30a5f6156921"}, + {file = "sqlalchemy-2.0.43-cp311-cp311-win_amd64.whl", hash = "sha256:c5d1730b25d9a07727d20ad74bc1039bbbb0a6ca24e6769861c1aa5bf2c4c4a8"}, + {file = "sqlalchemy-2.0.43-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:20d81fc2736509d7a2bd33292e489b056cbae543661bb7de7ce9f1c0cd6e7f24"}, + {file = "sqlalchemy-2.0.43-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:25b9fc27650ff5a2c9d490c13c14906b918b0de1f8fcbb4c992712d8caf40e83"}, + {file = "sqlalchemy-2.0.43-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6772e3ca8a43a65a37c88e2f3e2adfd511b0b1da37ef11ed78dea16aeae85bd9"}, + {file = "sqlalchemy-2.0.43-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1a113da919c25f7f641ffbd07fbc9077abd4b3b75097c888ab818f962707eb48"}, + {file = "sqlalchemy-2.0.43-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:4286a1139f14b7d70141c67a8ae1582fc2b69105f1b09d9573494eb4bb4b2687"}, + {file = "sqlalchemy-2.0.43-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:529064085be2f4d8a6e5fab12d36ad44f1909a18848fcfbdb59cc6d4bbe48efe"}, + {file = "sqlalchemy-2.0.43-cp312-cp312-win32.whl", hash = "sha256:b535d35dea8bbb8195e7e2b40059e2253acb2b7579b73c1b432a35363694641d"}, + {file = "sqlalchemy-2.0.43-cp312-cp312-win_amd64.whl", hash = "sha256:1c6d85327ca688dbae7e2b06d7d84cfe4f3fffa5b5f9e21bb6ce9d0e1a0e0e0a"}, + {file = "sqlalchemy-2.0.43-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e7c08f57f75a2bb62d7ee80a89686a5e5669f199235c6d1dac75cd59374091c3"}, + {file = "sqlalchemy-2.0.43-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:14111d22c29efad445cd5021a70a8b42f7d9152d8ba7f73304c4d82460946aaa"}, + {file = "sqlalchemy-2.0.43-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:21b27b56eb2f82653168cefe6cb8e970cdaf4f3a6cb2c5e3c3c1cf3158968ff9"}, + {file = "sqlalchemy-2.0.43-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9c5a9da957c56e43d72126a3f5845603da00e0293720b03bde0aacffcf2dc04f"}, + {file = "sqlalchemy-2.0.43-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:5d79f9fdc9584ec83d1b3c75e9f4595c49017f5594fee1a2217117647225d738"}, + {file = "sqlalchemy-2.0.43-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9df7126fd9db49e3a5a3999442cc67e9ee8971f3cb9644250107d7296cb2a164"}, + {file = "sqlalchemy-2.0.43-cp313-cp313-win32.whl", hash = "sha256:7f1ac7828857fcedb0361b48b9ac4821469f7694089d15550bbcf9ab22564a1d"}, + {file = "sqlalchemy-2.0.43-cp313-cp313-win_amd64.whl", hash = "sha256:971ba928fcde01869361f504fcff3b7143b47d30de188b11c6357c0505824197"}, + {file = "sqlalchemy-2.0.43-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:4e6aeb2e0932f32950cf56a8b4813cb15ff792fc0c9b3752eaf067cfe298496a"}, + {file = "sqlalchemy-2.0.43-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:61f964a05356f4bca4112e6334ed7c208174511bd56e6b8fc86dad4d024d4185"}, + {file = "sqlalchemy-2.0.43-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:46293c39252f93ea0910aababa8752ad628bcce3a10d3f260648dd472256983f"}, + {file = "sqlalchemy-2.0.43-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:136063a68644eca9339d02e6693932116f6a8591ac013b0014479a1de664e40a"}, + {file = "sqlalchemy-2.0.43-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:6e2bf13d9256398d037fef09fd8bf9b0bf77876e22647d10761d35593b9ac547"}, + {file = "sqlalchemy-2.0.43-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:44337823462291f17f994d64282a71c51d738fc9ef561bf265f1d0fd9116a782"}, + {file = "sqlalchemy-2.0.43-cp38-cp38-win32.whl", hash = "sha256:13194276e69bb2af56198fef7909d48fd34820de01d9c92711a5fa45497cc7ed"}, + {file = "sqlalchemy-2.0.43-cp38-cp38-win_amd64.whl", hash = "sha256:334f41fa28de9f9be4b78445e68530da3c5fa054c907176460c81494f4ae1f5e"}, + {file = "sqlalchemy-2.0.43-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:ceb5c832cc30663aeaf5e39657712f4c4241ad1f638d487ef7216258f6d41fe7"}, + {file = "sqlalchemy-2.0.43-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:11f43c39b4b2ec755573952bbcc58d976779d482f6f832d7f33a8d869ae891bf"}, + {file = "sqlalchemy-2.0.43-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:413391b2239db55be14fa4223034d7e13325a1812c8396ecd4f2c08696d5ccad"}, + {file = "sqlalchemy-2.0.43-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c379e37b08c6c527181a397212346be39319fb64323741d23e46abd97a400d34"}, + {file = "sqlalchemy-2.0.43-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:03d73ab2a37d9e40dec4984d1813d7878e01dbdc742448d44a7341b7a9f408c7"}, + {file = "sqlalchemy-2.0.43-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:8cee08f15d9e238ede42e9bbc1d6e7158d0ca4f176e4eab21f88ac819ae3bd7b"}, + {file = "sqlalchemy-2.0.43-cp39-cp39-win32.whl", hash = "sha256:b3edaec7e8b6dc5cd94523c6df4f294014df67097c8217a89929c99975811414"}, + {file = "sqlalchemy-2.0.43-cp39-cp39-win_amd64.whl", hash = "sha256:227119ce0a89e762ecd882dc661e0aa677a690c914e358f0dd8932a2e8b2765b"}, + {file = "sqlalchemy-2.0.43-py3-none-any.whl", hash = "sha256:1681c21dd2ccee222c2fe0bef671d1aef7c504087c9c4e800371cfcc8ac966fc"}, {file = "sqlalchemy-2.0.43.tar.gz", hash = "sha256:788bfcef6787a7764169cfe9859fe425bf44559619e1d9f56f5bddf2ebf6f417"}, ] @@ -4275,4 +4397,4 @@ cffi = ["cffi (>=1.17) ; python_version >= \"3.13\" and platform_python_implemen [metadata] lock-version = "2.1" python-versions = "^3.13" -content-hash = "db9cd03d7d831484581a0f801195e49f1e471090708e59af82753b926365ef62" +content-hash = "c0a7776e26b82c16ef0b31b5d81e7057ebc4b3b8afa030a85a37a1c20bf2e4f9" diff --git a/backend/pyproject.toml b/backend/pyproject.toml index 11e0d55745..9f4249b98a 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -19,6 +19,7 @@ django-configurations = "^2.5.1" django-cors-headers = "^4.7.0" django-ninja = "^1.4.3" django-redis = "^6.0.0" +django-rq = "^3.1" django-storages = { extras = [ "s3" ], version = "^1.14.4" } emoji = "^2.14.1" geopy = "^2.4.1" diff --git a/backend/settings/base.py b/backend/settings/base.py index c8a297f1a1..18ba9c341b 100644 --- a/backend/settings/base.py +++ b/backend/settings/base.py @@ -46,6 +46,7 @@ class Base(Configuration): THIRD_PARTY_APPS = ( "algoliasearch_django", "corsheaders", + "django_rq", "ninja", "storages", ) @@ -140,6 +141,16 @@ class Base(Configuration): } } + RQ_QUEUES = { + "ai": { + "HOST": REDIS_HOST, + "PORT": 6379, + "PASSWORD": REDIS_PASSWORD, + "DB": 1, + "DEFAULT_TIMEOUT": 360, + } + } + # Database # https://docs.djangoproject.com/en/5.1/ref/settings/#databases DATABASES = { diff --git a/backend/settings/urls.py b/backend/settings/urls.py index 9d7cedc6cc..02ebe17a80 100644 --- a/backend/settings/urls.py +++ b/backend/settings/urls.py @@ -28,6 +28,7 @@ path("owasp/", include(owasp_urls)), path("status/", get_status), path("", include("apps.sitemap.urls")), + path("django-rq/", include("django_rq.urls")), ] if SlackConfig.app: diff --git a/backend/tests/apps/slack/commands/ai_test.py b/backend/tests/apps/slack/commands/ai_test.py new file mode 100644 index 0000000000..e7e5af1b0f --- /dev/null +++ b/backend/tests/apps/slack/commands/ai_test.py @@ -0,0 +1,186 @@ +"""Tests for AI command functionality.""" + +from unittest.mock import patch + +import pytest + +from apps.slack.commands.ai import Ai + + +class TestAiCommand: + """Test cases for AI command functionality.""" + + @pytest.fixture(autouse=True) + def setup_method(self): + """Set up test data before each test method.""" + self.ai_command = Ai() + + @patch("apps.slack.common.handlers.ai.get_blocks") + def test_render_blocks_success(self, mock_get_blocks): + """Test successful rendering of AI response blocks.""" + command = { + "text": "What is OWASP?", + "user_id": "U123456", + "channel_id": "C123456", + } + expected_blocks = [ + { + "type": "section", + "text": { + "type": "mrkdwn", + "text": "OWASP is a security organization...", + }, + } + ] + mock_get_blocks.return_value = expected_blocks + + ai_command = Ai() + result = ai_command.render_blocks(command) + + mock_get_blocks.assert_called_once_with(query="What is OWASP?") + assert result == expected_blocks + + @patch("apps.slack.common.handlers.ai.get_blocks") + def test_render_blocks_with_whitespace(self, mock_get_blocks): + """Test rendering blocks with text that has whitespace.""" + command = { + "text": " What is OWASP security? ", + "user_id": "U123456", + "channel_id": "C123456", + } + expected_blocks = [ + { + "type": "section", + "text": { + "type": "mrkdwn", + "text": "OWASP is a security organization...", + }, + } + ] + mock_get_blocks.return_value = expected_blocks + + ai_command = Ai() + result = ai_command.render_blocks(command) + + mock_get_blocks.assert_called_once_with(query="What is OWASP security?") + assert result == expected_blocks + + @patch("apps.slack.common.handlers.ai.get_blocks") + def test_render_blocks_empty_text(self, mock_get_blocks): + """Test rendering blocks with empty text.""" + command = {"text": "", "user_id": "U123456", "channel_id": "C123456"} + expected_blocks = [ + {"type": "section", "text": {"type": "mrkdwn", "text": "Error message"}} + ] + mock_get_blocks.return_value = expected_blocks + + ai_command = Ai() + result = ai_command.render_blocks(command) + + mock_get_blocks.assert_called_once_with(query="") + assert result == expected_blocks + + @patch("apps.slack.common.handlers.ai.get_blocks") + def test_render_blocks_only_whitespace(self, mock_get_blocks): + """Test rendering blocks with only whitespace in text.""" + command = {"text": " ", "user_id": "U123456", "channel_id": "C123456"} + expected_blocks = [ + {"type": "section", "text": {"type": "mrkdwn", "text": "Error message"}} + ] + mock_get_blocks.return_value = expected_blocks + + ai_command = Ai() + result = ai_command.render_blocks(command) + + mock_get_blocks.assert_called_once_with(query="") + assert result == expected_blocks + + @patch("apps.slack.common.handlers.ai.get_blocks") + def test_render_blocks_complex_query(self, mock_get_blocks): + """Test rendering blocks with complex query.""" + command = { + "text": "What are the OWASP Top 10 vulnerabilities and how can I prevent them?", + "user_id": "U123456", + "channel_id": "C123456", + } + expected_blocks = [ + { + "type": "section", + "text": {"type": "mrkdwn", "text": "The OWASP Top 10 is a list..."}, + }, + {"type": "divider"}, + { + "type": "section", + "text": {"type": "mrkdwn", "text": "Prevention techniques..."}, + }, + ] + mock_get_blocks.return_value = expected_blocks + + ai_command = Ai() + result = ai_command.render_blocks(command) + + mock_get_blocks.assert_called_once_with( + query="What are the OWASP Top 10 vulnerabilities and how can I prevent them?" + ) + assert result == expected_blocks + + @patch("apps.slack.common.handlers.ai.get_blocks") + def test_render_blocks_handles_exception(self, mock_get_blocks): + """Test that render_blocks handles exceptions gracefully.""" + command = { + "text": "What is OWASP?", + "user_id": "U123456", + "channel_id": "C123456", + } + mock_get_blocks.side_effect = Exception("AI service error") + + ai_command = Ai() + with pytest.raises(Exception, match="AI service error"): + ai_command.render_blocks(command) + + @patch("apps.slack.common.handlers.ai.get_blocks") + def test_render_blocks_returns_none(self, mock_get_blocks): + """Test handling when get_blocks returns None.""" + command = { + "text": "What is OWASP?", + "user_id": "U123456", + "channel_id": "C123456", + } + mock_get_blocks.return_value = None + + ai_command = Ai() + result = ai_command.render_blocks(command) + + mock_get_blocks.assert_called_once_with(query="What is OWASP?") + assert result is None + + def test_ai_command_inheritance(self): + """Test that Ai command inherits from CommandBase.""" + from apps.slack.commands.command import CommandBase + + ai_command = Ai() + assert isinstance(ai_command, CommandBase) + + @patch("apps.slack.common.handlers.ai.get_blocks") + def test_render_blocks_special_characters(self, mock_get_blocks): + """Test rendering blocks with special characters in query.""" + command = { + "text": "What is XSS & SQL injection? How to prevent