From 707d33e1d216dc5cac8bbac25ebcef0d10f0aee8 Mon Sep 17 00:00:00 2001 From: dharya4242 Date: Sun, 5 Oct 2025 16:28:32 +0000 Subject: [PATCH 1/6] feat: route onboarding flow through MCP-backed GitHub toolkit --- .../github/tools/general_github_help.py | 18 +- .../devrel/nodes/handlers/onboarding.py | 66 ++++- .../agents/devrel/nodes/react_supervisor.py | 19 ++ .../app/agents/devrel/onboarding/__init__.py | 0 .../app/agents/devrel/onboarding/messages.py | 98 +++++++ .../app/agents/devrel/onboarding/workflow.py | 276 ++++++++++++++++++ backend/app/agents/devrel/tool_wrappers.py | 13 +- backend/app/agents/state.py | 3 + backend/app/api/v1/auth.py | 32 +- backend/app/core/config/settings.py | 7 + backend/app/integrations/mcp/client.py | 51 ++++ backend/integrations/discord/cogs.py | 139 ++++++++- backend/integrations/discord/views.py | 104 +++++++ 13 files changed, 800 insertions(+), 26 deletions(-) create mode 100644 backend/app/agents/devrel/onboarding/__init__.py create mode 100644 backend/app/agents/devrel/onboarding/messages.py create mode 100644 backend/app/agents/devrel/onboarding/workflow.py create mode 100644 backend/app/integrations/mcp/client.py diff --git a/backend/app/agents/devrel/github/tools/general_github_help.py b/backend/app/agents/devrel/github/tools/general_github_help.py index a93e86b..3ecd143 100644 --- a/backend/app/agents/devrel/github/tools/general_github_help.py +++ b/backend/app/agents/devrel/github/tools/general_github_help.py @@ -1,4 +1,4 @@ -from typing import Dict, Any +from typing import Dict, Any, Optional import logging from langchain_core.messages import HumanMessage from app.agents.devrel.nodes.handlers.web_search import _extract_search_query @@ -8,7 +8,7 @@ logger = logging.getLogger(__name__) -async def handle_general_github_help(query: str, llm) -> Dict[str, Any]: +async def handle_general_github_help(query: str, llm, hint: Optional[str] = None) -> Dict[str, Any]: """Execute general GitHub help with web search and LLM knowledge""" logger.info("Providing general GitHub help") @@ -23,10 +23,16 @@ async def handle_general_github_help(query: str, llm) -> Dict[str, Any]: else: search_context = "No search results available." - help_prompt = GENERAL_GITHUB_HELP_PROMPT.format( - query=query, - search_context=search_context - ) + if hint: + help_prompt = GENERAL_GITHUB_HELP_PROMPT.format( + query=f"{query}\n\nAssistant hint: {hint}", + search_context=search_context + ) + else: + help_prompt = GENERAL_GITHUB_HELP_PROMPT.format( + query=query, + search_context=search_context + ) response = await llm.ainvoke([HumanMessage(content=help_prompt)]) diff --git a/backend/app/agents/devrel/nodes/handlers/onboarding.py b/backend/app/agents/devrel/nodes/handlers/onboarding.py index 86bba56..c2301f4 100644 --- a/backend/app/agents/devrel/nodes/handlers/onboarding.py +++ b/backend/app/agents/devrel/nodes/handlers/onboarding.py @@ -1,17 +1,67 @@ import logging +from typing import Any, Dict + +from app.agents.devrel.onboarding.workflow import ( + OnboardingStage, + run_onboarding_flow, +) from app.agents.state import AgentState logger = logging.getLogger(__name__) -async def handle_onboarding_node(state: AgentState) -> AgentState: - """Handle onboarding requests""" +def _latest_text(state: AgentState) -> str: + if state.messages: + return state.messages[-1].get("content", "").lower() + return state.context.get("original_message", "").lower() + +async def handle_onboarding_node(state: AgentState) -> Dict[str, Any]: + """Handle onboarding requests via the multi-stage onboarding workflow.""" logger.info(f"Handling onboarding for session {state.session_id}") + text = _latest_text(state) + + # Try to derive verification state if present in context/user_profile + is_verified = False + github_username = None + try: + profile = state.user_profile or {} + ctx_profile = state.context.get("user_profile", {}) + is_verified = bool(profile.get("is_verified") or ctx_profile.get("is_verified")) + github_username = profile.get("github_username") or ctx_profile.get("github_username") + except Exception: + pass + + flow_result, updated_state = run_onboarding_flow( + state=state, + latest_message=text, + is_verified=is_verified, + github_username=github_username, + ) + + task_result: Dict[str, Any] = { + "type": "onboarding", + "stage": flow_result.stage.value, + "status": flow_result.status, + "welcome_message": flow_result.welcome_message, + "final_message": flow_result.final_message, + "actions": flow_result.actions, + "is_verified": flow_result.is_verified, + "capability_sections": flow_result.capability_sections, + } + + if flow_result.route_hint: + task_result["route_hint"] = flow_result.route_hint + if flow_result.handoff: + task_result["handoff"] = flow_result.handoff + if flow_result.next_tool: + task_result["next_tool"] = flow_result.next_tool + if flow_result.metadata: + task_result["metadata"] = flow_result.metadata + + current_task = f"onboarding_{flow_result.stage.value}" + return { - "task_result": { - "type": "onboarding", - "action": "welcome_and_guide", - "next_steps": ["setup_environment", "first_contribution", "join_community"] - }, - "current_task": "onboarding_handled" + "task_result": task_result, + "current_task": current_task, + "onboarding_state": updated_state, } diff --git a/backend/app/agents/devrel/nodes/react_supervisor.py b/backend/app/agents/devrel/nodes/react_supervisor.py index 12bec4c..e180c53 100644 --- a/backend/app/agents/devrel/nodes/react_supervisor.py +++ b/backend/app/agents/devrel/nodes/react_supervisor.py @@ -17,6 +17,25 @@ async def react_supervisor_node(state: AgentState, llm) -> Dict[str, Any]: tool_results = state.context.get("tool_results", []) iteration_count = state.context.get("iteration_count", 0) + forced_action = state.context.get("force_next_tool") + if forced_action: + logger.info( + "Supervisor auto-routing to %s for session %s", forced_action, state.session_id + ) + decision = { + "action": forced_action, + "reasoning": "Auto-routed by onboarding workflow", + "thinking": "", + } + updated_context = {**state.context} + updated_context.pop("force_next_tool", None) + updated_context["supervisor_decision"] = decision + updated_context["iteration_count"] = iteration_count + 1 + return { + "context": updated_context, + "current_task": f"supervisor_forced_{forced_action}", + } + prompt = REACT_SUPERVISOR_PROMPT.format( latest_message=latest_message, platform=state.platform, diff --git a/backend/app/agents/devrel/onboarding/__init__.py b/backend/app/agents/devrel/onboarding/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/agents/devrel/onboarding/messages.py b/backend/app/agents/devrel/onboarding/messages.py new file mode 100644 index 0000000..dcbff5a --- /dev/null +++ b/backend/app/agents/devrel/onboarding/messages.py @@ -0,0 +1,98 @@ +"""Shared onboarding messaging primitives used across channels.""" +from __future__ import annotations + +from typing import Dict, List, Optional + +# Structured capability sections pulled from design docs so UI and LLM share copy +CAPABILITY_SECTIONS: List[Dict[str, List[str]]] = [ + { + "title": "Explore Our Projects", + "examples": [ + "Show me our most active repositories.", + "Give me an overview of the 'Devr.AI-backend' repo.", + ], + }, + { + "title": "Find Ways to Contribute", + "examples": [ + "Are there any 'good first issues' available?", + "Find issues with the 'bug' label.", + ], + }, + { + "title": "Answer Project Questions", + "examples": [ + "How do I set up the local development environment?", + "What's the process for submitting a pull request?", + ], + }, +] + +CAPABILITIES_INTRO_TEXT = ( + "You're all set! As the Devr.AI assistant, my main purpose is to help you " + "navigate and contribute to our projects. Here's a look at what you can ask " + "me to do." +) +CAPABILITIES_OUTRO_TEXT = "Feel free to ask me anything related to the project. What's on your mind?" + + +def render_capabilities_text() -> str: + """Render the capabilities message as plain text for chat responses.""" + lines: List[str] = [CAPABILITIES_INTRO_TEXT, ""] + for section in CAPABILITY_SECTIONS: + lines.append(f"{section['title']}:") + for example in section["examples"]: + lines.append(f"- \"{example}\"") + lines.append("") + lines.append(CAPABILITIES_OUTRO_TEXT) + return "\n".join(lines).strip() + + +def build_new_user_welcome() -> str: + """Welcome copy when verification is still pending.""" + return ( + "πŸ‘‹ Welcome to the Devr.AI community! I'm here to help you get started on your contributor journey.\n\n" + "To give you the best recommendations for repositories and issues, I first need to link your GitHub account. " + "This one-time step helps me align tasks with your profile.\n\n" + "Here's how to verify:\n" + "- Run `/verify_github` to start verification right away.\n" + "- Use `/verification_status` to see if you're already linked.\n" + "- Use `/help` anytime to explore everything I can assist with.\n\n" + "Would you like to verify your GitHub account now or skip this step for now? You can always do it later." + ) + + +def build_verified_welcome(github_username: Optional[str] = None) -> str: + """Welcome copy for returning verified contributors.""" + greeting = "πŸ‘‹ Welcome back to the Devr.AI community!" + if github_username: + greeting += f" I see `{github_username}` is already linked, which is great." + else: + greeting += " I see your GitHub account is already verified, which is great." + return ( + f"{greeting}\n\nHow can I help you get started today? Ask me for repository overviews, issues to work on, or project guidance whenever you're ready." + ) + + +def build_encourage_verification_message(reminder_count: int = 0) -> str: + """Reminder copy for users who haven't verified yet but want to explore.""" + reminder_prefix = "Quick reminder" if reminder_count else "No worries" + return ( + f"{reminder_prefix} β€” linking your GitHub unlocks personalized suggestions. " + "Run `/verify_github` when you're ready, and `/verification_status` to check progress.\n\n" + "While you set that up, I can still show you what's happening across the organization. " + "Ask for repository highlights, open issues, or anything else you're curious about." + ) + + +def build_verified_capabilities_intro(github_username: Optional[str] = None) -> str: + """Intro text shown right before the capability menu for verified users.""" + if github_username: + return ( + f"Awesome β€” `{github_username}` is linked! You're all set to explore. " + "Here's a quick menu of what I can help you with right away." + ) + return ( + "Great! Your GitHub account is connected and I'm ready to tailor suggestions. " + "Here are the top things I can help with." + ) diff --git a/backend/app/agents/devrel/onboarding/workflow.py b/backend/app/agents/devrel/onboarding/workflow.py new file mode 100644 index 0000000..095df7b --- /dev/null +++ b/backend/app/agents/devrel/onboarding/workflow.py @@ -0,0 +1,276 @@ +"""Onboarding workflow state machine used by the onboarding tool node.""" +from __future__ import annotations + +import re +from dataclasses import dataclass, field +from enum import Enum +from typing import Any, Dict, List, Optional, Tuple + +from app.agents.devrel.onboarding import messages +from app.agents.state import AgentState + +class OnboardingStage(str, Enum): + """Discrete stages in the onboarding flow.""" + + INTRO = "intro" + AWAITING_CHOICE = "awaiting_choice" + ENCOURAGE_VERIFICATION = "encourage_verification" + VERIFIED_CAPABILITIES = "verified_capabilities" + COMPLETED = "completed" + + +@dataclass +class OnboardingFlowResult: + """Structured response produced by the onboarding workflow.""" + + stage: OnboardingStage + status: str + welcome_message: str + actions: List[Dict[str, str]] = field(default_factory=list) + final_message: Optional[str] = None + is_verified: bool = False + capability_sections: Optional[List[Dict[str, Any]]] = None + route_hint: Optional[str] = None + handoff: Optional[str] = None + next_tool: Optional[str] = None + metadata: Dict[str, Any] = field(default_factory=dict) + + +_INTENT_VERIFIED = re.compile(r"\b(i\s*(have)?\s*)?(linked|connected|verified)\b.*github", re.IGNORECASE) +_INTENT_SKIP = re.compile(r"\b(skip|later|not\s+now)\b", re.IGNORECASE) +_INTENT_HELP = re.compile(r"\b(how|help|can't|cannot|stuck)\b.*verify", re.IGNORECASE) +_INTENT_EXPLORE = re.compile( + r"\b(repo|repository|issue|issues|project|projects|org|organisation|organization|contribute|task)\b", + re.IGNORECASE, +) + + +def _detect_user_intent(message: str) -> str: + if not message: + return "none" + + text = message.strip().lower() + + if _INTENT_VERIFIED.search(text): + return "confirm_verified" + if _INTENT_SKIP.search(text): + return "skip" + if _INTENT_HELP.search(text): + return "help_verify" + if _INTENT_EXPLORE.search(text): + return "explore" + + return "none" + + +def _base_actions(include_verification: bool = True) -> List[Dict[str, str]]: + actions: List[Dict[str, str]] = [] + if include_verification: + actions.extend( + [ + {"type": "suggest_command", "command": "/verify_github"}, + {"type": "suggest_command", "command": "/verification_status"}, + ] + ) + actions.append({"type": "suggest_command", "command": "/help"}) + return actions + + +def _exploration_suggestions() -> List[Dict[str, str]]: + return [ + {"type": "suggest_message", "content": "Show me our most active repositories."}, + {"type": "suggest_message", "content": "Are there any 'good first issues' available?"}, + {"type": "suggest_message", "content": "Give me an overview of the 'Devr.AI-backend' repo."}, + ] + + +def run_onboarding_flow( + state: AgentState, + latest_message: str, + is_verified: bool, + github_username: Optional[str], +) -> Tuple[OnboardingFlowResult, Dict[str, Any]]: + """Execute the onboarding state machine and return response + updated state.""" + + onboarding_state = dict(state.onboarding_state or {}) + stage = onboarding_state.get("stage", OnboardingStage.INTRO.value) + try: + stage_enum = OnboardingStage(stage) + except ValueError: + stage_enum = OnboardingStage.INTRO + + intent = _detect_user_intent(latest_message) + + reminders_sent = int(onboarding_state.get("reminders_sent", 0)) + capability_sections = messages.CAPABILITY_SECTIONS + + if stage_enum is OnboardingStage.INTRO: + if is_verified: + onboarding_state["stage"] = OnboardingStage.VERIFIED_CAPABILITIES.value + onboarding_state["verified_acknowledged"] = True + intro = messages.build_verified_capabilities_intro(github_username) + return ( + OnboardingFlowResult( + stage=OnboardingStage.VERIFIED_CAPABILITIES, + status="completed", + welcome_message=intro, + final_message=messages.render_capabilities_text(), + actions=_exploration_suggestions(), + is_verified=True, + capability_sections=capability_sections, + route_hint="onboarding", + handoff="github_toolkit", + next_tool="github_toolkit", + ), + onboarding_state, + ) + + onboarding_state["stage"] = OnboardingStage.AWAITING_CHOICE.value + onboarding_state.setdefault("reminders_sent", reminders_sent) + return ( + OnboardingFlowResult( + stage=OnboardingStage.AWAITING_CHOICE, + status="in_progress", + welcome_message=messages.build_new_user_welcome(), + final_message=messages.render_capabilities_text(), + actions=_base_actions(include_verification=True), + is_verified=is_verified, + capability_sections=capability_sections, + route_hint="onboarding", + ), + onboarding_state, + ) + + if stage_enum is OnboardingStage.AWAITING_CHOICE: + if is_verified or intent == "confirm_verified": + onboarding_state["stage"] = OnboardingStage.VERIFIED_CAPABILITIES.value + onboarding_state["verified_acknowledged"] = True + intro = messages.build_verified_capabilities_intro(github_username) + return ( + OnboardingFlowResult( + stage=OnboardingStage.VERIFIED_CAPABILITIES, + status="completed", + welcome_message=intro, + final_message=messages.render_capabilities_text(), + actions=_exploration_suggestions(), + is_verified=True, + capability_sections=capability_sections, + route_hint="onboarding", + handoff="github_toolkit", + next_tool="github_toolkit", + ), + onboarding_state, + ) + + if intent in {"skip", "explore", "help_verify"}: + onboarding_state["stage"] = OnboardingStage.ENCOURAGE_VERIFICATION.value + onboarding_state["reminders_sent"] = reminders_sent + 1 + reminder_message = messages.build_encourage_verification_message(reminders_sent) + return ( + OnboardingFlowResult( + stage=OnboardingStage.ENCOURAGE_VERIFICATION, + status="in_progress", + welcome_message=reminder_message, + final_message=messages.render_capabilities_text(), + actions=_base_actions(include_verification=True) + _exploration_suggestions(), + is_verified=False, + capability_sections=capability_sections, + route_hint="onboarding", + handoff="github_toolkit", + next_tool="github_toolkit", + metadata={"reminders_sent": onboarding_state["reminders_sent"]}, + ), + onboarding_state, + ) + + # Still waiting for a clear signal; restate verification pathway + onboarding_state["stage"] = OnboardingStage.AWAITING_CHOICE.value + return ( + OnboardingFlowResult( + stage=OnboardingStage.AWAITING_CHOICE, + status="in_progress", + welcome_message=messages.build_new_user_welcome(), + final_message=None, + actions=_base_actions(include_verification=True), + is_verified=False, + capability_sections=capability_sections, + route_hint="onboarding", + ), + onboarding_state, + ) + + if stage_enum is OnboardingStage.ENCOURAGE_VERIFICATION: + if is_verified or intent == "confirm_verified": + onboarding_state["stage"] = OnboardingStage.VERIFIED_CAPABILITIES.value + onboarding_state["verified_acknowledged"] = True + intro = messages.build_verified_capabilities_intro(github_username) + return ( + OnboardingFlowResult( + stage=OnboardingStage.VERIFIED_CAPABILITIES, + status="completed", + welcome_message=intro, + final_message=messages.render_capabilities_text(), + actions=_exploration_suggestions(), + is_verified=True, + capability_sections=capability_sections, + route_hint="onboarding", + handoff="github_toolkit", + next_tool="github_toolkit", + ), + onboarding_state, + ) + + onboarding_state["reminders_sent"] = reminders_sent + 1 + reminder_message = messages.build_encourage_verification_message(reminders_sent) + return ( + OnboardingFlowResult( + stage=OnboardingStage.ENCOURAGE_VERIFICATION, + status="in_progress", + welcome_message=reminder_message, + final_message=messages.render_capabilities_text(), + actions=_base_actions(include_verification=True) + _exploration_suggestions(), + is_verified=False, + capability_sections=capability_sections, + route_hint="onboarding", + handoff="github_toolkit", + next_tool="github_toolkit", + metadata={"reminders_sent": onboarding_state["reminders_sent"]}, + ), + onboarding_state, + ) + + if stage_enum is OnboardingStage.VERIFIED_CAPABILITIES: + onboarding_state["stage"] = OnboardingStage.COMPLETED.value + return ( + OnboardingFlowResult( + stage=OnboardingStage.COMPLETED, + status="completed", + welcome_message=messages.build_verified_welcome(github_username), + final_message=messages.render_capabilities_text(), + actions=_exploration_suggestions(), + is_verified=True, + capability_sections=capability_sections, + route_hint="onboarding", + handoff="github_toolkit", + next_tool="github_toolkit", + ), + onboarding_state, + ) + + # Completed state - short acknowledgement + onboarding_state["stage"] = OnboardingStage.COMPLETED.value + return ( + OnboardingFlowResult( + stage=OnboardingStage.COMPLETED, + status="completed", + welcome_message=messages.build_verified_welcome(github_username), + final_message=None, + actions=_exploration_suggestions(), + is_verified=is_verified, + capability_sections=capability_sections, + route_hint="onboarding", + handoff="github_toolkit" if is_verified else None, + next_tool="github_toolkit" if is_verified else None, + ), + onboarding_state, + ) diff --git a/backend/app/agents/devrel/tool_wrappers.py b/backend/app/agents/devrel/tool_wrappers.py index 7fa10bb..ba619df 100644 --- a/backend/app/agents/devrel/tool_wrappers.py +++ b/backend/app/agents/devrel/tool_wrappers.py @@ -30,7 +30,18 @@ async def onboarding_tool_node(state: AgentState) -> Dict[str, Any]: handler_result = await handle_onboarding_node(state) tool_result = handler_result.get("task_result", {}) - return add_tool_result(state, "onboarding", tool_result) + state_update = add_tool_result(state, "onboarding", tool_result) + + if "onboarding_state" in handler_result: + state_update["onboarding_state"] = handler_result["onboarding_state"] + + next_tool = tool_result.get("next_tool") + if next_tool: + context = dict(state_update.get("context", {})) + context["force_next_tool"] = next_tool + state_update["context"] = context + + return state_update async def github_toolkit_tool_node(state: AgentState, github_toolkit) -> Dict[str, Any]: diff --git a/backend/app/agents/state.py b/backend/app/agents/state.py index 8651ca4..eed2b72 100644 --- a/backend/app/agents/state.py +++ b/backend/app/agents/state.py @@ -26,6 +26,9 @@ class AgentState(BaseModel): messages: Annotated[List[Dict[str, Any]], add] = Field(default_factory=list) context: Dict[str, Any] = Field(default_factory=dict) + # Channel-specific conversation state (e.g., onboarding workflow progress) + onboarding_state: Dict[str, Any] = Field(default_factory=dict) + # TODO: PERSISTENT MEMORY DATA (survives across sessions via summarization) user_profile: Dict[str, Any] = Field(default_factory=dict) diff --git a/backend/app/api/v1/auth.py b/backend/app/api/v1/auth.py index 738894b..0d0bacb 100644 --- a/backend/app/api/v1/auth.py +++ b/backend/app/api/v1/auth.py @@ -1,4 +1,4 @@ -from fastapi import APIRouter, Request, HTTPException, Query +from fastapi import APIRouter, Request, HTTPException, Query, Depends from fastapi.responses import HTMLResponse from app.database.supabase.client import get_supabase_client from app.services.auth.verification import find_user_by_session_and_verify, get_verification_session_info @@ -6,12 +6,23 @@ from typing import Optional import logging import asyncio +from typing import TYPE_CHECKING +from app.core.dependencies import get_app_instance +from integrations.discord.views import send_final_handoff_dm + +if TYPE_CHECKING: + from main import DevRAIApplication logger = logging.getLogger(__name__) router = APIRouter() @router.get("/callback", response_class=HTMLResponse) -async def auth_callback(request: Request, code: Optional[str] = Query(None), session: Optional[str] = Query(None)): +async def auth_callback( + request: Request, + code: Optional[str] = Query(None), + session: Optional[str] = Query(None), + app_instance: "DevRAIApplication" = Depends(get_app_instance), +): """ Handles the OAuth callback from Supabase after a user authorizes on GitHub. """ @@ -24,13 +35,13 @@ async def auth_callback(request: Request, code: Optional[str] = Query(None), ses if not session: logger.error("Missing session ID in callback") - return _error_response("Missing session ID. Please try the !verify_github command again.") + return _error_response("Missing session ID. Please try the /verify_github command again.") # Check if session is valid and not expired session_info = await get_verification_session_info(session) if not session_info: logger.error(f"Invalid or expired session ID: {session}") - return _error_response("Your verification session has expired. Please run the !verify_github command again.") + return _error_response("Your verification session has expired. Please run the /verify_github command again.") supabase = get_supabase_client() try: @@ -67,7 +78,7 @@ async def auth_callback(request: Request, code: Optional[str] = Query(None), ses if not verified_user: logger.error("User verification failed - no pending verification found") - return _error_response("No pending verification found or verification has expired. Please try the !verify_github command again.") + return _error_response("No pending verification found or verification has expired. Please try the /verify_github command again.") logger.info(f"Successfully verified user: {verified_user.id}!") @@ -78,6 +89,15 @@ async def auth_callback(request: Request, code: Optional[str] = Query(None), ses except Exception as e: logger.error(f"Error starting user profiling: {verified_user.id}: {str(e)}") + # Optional: DM the user that they're all set with final hand-off message + try: + bot = app_instance.discord_bot if app_instance else None + if bot and getattr(verified_user, "discord_id", None): + discord_user = await bot.fetch_user(int(verified_user.discord_id)) + await send_final_handoff_dm(discord_user) + except Exception as e: + logger.warning(f"Could not DM verification success: {e}") + return _success_response(github_username) except Exception as e: @@ -275,7 +295,7 @@ def _error_response(error_message: str) -> str:

{error_message}

-

Please return to Discord and try the !verify_github command again.

+

Please return to Discord and try the /verify_github command again.

If you continue to experience issues, please contact support.

diff --git a/backend/app/core/config/settings.py b/backend/app/core/config/settings.py index f314a94..5e21b0e 100644 --- a/backend/app/core/config/settings.py +++ b/backend/app/core/config/settings.py @@ -39,6 +39,13 @@ class Settings(BaseSettings): # Backend URL backend_url: str = "" + # Onboarding UX toggles + onboarding_show_oauth_button: bool = True + + # MCP configuration + mcp_server_url: Optional[str] = None + mcp_api_key: Optional[str] = None + @field_validator("supabase_url", "supabase_key", mode="before") @classmethod def _not_empty(cls, v, field): diff --git a/backend/app/integrations/mcp/client.py b/backend/app/integrations/mcp/client.py new file mode 100644 index 0000000..6aa09c7 --- /dev/null +++ b/backend/app/integrations/mcp/client.py @@ -0,0 +1,51 @@ +import os +import json +import asyncio +from typing import Any, Dict, Optional + +try: + import aiohttp +except Exception: # pragma: no cover + aiohttp = None # lazy import guard for environments without aiohttp + +DEFAULT_TIMEOUT = 15 + +class MCPClientError(Exception): + pass + +class MCPClient: + """Minimal async MCP client over HTTP. + + Exposes a simple call(method, params) that posts JSON to the MCP server. + """ + + def __init__(self, server_url: Optional[str] = None, api_key: Optional[str] = None): + self.server_url = server_url or os.getenv("MCP_SERVER_URL") + self.api_key = api_key or os.getenv("MCP_API_KEY") + + def available(self) -> bool: + return bool(self.server_url) and aiohttp is not None + + async def call(self, method: str, params: Dict[str, Any], timeout: int = DEFAULT_TIMEOUT) -> Dict[str, Any]: + if not self.available(): + raise MCPClientError("MCP not configured or aiohttp missing") + + payload = {"method": method, "params": params} + headers = {"Content-Type": "application/json"} + if self.api_key: + headers["Authorization"] = f"Bearer {self.api_key}" + + async with aiohttp.ClientSession() as session: + try: + async with session.post(self.server_url, headers=headers, json=payload, timeout=timeout) as resp: + text = await resp.text() + if resp.status >= 400: + raise MCPClientError(f"MCP HTTP {resp.status}: {text}") + try: + data = json.loads(text) + except json.JSONDecodeError: + raise MCPClientError("MCP returned invalid JSON") + return data + except asyncio.TimeoutError as e: + raise MCPClientError(f"MCP timeout: {str(e)}") + diff --git a/backend/integrations/discord/cogs.py b/backend/integrations/discord/cogs.py index 1b01813..9f884b6 100644 --- a/backend/integrations/discord/cogs.py +++ b/backend/integrations/discord/cogs.py @@ -1,14 +1,22 @@ +import logging + import discord from discord import app_commands from discord.ext import commands, tasks -import logging + +from app.agents.devrel.onboarding.messages import ( + build_encourage_verification_message, + build_new_user_welcome, + build_verified_capabilities_intro, + build_verified_welcome, +) +from app.core.config import settings from app.core.orchestration.queue_manager import AsyncQueueManager, QueuePriority -from app.services.auth.supabase import login_with_github from app.services.auth.management import get_or_create_user_by_discord +from app.services.auth.supabase import login_with_github from app.services.auth.verification import create_verification_session, cleanup_expired_tokens from integrations.discord.bot import DiscordBot -from integrations.discord.views import OAuthView -from app.core.config import settings +from integrations.discord.views import OAuthView, OnboardingView, build_final_handoff_embed logger = logging.getLogger(__name__) @@ -178,4 +186,125 @@ async def verify_github(self, interaction: discord.Interaction): async def setup(bot: commands.Bot): """This function is called by the bot to load the cog.""" - await bot.add_cog(DevRelCommands(bot, bot.queue_manager)) \ No newline at end of file + await bot.add_cog(DevRelCommands(bot, bot.queue_manager)) + await bot.add_cog(OnboardingCog(bot)) + + +class OnboardingCog(commands.Cog): + """Handles onboarding flow: welcome DM, verification prompt, and skip option.""" + + def __init__(self, bot: DiscordBot): + self.bot = bot + + def _build_welcome_embed(self, member: discord.abc.User) -> discord.Embed: + welcome_text = build_new_user_welcome() + first_paragraph, _, remainder = welcome_text.partition("\n\n") + if first_paragraph.startswith("πŸ‘‹ "): + first_paragraph = first_paragraph[2:] + + description_blocks = [first_paragraph] + if remainder: + description_blocks.append(remainder.strip()) + + embed = discord.Embed( + title="Welcome to Devr.AI! πŸ‘‹", + description="\n\n".join(description_blocks).strip(), + color=discord.Color.blue(), + ) + embed.set_author(name=member.display_name if hasattr(member, "display_name") else str(member)) + embed.set_footer(text="Link expires after a short time for security.") + return embed + + @app_commands.command(name="onboarding_test", description="DM the onboarding flow to yourself (dev only)") + async def onboarding_test(self, interaction: discord.Interaction): + await interaction.response.defer(ephemeral=True) + user = interaction.user + status = await self._send_onboarding_flow(user) + messages = { + "verified": "Sent final hand-off DM (already verified).", + "session_unavailable": "Sent fallback DMs (session unavailable).", + "auth_unavailable": "Sent fallback DMs (no auth URL).", + "onboarding_sent": "Sent onboarding DM to you.", + "dm_forbidden": "I can’t DM you (DMs disabled).", + "error": "Hit an error while sending onboarding DM.", + } + await interaction.followup.send(messages.get(status, "Completed."), ephemeral=True) + + @commands.Cog.listener() + async def on_member_join(self, member: discord.Member): + # We don't need different behavior here; just run the shared flow + await self._send_onboarding_flow(member) + + async def _send_onboarding_flow(self, user: discord.abc.User) -> str: + """Shared onboarding flow used by both /onboarding_test and on_member_join. + + Returns a status string for telemetry/UX messages: + - "verified": user already linked; final hand-off sent + - "session_unavailable": could not create verification session; fallback DMs sent + - "auth_unavailable": could not get OAuth URL; fallback DMs sent + - "onboarding_sent": welcome DM with buttons sent + - "dm_forbidden": cannot DM the user + - "error": unexpected error (fallback attempted) + """ + try: + # Ensure DB record exists + profile = await get_or_create_user_by_discord( + discord_id=str(user.id), + display_name=getattr(user, "display_name", str(user)), + discord_username=getattr(user, "name", str(user)), + avatar_url=str(user.avatar.url) if getattr(user, "avatar", None) else None, + ) + + # Already verified: send final hand-off and finish + if getattr(profile, "is_verified", False) and getattr(profile, "github_id", None): + try: + await user.send(build_verified_welcome(profile.github_username)) + await user.send(build_verified_capabilities_intro(profile.github_username)) + await user.send(embed=build_final_handoff_embed()) + except Exception: + pass + return "verified" + + # Determine whether to show OAuth button + show_oauth_button = getattr(settings, 'onboarding_show_oauth_button', True) + auth_url = None + + if show_oauth_button: + # Create verification session + session_id = await create_verification_session(str(user.id)) + if not session_id: + try: + await user.send("I couldn't start verification right now. You can use /verify_github anytime.") + await user.send(build_encourage_verification_message(reminder_count=1)) + await user.send(embed=build_final_handoff_embed()) + except Exception: + pass + return "session_unavailable" + + # Generate GitHub OAuth URL via Supabase + callback_url = f"{settings.backend_url}/v1/auth/callback?session={session_id}" + auth = await login_with_github(redirect_to=callback_url) + auth_url = auth.get("url") + if not auth_url: + try: + await user.send("Couldn't generate a verification link. Please use /verify_github.") + await user.send(build_encourage_verification_message(reminder_count=1)) + await user.send(embed=build_final_handoff_embed()) + except Exception: + pass + return "auth_unavailable" + + # Send welcome DM with actions (auth_url may be None when button disabled) + embed = self._build_welcome_embed(user) + await user.send(embed=embed, view=OnboardingView(auth_url)) + return "onboarding_sent" + except discord.Forbidden: + return "dm_forbidden" + except Exception as e: + logger.error(f"onboarding flow error: {e}") + try: + await user.send("I hit an error. You can still run /verify_github and /help.") + await user.send(build_encourage_verification_message(reminder_count=1)) + except Exception: + pass + return "error" diff --git a/backend/integrations/discord/views.py b/backend/integrations/discord/views.py index db62d5c..807d6dd 100644 --- a/backend/integrations/discord/views.py +++ b/backend/integrations/discord/views.py @@ -1,5 +1,38 @@ import discord +from app.agents.devrel.onboarding.messages import ( + CAPABILITIES_INTRO_TEXT, + CAPABILITIES_OUTRO_TEXT, + CAPABILITY_SECTIONS, +) +from app.services.auth.management import get_or_create_user_by_discord + + +def build_final_handoff_embed() -> discord.Embed: + """Create the final hand-off embed describing capabilities.""" + embed = discord.Embed( + title="You're all set!", + description=CAPABILITIES_INTRO_TEXT, + color=discord.Color.green(), + ) + for section in CAPABILITY_SECTIONS: + examples = "\n".join(f"β€’ {example}" for example in section["examples"]) + embed.add_field(name=section["title"], value=examples, inline=False) + + embed.add_field(name="Ready when you are", value=CAPABILITIES_OUTRO_TEXT, inline=False) + embed.set_footer(text="Use /help anytime to see commands.") + return embed + + +async def send_final_handoff_dm(user: discord.abc.User): + """Send the final hand-off message to a user via DM.""" + try: + embed = build_final_handoff_embed() + await user.send(embed=embed) + except Exception: + # Fail silently to avoid crashing flows if DMs are closed or similar + pass + class OAuthView(discord.ui.View): """View with OAuth button.""" @@ -14,3 +47,74 @@ def __init__(self, oauth_url: str, provider_name: str): url=oauth_url ) self.add_item(button) + + +class OnboardingView(discord.ui.View): + """View shown in onboarding DM with optional GitHub connect link and Skip button.""" + + def __init__(self, oauth_url: str | None): + super().__init__(timeout=300) + # Link button to start GitHub OAuth (optional) + if oauth_url: + self.add_item( + discord.ui.Button( + label="Connect GitHub", + style=discord.ButtonStyle.link, + url=oauth_url, + ) + ) + + @discord.ui.button( + label="I've verified", + style=discord.ButtonStyle.primary, + custom_id="onboarding_check_verified", + ) + async def check_verified( # type: ignore[override] + self, interaction: discord.Interaction, button: discord.ui.Button + ): + await interaction.response.defer(ephemeral=True, thinking=False) + + try: + profile = await get_or_create_user_by_discord( + discord_id=str(interaction.user.id), + display_name=getattr(interaction.user, "display_name", str(interaction.user)), + discord_username=getattr(interaction.user, "name", str(interaction.user)), + avatar_url=str(interaction.user.avatar.url) if getattr(interaction.user, "avatar", None) else None, + ) + except Exception: + await interaction.followup.send( + "❌ I couldn't confirm right now. Please use `/verification_status` to check manually.", + ephemeral=True, + ) + return + + if getattr(profile, "is_verified", False) and getattr(profile, "github_id", None): + for item in self.children: + if isinstance(item, discord.ui.Button) and item.style != discord.ButtonStyle.link: + item.disabled = True + await interaction.followup.send( + "βœ… Your GitHub connection is confirmed! I've sent over the capability menu.", + ephemeral=True, + ) + await send_final_handoff_dm(interaction.user) + try: + await interaction.message.edit(view=self) + except Exception: + pass + else: + await interaction.followup.send( + "I still don't see a linked GitHub account. Run `/verify_github` and try again in a moment.", + ephemeral=True, + ) + + @discord.ui.button(label="Skip for now", style=discord.ButtonStyle.secondary) + async def skip(self, interaction: discord.Interaction, button: discord.ui.Button): # type: ignore[override] + # Send the final hand-off DM and disable the view buttons + await send_final_handoff_dm(interaction.user) + for item in self.children: + item.disabled = True + try: + await interaction.response.edit_message(view=self) + except Exception: + # If edit fails (e.g., message deleted), ignore + pass From f5a9a8061c9cd07820e35434d530088521540a40 Mon Sep 17 00:00:00 2001 From: Dharya jasuja Date: Mon, 6 Oct 2025 09:47:42 +0530 Subject: [PATCH 2/6] Delete backend/app/integrations/mcp/client.py it was committed by mistake ,i was experimenting with MCPs --- backend/app/integrations/mcp/client.py | 51 -------------------------- 1 file changed, 51 deletions(-) delete mode 100644 backend/app/integrations/mcp/client.py diff --git a/backend/app/integrations/mcp/client.py b/backend/app/integrations/mcp/client.py deleted file mode 100644 index 6aa09c7..0000000 --- a/backend/app/integrations/mcp/client.py +++ /dev/null @@ -1,51 +0,0 @@ -import os -import json -import asyncio -from typing import Any, Dict, Optional - -try: - import aiohttp -except Exception: # pragma: no cover - aiohttp = None # lazy import guard for environments without aiohttp - -DEFAULT_TIMEOUT = 15 - -class MCPClientError(Exception): - pass - -class MCPClient: - """Minimal async MCP client over HTTP. - - Exposes a simple call(method, params) that posts JSON to the MCP server. - """ - - def __init__(self, server_url: Optional[str] = None, api_key: Optional[str] = None): - self.server_url = server_url or os.getenv("MCP_SERVER_URL") - self.api_key = api_key or os.getenv("MCP_API_KEY") - - def available(self) -> bool: - return bool(self.server_url) and aiohttp is not None - - async def call(self, method: str, params: Dict[str, Any], timeout: int = DEFAULT_TIMEOUT) -> Dict[str, Any]: - if not self.available(): - raise MCPClientError("MCP not configured or aiohttp missing") - - payload = {"method": method, "params": params} - headers = {"Content-Type": "application/json"} - if self.api_key: - headers["Authorization"] = f"Bearer {self.api_key}" - - async with aiohttp.ClientSession() as session: - try: - async with session.post(self.server_url, headers=headers, json=payload, timeout=timeout) as resp: - text = await resp.text() - if resp.status >= 400: - raise MCPClientError(f"MCP HTTP {resp.status}: {text}") - try: - data = json.loads(text) - except json.JSONDecodeError: - raise MCPClientError("MCP returned invalid JSON") - return data - except asyncio.TimeoutError as e: - raise MCPClientError(f"MCP timeout: {str(e)}") - From 7c6e9e6d8fce1e4d04a98a7e8d5648c532497ca1 Mon Sep 17 00:00:00 2001 From: dharya4242 Date: Fri, 17 Oct 2025 19:21:30 +0000 Subject: [PATCH 3/6] general_github_help.py will remain unchanged --- .../github/tools/general_github_help.py | 20 +++++++------------ 1 file changed, 7 insertions(+), 13 deletions(-) diff --git a/backend/app/agents/devrel/github/tools/general_github_help.py b/backend/app/agents/devrel/github/tools/general_github_help.py index 3ecd143..4493a6b 100644 --- a/backend/app/agents/devrel/github/tools/general_github_help.py +++ b/backend/app/agents/devrel/github/tools/general_github_help.py @@ -1,4 +1,4 @@ -from typing import Dict, Any, Optional +from typing import Dict, Any import logging from langchain_core.messages import HumanMessage from app.agents.devrel.nodes.handlers.web_search import _extract_search_query @@ -8,7 +8,7 @@ logger = logging.getLogger(__name__) -async def handle_general_github_help(query: str, llm, hint: Optional[str] = None) -> Dict[str, Any]: +async def handle_general_github_help(query: str, llm) -> Dict[str, Any]: """Execute general GitHub help with web search and LLM knowledge""" logger.info("Providing general GitHub help") @@ -23,16 +23,10 @@ async def handle_general_github_help(query: str, llm, hint: Optional[str] = None else: search_context = "No search results available." - if hint: - help_prompt = GENERAL_GITHUB_HELP_PROMPT.format( - query=f"{query}\n\nAssistant hint: {hint}", - search_context=search_context - ) - else: - help_prompt = GENERAL_GITHUB_HELP_PROMPT.format( - query=query, - search_context=search_context - ) + help_prompt = GENERAL_GITHUB_HELP_PROMPT.format( + query=query, + search_context=search_context + ) response = await llm.ainvoke([HumanMessage(content=help_prompt)]) @@ -53,4 +47,4 @@ async def handle_general_github_help(query: str, llm, hint: Optional[str] = None "query": query, "error": str(e), "message": "Failed to provide general GitHub help" - } + } \ No newline at end of file From 7cca3efa80179bb40732e1cd9ece359cd775f760 Mon Sep 17 00:00:00 2001 From: dharya4242 Date: Fri, 17 Oct 2025 21:13:41 +0000 Subject: [PATCH 4/6] fix onboarding profile refresh and toolkit loop --- .../app/agents/devrel/nodes/gather_context.py | 36 ++++++++++++++++--- .../agents/devrel/nodes/react_supervisor.py | 16 +++++++++ .../app/agents/devrel/onboarding/workflow.py | 4 +++ backend/app/agents/devrel/tool_wrappers.py | 16 +++++++-- 4 files changed, 65 insertions(+), 7 deletions(-) diff --git a/backend/app/agents/devrel/nodes/gather_context.py b/backend/app/agents/devrel/nodes/gather_context.py index dc2f9e7..0193254 100644 --- a/backend/app/agents/devrel/nodes/gather_context.py +++ b/backend/app/agents/devrel/nodes/gather_context.py @@ -1,7 +1,9 @@ import logging from datetime import datetime -from typing import Dict, Any +from typing import Any, Dict + from app.agents.state import AgentState +from app.services.auth.management import get_or_create_user_by_discord logger = logging.getLogger(__name__) @@ -21,17 +23,43 @@ async def gather_context_node(state: AgentState) -> Dict[str, Any]: "timestamp": datetime.now().isoformat() } + profile_data: Dict[str, Any] = dict(state.user_profile or {}) + + if state.platform.lower() == "discord": + author = state.context.get("author", {}) or {} + discord_id = author.get("id") or state.user_id + display_name = author.get("display_name") or author.get("global_name") or author.get("name") or author.get("username") + discord_username = author.get("username") or author.get("name") or author.get("display_name") + avatar_url = author.get("avatar") or author.get("avatar_url") + + if discord_id: + try: + user = await get_or_create_user_by_discord( + discord_id=str(discord_id), + display_name=str(display_name or discord_username or discord_id), + discord_username=str(discord_username or display_name or discord_id), + avatar_url=avatar_url, + ) + profile_data = user.model_dump() + except Exception as exc: # pragma: no cover - graceful degradation + logger.warning("Failed to refresh Discord user profile for %s: %s", discord_id, exc) + context_data = { - "user_profile": {"user_id": state.user_id, "platform": state.platform}, + "user_profile": profile_data or {"user_id": state.user_id, "platform": state.platform}, "conversation_context": len(state.messages) + 1, # +1 for the new message "session_info": {"session_id": state.session_id} } updated_context = {**state.context, **context_data} - return { + result: Dict[str, Any] = { "messages": [new_message], "context": updated_context, "current_task": "context_gathered", - "last_interaction_time": datetime.now() + "last_interaction_time": datetime.now(), } + + if profile_data: + result["user_profile"] = profile_data + + return result diff --git a/backend/app/agents/devrel/nodes/react_supervisor.py b/backend/app/agents/devrel/nodes/react_supervisor.py index e180c53..c3e278c 100644 --- a/backend/app/agents/devrel/nodes/react_supervisor.py +++ b/backend/app/agents/devrel/nodes/react_supervisor.py @@ -36,6 +36,22 @@ async def react_supervisor_node(state: AgentState, llm) -> Dict[str, Any]: "current_task": f"supervisor_forced_{forced_action}", } + if state.context.get("force_complete"): + logger.info("Supervisor forcing completion for session %s", state.session_id) + decision = { + "action": "complete", + "reasoning": "Auto-complete after onboarding hand-off", + "thinking": "", + } + updated_context = {**state.context} + updated_context.pop("force_complete", None) + updated_context["supervisor_decision"] = decision + updated_context["iteration_count"] = iteration_count + 1 + return { + "context": updated_context, + "current_task": "supervisor_forced_complete", + } + prompt = REACT_SUPERVISOR_PROMPT.format( latest_message=latest_message, platform=state.platform, diff --git a/backend/app/agents/devrel/onboarding/workflow.py b/backend/app/agents/devrel/onboarding/workflow.py index 095df7b..f7ba64b 100644 --- a/backend/app/agents/devrel/onboarding/workflow.py +++ b/backend/app/agents/devrel/onboarding/workflow.py @@ -108,6 +108,7 @@ def run_onboarding_flow( if is_verified: onboarding_state["stage"] = OnboardingStage.VERIFIED_CAPABILITIES.value onboarding_state["verified_acknowledged"] = True + onboarding_state["reminders_sent"] = 0 intro = messages.build_verified_capabilities_intro(github_username) return ( OnboardingFlowResult( @@ -145,6 +146,7 @@ def run_onboarding_flow( if is_verified or intent == "confirm_verified": onboarding_state["stage"] = OnboardingStage.VERIFIED_CAPABILITIES.value onboarding_state["verified_acknowledged"] = True + onboarding_state["reminders_sent"] = 0 intro = messages.build_verified_capabilities_intro(github_username) return ( OnboardingFlowResult( @@ -203,6 +205,7 @@ def run_onboarding_flow( if is_verified or intent == "confirm_verified": onboarding_state["stage"] = OnboardingStage.VERIFIED_CAPABILITIES.value onboarding_state["verified_acknowledged"] = True + onboarding_state["reminders_sent"] = 0 intro = messages.build_verified_capabilities_intro(github_username) return ( OnboardingFlowResult( @@ -241,6 +244,7 @@ def run_onboarding_flow( if stage_enum is OnboardingStage.VERIFIED_CAPABILITIES: onboarding_state["stage"] = OnboardingStage.COMPLETED.value + onboarding_state["reminders_sent"] = 0 return ( OnboardingFlowResult( stage=OnboardingStage.COMPLETED, diff --git a/backend/app/agents/devrel/tool_wrappers.py b/backend/app/agents/devrel/tool_wrappers.py index ba619df..f26d881 100644 --- a/backend/app/agents/devrel/tool_wrappers.py +++ b/backend/app/agents/devrel/tool_wrappers.py @@ -35,11 +35,13 @@ async def onboarding_tool_node(state: AgentState) -> Dict[str, Any]: if "onboarding_state" in handler_result: state_update["onboarding_state"] = handler_result["onboarding_state"] + context = dict(state_update.get("context", {})) next_tool = tool_result.get("next_tool") if next_tool: - context = dict(state_update.get("context", {})) context["force_next_tool"] = next_tool - state_update["context"] = context + if tool_result.get("stage") in {"verified_capabilities", "completed"}: + context["complete_after_forced_tool"] = next_tool + state_update["context"] = context return state_update @@ -65,4 +67,12 @@ async def github_toolkit_tool_node(state: AgentState, github_toolkit) -> Dict[st "status": "error" } - return add_tool_result(state, "github_toolkit", tool_result) + state_update = add_tool_result(state, "github_toolkit", tool_result) + + context = dict(state_update.get("context", {})) + if context.get("complete_after_forced_tool") == "github_toolkit": + context.pop("complete_after_forced_tool", None) + context["force_complete"] = True + state_update["context"] = context + + return state_update From abb6690875d2b7c3cb58d1595d8d61d0c471ac24 Mon Sep 17 00:00:00 2001 From: dharya4242 Date: Thu, 23 Oct 2025 14:14:22 +0000 Subject: [PATCH 5/6] address CodeRabbit review comments --- backend/app/core/config/settings.py | 14 +++++--------- backend/integrations/discord/cogs.py | 28 ++++++++++++++++++---------- 2 files changed, 23 insertions(+), 19 deletions(-) diff --git a/backend/app/core/config/settings.py b/backend/app/core/config/settings.py index eb29b8a..1349a02 100644 --- a/backend/app/core/config/settings.py +++ b/backend/app/core/config/settings.py @@ -1,6 +1,6 @@ from pydantic_settings import BaseSettings from dotenv import load_dotenv -from pydantic import field_validator,ConfigDict +from pydantic import field_validator, ConfigDict from typing import Optional load_dotenv() @@ -42,10 +42,6 @@ class Settings(BaseSettings): # Onboarding UX toggles onboarding_show_oauth_button: bool = True - # MCP configuration - mcp_server_url: Optional[str] = None - mcp_api_key: Optional[str] = None - @field_validator("supabase_url", "supabase_key", mode="before") @classmethod def _not_empty(cls, v, field): @@ -53,10 +49,10 @@ def _not_empty(cls, v, field): raise ValueError(f"{field.name} must be set") return v - model_config = ConfigDict( - env_file = ".env", - extra = "ignore" - ) # to prevent errors from extra env variables + model_config = ConfigDict( + env_file=".env", + extra="ignore" + ) # to prevent errors from extra env variables settings = Settings() diff --git a/backend/integrations/discord/cogs.py b/backend/integrations/discord/cogs.py index ffac7df..ef37647 100644 --- a/backend/integrations/discord/cogs.py +++ b/backend/integrations/discord/cogs.py @@ -437,7 +437,7 @@ async def onboarding_test(self, interaction: discord.Interaction): "session_unavailable": "Sent fallback DMs (session unavailable).", "auth_unavailable": "Sent fallback DMs (no auth URL).", "onboarding_sent": "Sent onboarding DM to you.", - "dm_forbidden": "I can’t DM you (DMs disabled).", + "dm_forbidden": "I can't DM you (DMs disabled).", , "error": "Hit an error while sending onboarding DM.", } await interaction.followup.send(messages.get(status, "Completed."), ephemeral=True) @@ -473,8 +473,10 @@ async def _send_onboarding_flow(self, user: discord.abc.User) -> str: await user.send(build_verified_welcome(profile.github_username)) await user.send(build_verified_capabilities_intro(profile.github_username)) await user.send(embed=build_final_handoff_embed()) - except Exception: - pass + except discord.Forbidden: + logger.warning(f"Cannot DM verified user {user.id} (DMs disabled)") + except Exception as e: + logger.exception(f"Failed to send verified welcome DM to user {user.id}: {e}") return "verified" # Determine whether to show OAuth button @@ -489,8 +491,10 @@ async def _send_onboarding_flow(self, user: discord.abc.User) -> str: await user.send("I couldn't start verification right now. You can use /verify_github anytime.") await user.send(build_encourage_verification_message(reminder_count=1)) await user.send(embed=build_final_handoff_embed()) - except Exception: - pass + except discord.Forbidden: + logger.warning(f"Cannot DM user {user.id} after session failure (DMs disabled)") + except Exception as e: + logger.exception(f"Failed to send session failure fallback DM to user {user.id}: {e}") return "session_unavailable" # Generate GitHub OAuth URL via Supabase @@ -502,8 +506,10 @@ async def _send_onboarding_flow(self, user: discord.abc.User) -> str: await user.send("Couldn't generate a verification link. Please use /verify_github.") await user.send(build_encourage_verification_message(reminder_count=1)) await user.send(embed=build_final_handoff_embed()) - except Exception: - pass + except discord.Forbidden: + logger.warning(f"Cannot DM user {user.id} after auth URL failure (DMs disabled)") + except Exception as e: + logger.exception(f"Failed to send auth failure fallback DM to user {user.id}: {e}") return "auth_unavailable" # Send welcome DM with actions (auth_url may be None when button disabled) @@ -513,10 +519,12 @@ async def _send_onboarding_flow(self, user: discord.abc.User) -> str: except discord.Forbidden: return "dm_forbidden" except Exception as e: - logger.error(f"onboarding flow error: {e}") + logger.exception(f"onboarding flow error: {e}") try: await user.send("I hit an error. You can still run /verify_github and /help.") await user.send(build_encourage_verification_message(reminder_count=1)) - except Exception: - pass + except discord.Forbidden: + logger.warning(f"Cannot DM user {user.id} after onboarding error (DMs disabled)") + except Exception as send_error: + logger.exception(f"Failed to send error fallback DM to user {user.id}: {send_error}") return "error" From af72240c86a5593b1c8f2d686d26c8a399ecce5a Mon Sep 17 00:00:00 2001 From: dharya4242 Date: Thu, 23 Oct 2025 14:31:42 +0000 Subject: [PATCH 6/6] added comma by mistake --- backend/integrations/discord/cogs.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/integrations/discord/cogs.py b/backend/integrations/discord/cogs.py index ef37647..64fd0b7 100644 --- a/backend/integrations/discord/cogs.py +++ b/backend/integrations/discord/cogs.py @@ -437,7 +437,7 @@ async def onboarding_test(self, interaction: discord.Interaction): "session_unavailable": "Sent fallback DMs (session unavailable).", "auth_unavailable": "Sent fallback DMs (no auth URL).", "onboarding_sent": "Sent onboarding DM to you.", - "dm_forbidden": "I can't DM you (DMs disabled).", , + "dm_forbidden": "I can't DM you (DMs disabled).", "error": "Hit an error while sending onboarding DM.", } await interaction.followup.send(messages.get(status, "Completed."), ephemeral=True)