Skip to content
3 changes: 3 additions & 0 deletions backend/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -32,3 +32,6 @@ GITHUB_TOKEN=your-github-token
POSTGRES_DB=nest_db_dev
POSTGRES_PASSWORD=nest_user_dev_password
POSTGRES_USER=nest_user_dev

# Replace T1234567890 with the actual Slack workspace ID.
SLACK_BOT_TOKEN_T1234567890=
2 changes: 2 additions & 0 deletions backend/src/apps/slack/MANIFEST.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,7 @@ oauth_config:
- im:write
- mpim:read
- mpim:write
- reactions:read
- reactions:write
- users:read
settings:
Expand All @@ -134,6 +135,7 @@ settings:
- app_mention
- member_joined_channel
- message.channels
- reaction_added
- team_join
interactivity:
is_enabled: true
Expand Down
2 changes: 2 additions & 0 deletions backend/src/apps/slack/admin/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,4 +4,6 @@
from .event import EventAdmin
from .member import MemberAdmin
from .message import MessageAdmin
from .reaction_alert import ReactionAlertAdmin
from .reaction_rule import ReactionRuleAdmin
from .workspace import WorkspaceAdmin
39 changes: 39 additions & 0 deletions backend/src/apps/slack/admin/reaction_alert.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
"""Django admin screen for emitted Slack reaction alerts."""

from django.contrib import admin

from apps.slack.models.reaction_alert import ReactionAlert


@admin.register(ReactionAlert)
class ReactionAlertAdmin(admin.ModelAdmin):
"""Admin list/search controls for emitted reaction alerts."""

list_display = (
"conversation",
"message_ts",
"report_type",
"reaction_count",
"nest_created_at",
)
readonly_fields = (
"conversation",
"message_ts",
"report_type",
"reaction_count",
"reporter_user_ids",
"alert_message_ts",
)
search_fields = (
"conversation__name",
"message_ts",
"report_type",
)

def has_add_permission(self, request):
"""Disable manual alert creation in Django admin."""
return False

def has_delete_permission(self, request, obj=None):
"""Disable manual alert deletion in Django admin."""
return False
27 changes: 27 additions & 0 deletions backend/src/apps/slack/admin/reaction_rule.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
"""Django admin screen for Slack reaction rules."""

from django.contrib import admin

from apps.slack.models.reaction_rule import ReactionRule


@admin.register(ReactionRule)
class ReactionRuleAdmin(admin.ModelAdmin):
"""Admin list/search controls for reaction rules."""

autocomplete_fields = ("conversation",)
list_display = (
"conversation",
"emojis",
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
"report_type",
"threshold",
"is_active",
)
list_filter = (
"is_active",
"report_type",
)
search_fields = (
"conversation__name",
"alert_channel_id",
)
1 change: 1 addition & 0 deletions backend/src/apps/slack/events/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ def configure_slack_events():
app_home_opened,
app_mention,
message_posted,
reaction_added,
team_join,
url_verification,
)
Expand Down
130 changes: 130 additions & 0 deletions backend/src/apps/slack/events/reaction_added.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
"""Handle Slack reaction_added events."""

import logging

from slack_sdk.errors import SlackApiError, SlackClientError

from apps.slack.blocks import markdown
from apps.slack.events.event import EventBase
from apps.slack.models.reaction_alert import ReactionAlert
from apps.slack.models.reaction_rule import ReactionRule
from apps.slack.utils.reaction import (
format_emojis,
mention_users,
parse_message_reaction,
reaction_from_payload,
)

logger = logging.getLogger(__name__)


def fetch_permalink(client, channel_id: str, message_ts: str) -> str:
"""Return a Slack permalink for the message, or an empty string."""
try:
return (
client.chat_getPermalink(
channel=channel_id,
message_ts=message_ts,
).get("permalink")
or ""
)
except SlackApiError as e:
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
logger.warning(
"Could not fetch Slack permalink for moderation alert: %s",
e.response.get("error", "unknown_error"),
)
return ""
except SlackClientError as e:
logger.warning("Could not fetch Slack permalink for moderation alert: %s", e)
return ""


def fetch_reaction(client, channel_id: str, message_ts: str, emojis: list[str]):
"""Return Slack's current unique-reporter snapshot for the rule emojis, or None."""
try:
payload = client.reactions_get(
channel=channel_id,
full=True,
timestamp=message_ts,
)
except SlackApiError as e:
logger.warning(
"Could not fetch Slack reactions for moderation alert: %s",
e.response.get("error", "unknown_error"),
)
return None

return reaction_from_payload(payload, emojis)


class ReactionAdded(EventBase):
"""Handle reaction_added events for moderation alerts."""

event_type = "reaction_added"

