diff --git a/backend/apps/ai/common/constants.py b/backend/apps/ai/common/constants.py index 2517a4c85c..27ddd0082c 100644 --- a/backend/apps/ai/common/constants.py +++ b/backend/apps/ai/common/constants.py @@ -6,3 +6,4 @@ DELIMITER = "\n\n" GITHUB_REQUEST_INTERVAL_SECONDS = 0.5 MIN_REQUEST_INTERVAL_SECONDS = 1.2 +QUEUE_RESPONSE_TIME_MINUTES = 1 diff --git a/backend/apps/ai/common/extractors/repository.py b/backend/apps/ai/common/extractors/repository.py index b257495782..ac86f7d9fd 100644 --- a/backend/apps/ai/common/extractors/repository.py +++ b/backend/apps/ai/common/extractors/repository.py @@ -131,9 +131,10 @@ def extract_repository_content(repository) -> tuple[str, str]: if response and is_valid_json(response): items = json.loads(response) for item in items: - name = item.get("name", "") - if name.startswith("tab_") and name.endswith(".md"): - tab_files.append(name) + if isinstance(item, dict): + name = item.get("name", "") + if name.startswith("tab_") and name.endswith(".md"): + tab_files.append(name) all_markdown_files = markdown_files + tab_files 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..17d13c787d --- /dev/null +++ b/backend/apps/slack/common/question_detector.py @@ -0,0 +1,141 @@ +"""Question detection utilities for Slack OWASP bot.""" + +from __future__ import annotations + +import logging +import os +import re + +import openai + +from apps.slack.constants import OWASP_KEYWORDS + +logger = logging.getLogger(__name__) + + +class QuestionDetector: + """Utility class for detecting OWASP-related questions.""" + + MAX_TOKENS = 50 + TEMPERATURE = 0.1 + CHAT_MODEL = "gpt-4o" + + SYSTEM_PROMPT = """ + You are an expert in cybersecurity and OWASP (Open Web Application Security Project). + Your task is to determine if a given question is related to OWASP, cybersecurity, + web application security, or similar topics. + + Key OWASP-related terms: {keywords} + + Respond with only "YES" if the question is related to OWASP/cybersecurity, + or "NO" if it's not. + Do not provide any explanation or additional text. + """ + + def __init__(self): + """Initialize the question detector. + + Raises: + ValueError: If the OpenAI API key is not set. + + """ + if not (openai_api_key := os.getenv("DJANGO_OPEN_AI_SECRET_KEY")): + error_msg = "DJANGO_OPEN_AI_SECRET_KEY environment variable not set" + raise ValueError(error_msg) + + self.owasp_keywords = OWASP_KEYWORDS + self.openai_client = openai.OpenAI(api_key=openai_api_key) + + 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 question_patterns + ] + + def is_owasp_question(self, text: str) -> bool: + """Check if the input text is an OWASP-related question. + + This is the main public method that orchestrates the detection logic. + """ + if not text or not text.strip(): + return False + + if not self.is_question(text): + return False + + openai_result = self.is_owasp_question_with_openai(text) + + if openai_result is None: + logger.warning( + "OpenAI detection failed. Falling back to keyword matching", + ) + return self.contains_owasp_keywords(text) + + if openai_result: + return True + if self.contains_owasp_keywords(text): + logger.info( + "OpenAI classified as non-OWASP, but keywords were detected. Overriding to TRUE." + ) + return True + return False + + 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 is_owasp_question_with_openai(self, text: str) -> bool | None: + """Determine if the text is an OWASP-related question. + + Returns: + - True: If the model responds with "YES". + - False: If the model responds with "NO". + - None: If the API call fails or the response is unexpected. + + """ + system_prompt = self.SYSTEM_PROMPT.format(keywords=", ".join(self.owasp_keywords)) + user_prompt = f'Question: "{text}"' + + try: + response = self.openai_client.chat.completions.create( + model=self.CHAT_MODEL, + messages=[ + {"role": "system", "content": system_prompt}, + {"role": "user", "content": user_prompt}, + ], + temperature=self.TEMPERATURE, + max_tokens=self.MAX_TOKENS, + ) + except openai.OpenAIError: + logger.exception("OpenAI API error during question detection") + return None + else: + answer = response.choices[0].message.content + if not answer: + logger.error("OpenAI returned an empty response") + return None + + clean_answer = answer.strip().upper() + + if "YES" in clean_answer: + return True + if "NO" in clean_answer: + return False + logger.warning("Unexpected OpenAI response") + return None + + 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..ef9bb7b9bb 100644 --- a/backend/apps/slack/constants.py +++ b/backend/apps/slack/constants.py @@ -22,6 +22,64 @@ OWASP_SPONSORSHIP_CHANNEL_ID = "#C08EGFDD9L2" OWASP_THREAT_MODELING_CHANNEL_ID = "#C1CS3C6AF" +OWASP_KEYWORDS = { + "api security", + "appsec", + "application security", + "assessment", + "authentication", + "authorization", + "cheat sheet series", + "chapter", + "code review", + "committee", + "cryptography", + "csrf", + "defectdojo", + "dependency", + "devops", + "devsecops", + "dynamic analysis", + "encryption", + "event", + "firewall", + "injection", + "juice shop", + "mobile security", + "nest", + "nettacker", + "owasp", + "penetration", + "project", + "rasp", + "red team", + "risk", + "sbom", + "secure", + "secure coding", + "security", + "security best practice", + "security bug", + "security fix", + "security framework", + "security guideline", + "security patch", + "security policy", + "security standard", + "security testing", + "security tools", + "static analysis", + "threat", + "threat modeling", + "top 10", + "top10", + "vulnerabilities", + "vulnerability", + "web security", + "webgoat", + "xss", +} + 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..f390969ca9 --- /dev/null +++ b/backend/apps/slack/services/message_auto_reply.py @@ -0,0 +1,53 @@ +"""Slack service tasks for background processing.""" + +import logging + +from django_rq import job +from slack_sdk.errors import SlackApiError + +from apps.slack.apps import SlackConfig +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 + + if not SlackConfig.app: + logger.warning("Slack app is not configured") + return + + client = SlackConfig.app.client + + try: + 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 27f6f7210b..eac19dfb65 100644 --- a/backend/poetry.lock +++ b/backend/poetry.lock @@ -1,4 +1,4 @@ -# This file is automatically @generated by Poetry 2.2.0 and should not be changed by hand. +# This file is automatically @generated by Poetry 2.1.4 and should not be changed by hand. [[package]] name = "aiohappyeyeballs" @@ -505,7 +505,7 @@ version = "8.3.0" description = "Composable command line interface toolkit" optional = false python-versions = ">=3.10" -groups = ["dev"] +groups = ["main", "dev"] files = [ {file = "click-8.3.0-py3-none-any.whl", hash = "sha256:9b9f285302c6e3064f4330c05f05b81945b2a39544279343e6e7c5f27a9baddc"}, {file = "click-8.3.0.tar.gz", hash = "sha256:e7b8232224eba16f4ebe410c25ced9f7875cb5f3263ffc93cc3e8da705e229c4"}, @@ -644,6 +644,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 = "46.0.1" @@ -877,6 +893,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" @@ -3191,6 +3228,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" @@ -3742,6 +3791,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.13.1" @@ -4471,4 +4537,4 @@ cffi = ["cffi (>=1.17,<2.0) ; platform_python_implementation != \"PyPy\" and pyt [metadata] lock-version = "2.1" python-versions = "^3.13" -content-hash = "80764d337699b4d28e374bc4f395944c8b3b59f4aa4addadf936312b783b29d3" +content-hash = "ccb7c02fa5edba40b21d1f5593d787cda618b08cd48fd5b9d692fc443a5d5b9c" diff --git a/backend/pyproject.toml b/backend/pyproject.toml index b6ace236f1..a77105d18a 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 22b76ea900..5ec949ef70 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", ) @@ -141,6 +142,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 9f2da5090e..4c7b11352f 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