Skip to content

Commit 10cda99

Browse files
committed
direct message implementation
1 parent 0f839b9 commit 10cda99

File tree

9 files changed

+299
-22
lines changed

9 files changed

+299
-22
lines changed

backend/apps/slack/MANIFEST.yaml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -133,6 +133,7 @@ settings:
133133
- app_mention
134134
- member_joined_channel
135135
- message.channels
136+
- message.im
136137
- team_join
137138
interactivity:
138139
is_enabled: true

backend/apps/slack/admin/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
"""Slack app admin."""
22

3+
from .chat import ChatAdmin
34
from .conversation import ConversationAdmin
45
from .event import EventAdmin
56
from .member import MemberAdmin

backend/apps/slack/admin/chat.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
"""Chat admin configuration."""
2+
3+
from django.contrib import admin
4+
5+
from apps.slack.models.chat import Chat
6+
7+
8+
class ChatAdmin(admin.ModelAdmin):
9+
"""Admin for Chat model."""
10+
11+
list_display = ("user", "workspace", "created_at")
12+
list_filter = ("user", "workspace")
13+
search_fields = ("user__username", "workspace__name")
14+
15+
16+
admin.site.register(Chat, ChatAdmin)

backend/apps/slack/common/handlers/ai.py

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66

77
from apps.ai.agent.tools.rag.rag_tool import RagTool
88
from apps.slack.blocks import markdown
9+
from apps.slack.models import Chat, Member, Workspace
910

1011
logger = logging.getLogger(__name__)
1112

@@ -46,6 +47,59 @@ def process_ai_query(query: str) -> str | None:
4647
return rag_tool.query(question=query)
4748

4849