def handle_event(self, event, client):
"""Post an alert when Slack shows the rule threshold is reached."""
if (details := parse_message_reaction(event)) is None:
return

channel_id, message_ts, emoji_name = details
if (rule := ReactionRule.for_emoji(channel_id, emoji_name)) is None:
return

if ReactionAlert.exists_for(rule.conversation, message_ts, rule.report_type):
return

if (snapshot := fetch_reaction(client, channel_id, message_ts, rule.emojis)) is None:
return

reaction_count, reporter_user_ids, permalink, matched_emojis = snapshot
if reaction_count < rule.threshold:
return

# Lock in-flight posts; the DB row is written only after Slack succeeds.
if (
owner := ReactionAlert.acquire(rule.conversation, message_ts, rule.report_type)
) is None:
return

try:
if not permalink:
permalink = fetch_permalink(client, channel_id, message_ts)

if ReactionAlert.renew(rule.conversation, message_ts, rule.report_type, owner):
alert_users = mention_users(rule.alert_user_ids)
reporters = mention_users(reporter_user_ids)
emojis = format_emojis(matched_emojis)
text = (
f"{alert_users}\n"
f"A message in <#{channel_id}> reached the "
f"{rule.report_type} report threshold."
)
if reporters:
text = f"{text}\nReported by: {reporters} using the following emojis: {emojis}"
if permalink:
text = f"{text}\n{permalink}"
text = text.strip()

try:
alert = client.chat_postMessage(
blocks=[markdown(text)],
channel=rule.alert_channel_id,
text=text,
)
except SlackApiError as e:
logger.warning(
"Could not post Slack moderation alert: %s",
e.response.get("error", "unknown_error"),
)
else:
ReactionAlert.record(
rule.conversation,
message_ts,
rule.report_type,
reaction_count,
alert.get("ts", ""),
reporter_user_ids=reporter_user_ids,
)
finally:
ReactionAlert.release(rule.conversation, message_ts, rule.report_type, owner)
6 changes: 3 additions & 3 deletions backend/src/apps/slack/events/url_verification.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,6 @@ class UrlVerification(EventBase):

event_type = "url_verification"

def handle_event(self, event, client):
"""Handle the URL verification event."""
return event["challenge"]
def handler(self, event, client, ack):
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
"""Acknowledge Slack URL verification challenges."""
ack(event["challenge"])
Comment thread
coderabbitai[bot] marked this conversation as resolved.
113 changes: 113 additions & 0 deletions backend/src/apps/slack/migrations/0023_reactionalert_reactionrule.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
# Generated by Django 6.0.6 on 2026-06-22 20:33

import django.core.validators
import django.db.models.deletion
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="ReactionAlert",
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)),
(
"message_ts",
models.CharField(
help_text="Slack timestamp of the message that triggered the alert.",
max_length=32,
),
),
(
"report_type",
models.CharField(
help_text="Report category for the emitted reaction alert.",
max_length=64,
),
),
("reaction_count", models.PositiveSmallIntegerField(default=0)),
(
"alert_message_ts",
models.CharField(
blank=True,
default="",
help_text="Slack timestamp of the posted reaction alert message.",
max_length=32,
),
),
(
"conversation",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE, to="slack.conversation"
),
),
],
options={
"db_table": "slack_reaction_alerts",
"unique_together": {("conversation", "message_ts", "report_type")},
},
),
migrations.CreateModel(
name="ReactionRule",
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)),
(
"emoji_name",
models.CharField(
help_text="Slack emoji name that triggers this reaction rule.",
max_length=64,
),
),
(
"report_type",
models.CharField(
help_text="Report category recorded when this reaction rule triggers.",
max_length=64,
),
),
(
"threshold",
models.PositiveSmallIntegerField(
default=3, validators=[django.core.validators.MinValueValidator(1)]
),
),
(
"alert_channel_id",
models.CharField(
help_text="Slack channel ID where reaction alerts are posted.",
max_length=50,
),
),
("alert_user_ids", models.JSONField(blank=True, default=list)),
("is_enabled", models.BooleanField(default=True)),
(
"conversation",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE, to="slack.conversation"
),
),
],
options={
"db_table": "slack_reaction_rules",
"unique_together": {("conversation", "emoji_name")},
},
),
]
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
# Generated by Django 6.0.8 on 2026-08-17 00:17

from django.db import migrations, models


class Migration(migrations.Migration):
dependencies = [
("slack", "0023_reactionalert_reactionrule"),
]

operations = [
migrations.AddField(
model_name="reactionalert",
name="reporter_user_ids",
field=models.JSONField(
blank=True,
default=list,
help_text=(
"Slack user IDs that had the triggering reaction when the alert was posted."
),
),
),
]
Loading
Loading