Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions backend/apps/slack/admin/__init__.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
"""Slack app admin."""

from .bot_interaction import BotInteractionAdmin
from .conversation import ConversationAdmin
from .event import EventAdmin
from .member import MemberAdmin
Expand Down
87 changes: 87 additions & 0 deletions backend/apps/slack/admin/bot_interaction.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
"""Bot interaction admin configuration."""

from django.contrib import admin

from apps.slack.models.bot_interaction import BotInteraction


class BotInteractionAdmin(admin.ModelAdmin):
"""Admin for BotInteraction model."""

fieldsets = (
(
"Interaction",
{
"fields": (
"channel_id",
"user_id",
"user_message",
"bot_response",
)
},
),
(
"Classification",
{
"fields": (
"intent_category",
"confidence_score",
"tokens_used",
)
},
),
(
"Feedback",
{
"fields": (
"thumbs_up",
"slack_reply_ts",
)
},
),
)
list_display = (
"channel_id",
"user_id",
"short_message",
"intent_category",
"thumbs_up",
"tokens_used",
"nest_created_at",
)
list_filter = (
"thumbs_up",
"intent_category",
)
readonly_fields = (
"channel_id",
"user_id",
"user_message",
"bot_response",
"slack_reply_ts",
"tokens_used",
"nest_created_at",
"nest_updated_at",
)
search_fields = (
"channel_id",
"user_id",
"user_message",
"intent_category",
)

MESSAGE_TRUNCATE_LENGTH = 60

@admin.display(description="Message")
def short_message(self, obj):
"""Return truncated user message for list display."""
if not obj.user_message:
return ""
return (
obj.user_message[: self.MESSAGE_TRUNCATE_LENGTH] + "…"
if len(obj.user_message) > self.MESSAGE_TRUNCATE_LENGTH
else obj.user_message
)


admin.site.register(BotInteraction, BotInteractionAdmin)
1 change: 1 addition & 0 deletions backend/apps/slack/events/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ def configure_slack_events():
owasp_community,
project_nest,
)
from apps.slack.events.reaction_added import bot_feedback

if SlackConfig.app:
EventBase.configure_events()
22 changes: 22 additions & 0 deletions backend/apps/slack/events/member_joined_channel/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
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,
owasp_community,
project_nest,
)
from apps.slack.events.reaction_added import bot_feedback

if SlackConfig.app:
EventBase.configure_events()
1 change: 1 addition & 0 deletions backend/apps/slack/events/reaction_added/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
"""Slack reaction_added event handlers."""
66 changes: 66 additions & 0 deletions backend/apps/slack/events/reaction_added/bot_feedback.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
"""Slack reaction_added event handler for bot reply feedback."""

import logging

from apps.slack.events.event import EventBase
from apps.slack.models.bot_interaction import BotInteraction

logger = logging.getLogger(__name__)

THUMBS_UP = "thumbsup"
THUMBS_DOWN = "thumbsdown"
FEEDBACK_REACTIONS = {THUMBS_UP, THUMBS_DOWN}


class BotFeedback(EventBase):
"""Records 👍 / 👎 reactions on NestBot replies."""

event_type = "reaction_added"

def handle_event(self, event, client):
"""Handle a reaction_added event.

Checks whether the reacted-to message ts matches a BotInteraction
slack_reply_ts. If so, records the feedback. Reactions on non-bot
messages are silently ignored.

Args:
event (dict): The Slack reaction_added event payload.
client: The Slack WebClient instance (unused but required by base).

"""
reaction = event.get("reaction", "")
if reaction not in FEEDBACK_REACTIONS:
return

item = event.get("item", {})
if item.get("type") != "message":
return

reply_ts = item.get("ts", "")
if not reply_ts:
return

channel_id = item.get("channel", "")
if not channel_id:
return

interaction = (
BotInteraction.objects.filter(
slack_reply_ts=reply_ts,
channel_id=channel_id,
)
.order_by("-nest_created_at")
.first()
)
if interaction is None:
return

interaction.thumbs_up = reaction == THUMBS_UP
interaction.save(update_fields=["thumbs_up", "nest_updated_at"])

logger.info(
"Feedback recorded for BotInteraction pk=%s: %s",
interaction.pk,
"👍" if interaction.thumbs_up else "👎",
)
77 changes: 77 additions & 0 deletions backend/apps/slack/migrations/0023_bot_interaction.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
from django.db import migrations, models


class Migration(migrations.Migration):
dependencies = [
("slack", "0022_workspace_invite_link_last_alert_message_ts_and_more"),
]