50+
def get_dm_blocks(query: str, user_id: str, workspace_id: str) -> list[dict]:
51+
"""Get AI response blocks for DM with conversation context.
52+
53+
Args:
54+
query (str): The user's question.
55+
user_id (str): Slack user ID.
56+
workspace_id (str): Slack workspace ID.
57+
58+
Returns:
59+
list: A list of Slack blocks representing the AI response.
60+
61+
"""
62+
ai_response = process_dm_ai_query(query.strip(), user_id, workspace_id)
63+
64+
if ai_response:
65+
return [markdown(ai_response)]
66+
return get_error_blocks()
67+
68+
69+
def process_dm_ai_query(query: str, user_id: str, workspace_id: str) -> str | None:
70+
"""Process the AI query with DM conversation context.
71+
72+
Args:
73+
query (str): The user's question.
74+
user_id (str): Slack user ID.
75+
workspace_id (str): Slack workspace ID.
76+
77+
Returns:
78+
str | None: The AI response or None if error occurred.
79+
80+
"""
81+
user = Member.objects.get(slack_user_id=user_id)
82+
workspace = Workspace.objects.get(slack_workspace_id=workspace_id)
83+
84+
chat = Chat.update_data(user, workspace)
85+
context = chat.get_context(limit_exchanges=20)
86+
87+
rag_tool = RagTool(
88+
chat_model="gpt-4o",
89+
embedding_model="text-embedding-3-small",
90+
)
91+
92+
if context:
93+
enhanced_query = f"Conversation context:\n{context}\n\nCurrent question: {query}"
94+
else:
95+
enhanced_query = query
96+
97+
response = rag_tool.query(question=enhanced_query)
98+
chat.add_to_context(query, response)
99+
100+
return response
101+
102+
49103
def get_error_blocks() -> list[dict]:
50104
"""Get error response blocks.
51105

backend/apps/slack/events/message_posted.py

Lines changed: 63 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,22 @@
1-
"""Slack message event template."""
1+
"""Slack message event handler for OWASP NestBot."""
22

33
import logging
44
from datetime import timedelta
55

66
import django_rq
77

88
from apps.ai.common.constants import QUEUE_RESPONSE_TIME_MINUTES
9+
from apps.slack.common.handlers.ai import get_dm_blocks
910
from apps.slack.common.question_detector import QuestionDetector
1011
from apps.slack.events.event import EventBase
11-
from apps.slack.models import Conversation, Member, Message
12+
from apps.slack.models import Conversation, Member, Message, Workspace
1213
from apps.slack.services.message_auto_reply import generate_ai_reply_if_unanswered
1314

1415
logger = logging.getLogger(__name__)
1516

1617

1718
class MessagePosted(EventBase):
18-
"""Handles new messages posted in channels."""
19+
"""Handles new messages posted in channels or direct messages."""
1920

2021
event_type = "message"
2122

@@ -24,25 +25,30 @@ def __init__(self):
2425
self.question_detector = QuestionDetector()
2526

2627
def handle_event(self, event, client):
27-
"""Handle an incoming message event."""
28+
"""Handle incoming Slack message events."""
2829
if event.get("subtype") or event.get("bot_id"):
29-
logger.info("Ignored message due to subtype, bot_id, or thread_ts.")
30+
logger.info("Ignored message due to subtype or bot_id.")
31+
return
32+
33+
channel_id = event.get("channel")
34+
user_id = event.get("user")
35+
text = event.get("text", "")
36+
channel_type = event.get("channel_type")
37+
38+
if channel_type == "im":
39+
self.handle_dm(event, client, channel_id, user_id, text)
3040
return
3141

3242
if event.get("thread_ts"):
3343
try:
3444
Message.objects.filter(
3545
slack_message_id=event.get("thread_ts"),
36-
conversation__slack_channel_id=event.get("channel"),
46+
conversation__slack_channel_id=channel_id,
3747
).update(has_replies=True)
3848
except Message.DoesNotExist:
3949
logger.warning("Thread message not found.")
4050
return
4151

42-
channel_id = event.get("channel")
43-
user_id = event.get("user")
44-
text = event.get("text", "")
45-
4652
try:
4753
conversation = Conversation.objects.get(
4854
slack_channel_id=channel_id,
@@ -71,3 +77,50 @@ def handle_event(self, event, client):
7177
generate_ai_reply_if_unanswered,
7278
message.id,
7379
)
80+
81+
def handle_dm(self, event, client, channel_id, user_id, text):
82+
"""Handle direct messages with NestBot (DMs)."""
83+
workspace_id = event.get("team")
84+
85+
if not workspace_id:
86+
try:
87+
channel_info = client.conversations_info(channel=channel_id)
88+
workspace_id = channel_info["channel"]["team"]
89+
except Exception:
90+
logger.exception("Failed to fetch workspace ID for DM.")
91+
return
92+
93+
try:
94+
Member.objects.get(slack_user_id=user_id, workspace__slack_workspace_id=workspace_id)
95+
except Member.DoesNotExist:
96+
try:
97+
user_info = client.users_info(user=user_id)
98+
workspace = Workspace.objects.get(slack_workspace_id=workspace_id)
99+
Member.update_data(user_info["user"], workspace, save=True)
100+
logger.info("Created new member for DM")
101+
except Exception:
102+
logger.exception("Failed to create member for DM.")
103+
return
104+
105+
thread_ts = event.get("thread_ts")
106+
107+
try:
108+
response_blocks = get_dm_blocks(text, user_id, workspace_id)
109+
if response_blocks:
110+
client.chat_postMessage(
111+
channel=channel_id,
112+
blocks=response_blocks,
113+
text=text,
114+
thread_ts=thread_ts,
115+
)
116+
117+
except Exception:
118+
logger.exception("Error processing DM")
119+
client.chat_postMessage(
120+
channel=channel_id,
121+
text=(
122+
"I'm sorry, I'm having trouble processing your message right now. "
123+
"Please try again later."
124+
),
125+
thread_ts=thread_ts,
126+
)
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
# Generated by Django 5.2.6 on 2025-09-26 19:24
2+
3+
import django.db.models.deletion
4+
import django.utils.timezone
5+
from django.db import migrations, models
6+
7+
8+
class Migration(migrations.Migration):
9+
dependencies = [
10+
("slack", "0019_conversation_is_nest_bot_assistant_enabled"),
11+
]
12+
13+
operations = [
14+
migrations.CreateModel(
15+
name="Chat",
16+
fields=[
17+
(
18+
"id",
19+
models.BigAutoField(
20+
auto_created=True, primary_key=True, serialize=False, verbose_name="ID"
21+
),
22+
),
23+
("nest_created_at", models.DateTimeField(auto_now_add=True)),
24+
("nest_updated_at", models.DateTimeField(auto_now=True)),
25+
("context", models.TextField(blank=True)),
26+
(
27+
"created_at",
28+
models.DateTimeField(
29+
default=django.utils.timezone.now, verbose_name="Created at"
30+
),
31+
),
32+
("is_active", models.BooleanField(default=True)),
33+
(
34+
"user",
35+
models.ForeignKey(
36+
on_delete=django.db.models.deletion.CASCADE,
37+
related_name="chats",
38+
to="slack.member",
39+
),
40+
),
41+
(
42+
"workspace",
43+
models.ForeignKey(
44+
on_delete=django.db.models.deletion.CASCADE,
45+
related_name="chats",
46+
to="slack.workspace",
47+
),
48+
),
49+
],
50+
options={
51+
"db_table": "slack_chat",
52+
"ordering": ["-created_at"],
53+
"unique_together": {("user", "workspace")},
54+
},
55+
),
56+
]

backend/apps/slack/models/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
from .chat import Chat
12
from .conversation import Conversation
23
from .event import Event
34
from .member import Member

backend/apps/slack/models/chat.py

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
"""Chat model for storing conversation context."""
2+
3+
from django.db import models
4+
from django.utils import timezone
5+
6+
from apps.common.models import TimestampedModel
7+
from apps.slack.models.member import Member
8+
from apps.slack.models.workspace import Workspace
9+
10+
11+
class Chat(TimestampedModel):
12+
"""Store chat conversation context for DMs."""
13+
14+
context = models.TextField(blank=True)
15+
created_at = models.DateTimeField(verbose_name="Created at", default=timezone.now)
16+
is_active = models.BooleanField(default=True)
17+
user = models.ForeignKey(Member, on_delete=models.CASCADE, related_name="chats")
18+
workspace = models.ForeignKey(Workspace, on_delete=models.CASCADE, related_name="chats")
19+
20+
class Meta:
21+
db_table = "slack_chat"
22+
unique_together = [["user", "workspace"]]
23+
ordering = ["-created_at"]
24+
25+
def __str__(self):
26+
"""Return a concise, human-readable identifier for this chat."""
27+
return f"Chat with {self.user.real_name or self.user.username} in {self.workspace.name}"
28+
29+
@staticmethod
30+
def update_data(user: Member, workspace: Workspace, *, save: bool = True) -> "Chat":
31+
"""Update or create chat data for a user in a workspace.
32+
33+
Args:
34+
user: Member instance to associate with the chat.
35+
workspace: Workspace instance to associate with the chat.
36+
save: Whether to save the chat to the database.
37+
38+
Returns:
39+
Updated or created Chat instance.
40+
41+
"""
42+
try:
43+
chat = Chat.objects.get(user=user, workspace=workspace)
44+
except Chat.DoesNotExist:
45+
chat = Chat(user=user, workspace=workspace, is_active=True)
46+
47+
if save:
48+
chat.save()
49+
50+
return chat
51+
52+
def add_to_context(self, user_message: str, bot_response: str | None = None) -> None:
53+
"""Add messages to the conversation context.
54+
55+
Args:
56+
user_message: The user's message to add to context.
57+
bot_response: The bot's response to add to context.
58+
59+
"""
60+
if not self.context:
61+
self.context = ""
62+
63+
self.context += f"User: {user_message}\n"
64+
65+
if bot_response:
66+
self.context += f"Bot: {bot_response}\n"
67+
68+
self.save(update_fields=["context"])
69+
70+
def get_context(self, limit_exchanges: int | None = None) -> str:
71+
"""Get the conversation context.
72+
73+
Args:
74+
limit_exchanges: Optional limit on number of exchanges to return.
75+
76+
Returns:
77+
The conversation context, potentially limited to recent exchanges.
78+
79+
"""
80+
if not self.context:
81+
return ""
82+
83+
if limit_exchanges is None:
84+
return self.context
85+
86+
lines = self.context.strip().split("\n")
87+
if len(lines) <= limit_exchanges * 2:
88+
return self.context
89+
90+
return "\n".join(lines[-(limit_exchanges * 2) :])

0 commit comments

Comments
 (0)