operations = [
migrations.CreateModel(
name="BotInteraction",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("nest_created_at", models.DateTimeField(auto_now_add=True)),
("nest_updated_at", models.DateTimeField(auto_now=True)),
(
"channel_id",
models.CharField(max_length=64, verbose_name="Channel ID"),
),
(
"user_id",
models.CharField(max_length=64, verbose_name="User ID"),
),
("user_message", models.TextField(verbose_name="User message")),
("bot_response", models.TextField(verbose_name="Bot response")),
(
"intent_category",
models.CharField(
blank=True,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: slack_reply_ts should not be optional because it is the key used to attach 👍/👎 feedback. Allowing empty values stores interactions that can never be correlated back from reactions.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At backend/apps/slack/migrations/0023_bot_interaction.py, line 40:

<comment>`slack_reply_ts` should not be optional because it is the key used to attach 👍/👎 feedback. Allowing empty values stores interactions that can never be correlated back from reactions.</comment>

<file context>
@@ -0,0 +1,83 @@
+                (
+                    "intent_category",
+                    models.CharField(
+                        blank=True,
+                        default="",
+                        max_length=64,
</file context>

default="",
max_length=64,
verbose_name="Intent category",
),
),
(
"confidence_score",
models.FloatField(blank=True, null=True, verbose_name="Confidence score"),
),
(
"thumbs_up",
models.BooleanField(
blank=True,
help_text="True = 👍, False = 👎, None = no reaction yet.",
null=True,
verbose_name="Thumbs up",
),
),
(
"tokens_used",
models.PositiveIntegerField(default=0, verbose_name="Tokens used"),
),
(
"slack_reply_ts",
models.CharField(
blank=True,
db_index=True,
default="",
help_text="Slack message ts of the bot reply. Used to match reaction_added events.",
max_length=32,
verbose_name="Slack reply timestamp",
),
),
],
options={
"verbose_name_plural": "Bot Interactions",
"db_table": "slack_bot_interactions",
},
),
]
1 change: 1 addition & 0 deletions backend/apps/slack/models/__init__.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
from .bot_interaction import BotInteraction
from .conversation import Conversation
from .event import Event
from .member import Member
Expand Down
64 changes: 64 additions & 0 deletions backend/apps/slack/models/bot_interaction.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
"""Slack app bot interaction model."""

from django.db import models

from apps.common.models import TimestampedModel


class BotInteraction(TimestampedModel):
"""Tracks AI-generated replies and their user feedback."""

class Meta:
"""Model options."""

db_table = "slack_bot_interactions"
verbose_name_plural = "Bot Interactions"

channel_id = models.CharField(
verbose_name="Channel ID",
max_length=64,
)
user_id = models.CharField(
verbose_name="User ID",
max_length=64,
)
user_message = models.TextField(
verbose_name="User message",
)
bot_response = models.TextField(
verbose_name="Bot response",
)
intent_category = models.CharField(
verbose_name="Intent category",
max_length=64,
blank=True,
default="",
)
confidence_score = models.FloatField(
verbose_name="Confidence score",
null=True,
blank=True,
)
thumbs_up = models.BooleanField(
verbose_name="Thumbs up",
null=True,
blank=True,
help_text="True = 👍, False = 👎, None = no reaction yet.",
)
tokens_used = models.PositiveIntegerField(
verbose_name="Tokens used",
default=0,
)
slack_reply_ts = models.CharField(
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
verbose_name="Slack reply timestamp",
max_length=32,
blank=True,
default="",
db_index=True,
help_text="Slack message ts of the bot reply. Used to match reaction_added events.",
)
Comment thread
coderabbitai[bot] marked this conversation as resolved.

def __str__(self):
"""Human readable representation."""
feedback = {True: "👍", False: "👎", None: "—"}[self.thumbs_up]
return f"[{self.channel_id}] {self.user_message[:50]!r} → {feedback}"
20 changes: 19 additions & 1 deletion backend/apps/slack/services/message_auto_reply.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,14 @@

import logging

from django.db import DatabaseError
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
from apps.slack.models.bot_interaction import BotInteraction

logger = logging.getLogger(__name__)

Expand Down Expand Up @@ -45,9 +47,25 @@ def generate_ai_reply_if_unanswered(message_id: int):
if not ai_response_text:
return

client.chat_postMessage(
response = 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,
)

# Log the interaction so reaction_added can match 👍/👎 back to this reply.
reply_ts = response.get("ts", "") if response else ""
try:
BotInteraction.objects.create(
channel_id=message.conversation.slack_channel_id,
user_id=message.raw_data.get("user", ""),
user_message=message.text,
bot_response=ai_response_text,
slack_reply_ts=reply_ts,
)
except DatabaseError:
logger.exception(
"Failed to persist BotInteraction after sending Slack reply",
extra={"channel_id": message.conversation.slack_channel_id, "reply_ts": reply_ts},
)
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
"""Tests for reaction_added events."""
Loading
Loading