diff --git a/backend/.env.example b/backend/.env.example index f6408f67fa..5e0ca57505 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -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= diff --git a/backend/src/apps/slack/MANIFEST.yaml b/backend/src/apps/slack/MANIFEST.yaml index d5a3c28650..3fd8b605a2 100644 --- a/backend/src/apps/slack/MANIFEST.yaml +++ b/backend/src/apps/slack/MANIFEST.yaml @@ -121,6 +121,7 @@ oauth_config: - im:write - mpim:read - mpim:write + - reactions:read - reactions:write - users:read settings: @@ -134,6 +135,7 @@ settings: - app_mention - member_joined_channel - message.channels + - reaction_added - team_join interactivity: is_enabled: true diff --git a/backend/src/apps/slack/admin/__init__.py b/backend/src/apps/slack/admin/__init__.py index b3193ffb04..a5bff42930 100644 --- a/backend/src/apps/slack/admin/__init__.py +++ b/backend/src/apps/slack/admin/__init__.py @@ -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 diff --git a/backend/src/apps/slack/admin/reaction_alert.py b/backend/src/apps/slack/admin/reaction_alert.py new file mode 100644 index 0000000000..eebac240bc --- /dev/null +++ b/backend/src/apps/slack/admin/reaction_alert.py @@ -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 diff --git a/backend/src/apps/slack/admin/reaction_rule.py b/backend/src/apps/slack/admin/reaction_rule.py new file mode 100644 index 0000000000..a8f3752ae4 --- /dev/null +++ b/backend/src/apps/slack/admin/reaction_rule.py @@ -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", + "report_type", + "threshold", + "is_active", + ) + list_filter = ( + "is_active", + "report_type", + ) + search_fields = ( + "conversation__name", + "alert_channel_id", + ) diff --git a/backend/src/apps/slack/events/__init__.py b/backend/src/apps/slack/events/__init__.py index 819a67a305..44e6c75808 100644 --- a/backend/src/apps/slack/events/__init__.py +++ b/backend/src/apps/slack/events/__init__.py @@ -5,6 +5,7 @@ def configure_slack_events(): app_home_opened, app_mention, message_posted, + reaction_added, team_join, url_verification, ) diff --git a/backend/src/apps/slack/events/reaction_added.py b/backend/src/apps/slack/events/reaction_added.py new file mode 100644 index 0000000000..2c51b7e82b --- /dev/null +++ b/backend/src/apps/slack/events/reaction_added.py @@ -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: + 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) diff --git a/backend/src/apps/slack/events/url_verification.py b/backend/src/apps/slack/events/url_verification.py index 858d59544b..234e8c3267 100644 --- a/backend/src/apps/slack/events/url_verification.py +++ b/backend/src/apps/slack/events/url_verification.py @@ -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): + """Acknowledge Slack URL verification challenges.""" + ack(event["challenge"]) diff --git a/backend/src/apps/slack/migrations/0023_reactionalert_reactionrule.py b/backend/src/apps/slack/migrations/0023_reactionalert_reactionrule.py new file mode 100644 index 0000000000..3c77241eb5 --- /dev/null +++ b/backend/src/apps/slack/migrations/0023_reactionalert_reactionrule.py @@ -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")}, + }, + ), + ] diff --git a/backend/src/apps/slack/migrations/0024_reactionalert_reporter_user_ids.py b/backend/src/apps/slack/migrations/0024_reactionalert_reporter_user_ids.py new file mode 100644 index 0000000000..98caf30bee --- /dev/null +++ b/backend/src/apps/slack/migrations/0024_reactionalert_reporter_user_ids.py @@ -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." + ), + ), + ), + ] diff --git a/backend/src/apps/slack/migrations/0025_alter_reactionrule_report_type.py b/backend/src/apps/slack/migrations/0025_alter_reactionrule_report_type.py new file mode 100644 index 0000000000..17d009edce --- /dev/null +++ b/backend/src/apps/slack/migrations/0025_alter_reactionrule_report_type.py @@ -0,0 +1,22 @@ +# Generated by Django 6.0.8 on 2026-08-17 01:08 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("slack", "0024_reactionalert_reporter_user_ids"), + ] + + operations = [ + migrations.AlterField( + model_name="reactionrule", + name="report_type", + field=models.CharField( + choices=[("spam", "Spam")], + default="spam", + help_text="Report category recorded when this reaction rule triggers.", + max_length=64, + ), + ), + ] diff --git a/backend/src/apps/slack/migrations/0026_rename_reactionrule_is_enabled.py b/backend/src/apps/slack/migrations/0026_rename_reactionrule_is_enabled.py new file mode 100644 index 0000000000..8698ae0969 --- /dev/null +++ b/backend/src/apps/slack/migrations/0026_rename_reactionrule_is_enabled.py @@ -0,0 +1,17 @@ +# Generated by Django 6.0.8 on 2026-08-17 01:09 + +from django.db import migrations + + +class Migration(migrations.Migration): + dependencies = [ + ("slack", "0025_alter_reactionrule_report_type"), + ] + + operations = [ + migrations.RenameField( + model_name="reactionrule", + old_name="is_enabled", + new_name="is_active", + ), + ] diff --git a/backend/src/apps/slack/migrations/0027_alter_reactionrule_emoji_name.py b/backend/src/apps/slack/migrations/0027_alter_reactionrule_emoji_name.py new file mode 100644 index 0000000000..ff94be025d --- /dev/null +++ b/backend/src/apps/slack/migrations/0027_alter_reactionrule_emoji_name.py @@ -0,0 +1,23 @@ +# Generated by Django 6.0.8 on 2026-08-17 01:28 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("slack", "0026_rename_reactionrule_is_enabled"), + ] + + operations = [ + migrations.AlterField( + model_name="reactionrule", + name="emoji_name", + field=models.CharField( + help_text=( + "Slack emoji name that triggers this reaction rule, without leading or " + "trailing colons (spam, not :spam:)." + ), + max_length=64, + ), + ), + ] diff --git a/backend/src/apps/slack/migrations/0028_reactionrule_emojis.py b/backend/src/apps/slack/migrations/0028_reactionrule_emojis.py new file mode 100644 index 0000000000..66b4933487 --- /dev/null +++ b/backend/src/apps/slack/migrations/0028_reactionrule_emojis.py @@ -0,0 +1,39 @@ +from django.db import migrations, models + + +def copy_emoji_name_to_emojis(apps, _schema_editor): + """Copy each reaction rule's single emoji name into the emojis list.""" + reaction_rule_model = apps.get_model("slack", "ReactionRule") + for rule in reaction_rule_model.objects.all(): + rule.emojis = [rule.emoji_name] if rule.emoji_name else [] + rule.save(update_fields=["emojis"]) + + +class Migration(migrations.Migration): + dependencies = [ + ("slack", "0027_alter_reactionrule_emoji_name"), + ] + + operations = [ + migrations.AddField( + model_name="reactionrule", + name="emojis", + field=models.JSONField( + blank=True, + default=list, + help_text="Slack emojis that trigger this reaction rule.", + ), + ), + migrations.RunPython( + copy_emoji_name_to_emojis, + migrations.RunPython.noop, + ), + migrations.AlterUniqueTogether( + name="reactionrule", + unique_together={("conversation", "report_type")}, + ), + migrations.RemoveField( + model_name="reactionrule", + name="emoji_name", + ), + ] diff --git a/backend/src/apps/slack/migrations/0029_alter_reactionrule_threshold.py b/backend/src/apps/slack/migrations/0029_alter_reactionrule_threshold.py new file mode 100644 index 0000000000..832294b91f --- /dev/null +++ b/backend/src/apps/slack/migrations/0029_alter_reactionrule_threshold.py @@ -0,0 +1,19 @@ +import django.core.validators +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("slack", "0028_reactionrule_emojis"), + ] + + operations = [ + migrations.AlterField( + model_name="reactionrule", + name="threshold", + field=models.PositiveSmallIntegerField( + default=10, + validators=[django.core.validators.MinValueValidator(1)], + ), + ), + ] diff --git a/backend/src/apps/slack/migrations/0030_alter_reaction_help_text.py b/backend/src/apps/slack/migrations/0030_alter_reaction_help_text.py new file mode 100644 index 0000000000..d212027284 --- /dev/null +++ b/backend/src/apps/slack/migrations/0030_alter_reaction_help_text.py @@ -0,0 +1,30 @@ +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("slack", "0029_alter_reactionrule_threshold"), + ] + + operations = [ + migrations.AlterField( + model_name="reactionrule", + name="alert_user_ids", + field=models.JSONField( + blank=True, + default=list, + help_text="Slack user IDs mentioned when this reaction rule triggers.", + ), + ), + migrations.AlterField( + model_name="reactionalert", + name="reporter_user_ids", + field=models.JSONField( + blank=True, + default=list, + help_text=( + "Slack user IDs that reacted with a listed emoji when the alert was posted." + ), + ), + ), + ] diff --git a/backend/src/apps/slack/migrations/0031_reactionrule_unique_conversation_report_type.py b/backend/src/apps/slack/migrations/0031_reactionrule_unique_conversation_report_type.py new file mode 100644 index 0000000000..f82b4ac1fe --- /dev/null +++ b/backend/src/apps/slack/migrations/0031_reactionrule_unique_conversation_report_type.py @@ -0,0 +1,24 @@ +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("slack", "0030_alter_reaction_help_text"), + ] + + operations = [ + migrations.AlterUniqueTogether( + name="reactionrule", + unique_together=set(), + ), + migrations.AddConstraint( + model_name="reactionrule", + constraint=models.UniqueConstraint( + fields=("conversation", "report_type"), + name="unique_reactionrule_conversation_report_type", + violation_error_message=( + "A reaction rule already exists for this conversation and report type." + ), + ), + ), + ] diff --git a/backend/src/apps/slack/models/__init__.py b/backend/src/apps/slack/models/__init__.py index 3bbe0878de..d83cba8c25 100644 --- a/backend/src/apps/slack/models/__init__.py +++ b/backend/src/apps/slack/models/__init__.py @@ -2,4 +2,6 @@ from .event import Event from .member import Member from .message import Message +from .reaction_alert import ReactionAlert +from .reaction_rule import ReactionRule from .workspace import Workspace diff --git a/backend/src/apps/slack/models/reaction_alert.py b/backend/src/apps/slack/models/reaction_alert.py new file mode 100644 index 0000000000..3c64a82f21 --- /dev/null +++ b/backend/src/apps/slack/models/reaction_alert.py @@ -0,0 +1,126 @@ +"""Store emitted Slack reaction alerts.""" + +from uuid import uuid4 + +from django.core.cache import cache +from django.db import IntegrityError, models + +from apps.common.models import TimestampedModel +from apps.slack.models.conversation import Conversation + +LOCK_TTL_SECONDS = 120 + + +class ReactionAlert(TimestampedModel): + """Record that an alert was already sent for a reported message.""" + + class Meta: + """Model options.""" + + db_table = "slack_reaction_alerts" + unique_together = ("conversation", "message_ts", "report_type") + + alert_message_ts = models.CharField( + max_length=32, + blank=True, + default="", + help_text="Slack timestamp of the posted reaction alert message.", + ) + message_ts = models.CharField( + max_length=32, + help_text="Slack timestamp of the message that triggered the alert.", + ) + reaction_count = models.PositiveSmallIntegerField(default=0) + report_type = models.CharField( + max_length=64, + help_text="Report category for the emitted reaction alert.", + ) + reporter_user_ids = models.JSONField( + blank=True, + default=list, + help_text="Slack user IDs that reacted with a listed emoji when the alert was posted.", + ) + + # FKs. + conversation = models.ForeignKey(Conversation, on_delete=models.CASCADE) + + @staticmethod + def acquire(conversation: Conversation, message_ts: str, report_type: str) -> str | None: + """Return a lock owner if this process should post the alert.""" + if ReactionAlert.exists_for(conversation, message_ts, report_type): + return None + + key = ReactionAlert.lock_key(conversation, message_ts, report_type) + owner = uuid4().hex + if not cache.add(key, owner, timeout=LOCK_TTL_SECONDS): + return None + + if ReactionAlert.exists_for(conversation, message_ts, report_type): + ReactionAlert.release(conversation, message_ts, report_type, owner) + return None + + return owner + + @staticmethod + def exists_for(conversation: Conversation, message_ts: str, report_type: str) -> bool: + """Return True if an alert was already recorded for this message.""" + return ReactionAlert.objects.filter( + conversation=conversation, + message_ts=message_ts, + report_type=report_type, + ).exists() + + @staticmethod + def lock_key(conversation: Conversation, message_ts: str, report_type: str) -> str: + """Return the cache key for an in-flight reaction alert.""" + return f"slack:reaction-alert:{conversation.pk}:{message_ts}:{report_type}" + + @staticmethod + def record( + conversation: Conversation, + message_ts: str, + report_type: str, + reaction_count: int, + alert_message_ts: str, + *, + reporter_user_ids: list[str], + ) -> None: + """Store that an alert was sent, ignoring a concurrent unique insert.""" + try: + ReactionAlert.objects.create( + alert_message_ts=alert_message_ts, + conversation=conversation, + message_ts=message_ts, + reaction_count=reaction_count, + report_type=report_type, + reporter_user_ids=reporter_user_ids, + ) + except IntegrityError: + if ReactionAlert.exists_for(conversation, message_ts, report_type): + return + raise + + @staticmethod + def release( + conversation: Conversation, + message_ts: str, + report_type: str, + owner: str, + ) -> None: + """Release the in-flight lock if this process still owns it.""" + key = ReactionAlert.lock_key(conversation, message_ts, report_type) + if cache.get(key) == owner: + cache.delete(key) + + @staticmethod + def renew( + conversation: Conversation, + message_ts: str, + report_type: str, + owner: str, + ) -> bool: + """Extend the lock TTL if this process still owns it.""" + key = ReactionAlert.lock_key(conversation, message_ts, report_type) + if cache.get(key) != owner: + return False + return bool(cache.touch(key, LOCK_TTL_SECONDS)) diff --git a/backend/src/apps/slack/models/reaction_rule.py b/backend/src/apps/slack/models/reaction_rule.py new file mode 100644 index 0000000000..358549d72e --- /dev/null +++ b/backend/src/apps/slack/models/reaction_rule.py @@ -0,0 +1,118 @@ +"""Store channel-specific Slack reaction rules.""" + +from django.core.exceptions import ValidationError +from django.core.validators import MinValueValidator +from django.db import models + +from apps.common.models import TimestampedModel +from apps.slack.models.conversation import Conversation +from apps.slack.utils.reaction import format_emojis + + +class ReactionRule(TimestampedModel): + """Channel-specific emoji threshold and alert target.""" + + class ReportType(models.TextChoices): + """Reaction report category choices.""" + + SPAM = "spam", "Spam" + + class Meta: + """Model options.""" + + constraints = [ + models.UniqueConstraint( + fields=("conversation", "report_type"), + name="unique_reactionrule_conversation_report_type", + violation_error_message=( + "A reaction rule already exists for this conversation and report type." + ), + ), + ] + db_table = "slack_reaction_rules" + + alert_channel_id = models.CharField( + max_length=50, + help_text="Slack channel ID where reaction alerts are posted.", + ) + alert_user_ids = models.JSONField( + blank=True, + default=list, + help_text="Slack user IDs mentioned when this reaction rule triggers.", + ) + emojis = models.JSONField( + blank=True, + default=list, + help_text="Slack emojis that trigger this reaction rule.", + ) + is_active = models.BooleanField(default=True) + report_type = models.CharField( + max_length=64, + choices=ReportType.choices, + default=ReportType.SPAM, + help_text="Report category recorded when this reaction rule triggers.", + ) + threshold = models.PositiveSmallIntegerField(default=10, validators=[MinValueValidator(1)]) + + # FKs. + conversation = models.ForeignKey(Conversation, on_delete=models.CASCADE) + + def __str__(self): + """Human readable representation.""" + return f"{self.conversation} {format_emojis(self.emojis)}".strip() + + def clean(self): + """Validate emojis and reject overlap with other active rules on the channel.""" + super().clean() + names = self.emojis + if not isinstance(names, list) or not names: + raise ValidationError({"emojis": "Enter at least one Slack emoji."}) + + cleaned = [] + seen: set[str] = set() + for raw_name in names: + if not isinstance(raw_name, str) or not raw_name.strip(): + raise ValidationError({"emojis": "Emojis must be non-empty strings."}) + name = raw_name.strip() + if name.startswith(":") or name.endswith(":"): + raise ValidationError( + {"emojis": "Enter emojis without colons (spam, not :spam:)."} + ) + if name in seen: + raise ValidationError({"emojis": f"Duplicate emoji: {name}."}) + seen.add(name) + cleaned.append(name) + self.emojis = cleaned + + if not self.conversation_id: + return + + others = ReactionRule.objects.filter( + conversation_id=self.conversation_id, + is_active=True, + ) + if self.pk: + others = others.exclude(pk=self.pk) + overlap = seen.intersection( + name for other in others if isinstance(other.emojis, list) for name in other.emojis + ) + if overlap: + raise ValidationError( + { + "emojis": ( + "These emojis are already used by another rule on this " + f"channel: {', '.join(sorted(overlap))}." + ) + } + ) + + @staticmethod + def for_emoji(channel_id: str, emoji_name: str) -> "ReactionRule | None": + """Get the active reaction rule for a channel and emoji, if configured.""" + for rule in ReactionRule.objects.select_related("conversation").filter( + conversation__slack_channel_id=channel_id, + is_active=True, + ): + if isinstance(rule.emojis, list) and emoji_name in rule.emojis: + return rule + return None diff --git a/backend/src/apps/slack/utils/reaction.py b/backend/src/apps/slack/utils/reaction.py new file mode 100644 index 0000000000..eb68d9af76 --- /dev/null +++ b/backend/src/apps/slack/utils/reaction.py @@ -0,0 +1,60 @@ +"""Slack reaction event helpers.""" + + +def format_emojis(emojis: object) -> str: + """Return Slack emoji markup for the given emoji names.""" + if not emojis or not isinstance(emojis, list): + return "" + return " ".join(f":{name}:" for name in emojis if name) + + +def mention_users(user_ids: object) -> str: + """Return Slack mention markup for the given user IDs.""" + if not user_ids or not isinstance(user_ids, list): + return "" + return " ".join(f"<@{user_id}>" for user_id in user_ids if user_id) + + +def parse_message_reaction(event): + """Return channel, message timestamp, and emoji for a message reaction event.""" + item = event.get("item", {}) + channel_id = item.get("channel") + emoji_name = event.get("reaction") + message_ts = item.get("ts") + if ( + item.get("type") != "message" + or not channel_id + or not emoji_name + or not message_ts + or not event.get("user") + ): + return None + return channel_id, message_ts, emoji_name + + +def reaction_from_payload(payload, emojis: object) -> tuple[int, list[str], str, list[str]] | None: + """Return unique reporters, permalink, and matched emoji names from reactions.get.""" + if not isinstance(emojis, list): + return None + wanted = {name for name in emojis if name} + if not wanted: + return None + + message = payload.get("message") or {} + permalink = message.get("permalink") or "" + reporters: list[str] = [] + seen: set[str] = set() + matched_names: set[str] = set() + for reaction in message.get("reactions") or []: + name = reaction.get("name") + if name not in wanted: + continue + matched_names.add(name) + for user_id in reaction.get("users") or []: + if user_id and user_id not in seen: + seen.add(user_id) + reporters.append(user_id) + if not matched_names: + return None + matched_emojis = [name for name in emojis if name in matched_names] + return len(reporters), reporters, permalink, matched_emojis diff --git a/backend/tests/unit/apps/slack/admin/reaction_alert_test.py b/backend/tests/unit/apps/slack/admin/reaction_alert_test.py new file mode 100644 index 0000000000..714a9af7e7 --- /dev/null +++ b/backend/tests/unit/apps/slack/admin/reaction_alert_test.py @@ -0,0 +1,14 @@ +from django.contrib.admin.sites import AdminSite + +from apps.slack.admin.reaction_alert import ReactionAlertAdmin +from apps.slack.models.reaction_alert import ReactionAlert + + +class TestReactionAlertAdmin: + def test_alerts_are_read_only_records(self): + """Test reaction alert records cannot be manually changed in admin.""" + admin = ReactionAlertAdmin(model=ReactionAlert, admin_site=AdminSite()) + + assert not admin.has_add_permission(request=None) + assert not admin.has_delete_permission(request=None) + assert "reporter_user_ids" in admin.readonly_fields diff --git a/backend/tests/unit/apps/slack/events/reaction_added_test.py b/backend/tests/unit/apps/slack/events/reaction_added_test.py new file mode 100644 index 0000000000..198fe60cc9 --- /dev/null +++ b/backend/tests/unit/apps/slack/events/reaction_added_test.py @@ -0,0 +1,356 @@ +from unittest.mock import Mock + +from slack_sdk.errors import SlackApiError, SlackRequestError + +from apps.slack.events.reaction_added import ReactionAdded + +EVENT = { + "item": {"type": "message", "channel": "C_SOURCE", "ts": "123.000"}, + "reaction": "spam", + "user": "U_REACTOR", +} + +LOCK_OWNER = "lock-owner" + +REACTIONS_GET = { + "message": { + "permalink": "https://slack.test/message", + "reactions": [ + {"name": "spam", "count": 2, "users": ["U_REACTOR", "U_OTHER"]}, + ], + } +} + + +def slack_error(error="invalid_auth"): + """Build a Slack API error for reaction tests.""" + return SlackApiError(message="API error", response={"error": error}) + + +def mock_rule(threshold=1, emojis=None): + """Build a reaction rule mock.""" + return Mock( + alert_channel_id="C_ALERT", + alert_user_ids=["U_MOD"], + conversation=Mock(), + emojis=emojis or ["spam"], + report_type="spam", + threshold=threshold, + ) + + +def mock_client(payload=None): + """Build a Slack client mock with reactions and post responses.""" + client = Mock() + client.reactions_get.return_value = payload if payload is not None else REACTIONS_GET + client.chat_postMessage.return_value = {"ts": "999.000"} + return client + + +def patch_rule_lookup(mocker, rule=None, *, missing=False): + """Patch reaction rule lookup.""" + mocker.patch( + "apps.slack.events.reaction_added.ReactionRule.for_emoji", + return_value=None if missing else (rule or mock_rule()), + ) + + +def patch_alert_lock(mocker, *, acquired=True, recorded=False, renewed=True): + """Patch reaction alert lookup, lock, and record helpers.""" + mocker.patch( + "apps.slack.events.reaction_added.ReactionAlert.exists_for", + return_value=recorded, + ) + acquire = mocker.patch( + "apps.slack.events.reaction_added.ReactionAlert.acquire", + return_value=LOCK_OWNER if acquired else None, + ) + mocker.patch( + "apps.slack.events.reaction_added.ReactionAlert.renew", + return_value=renewed, + ) + release = mocker.patch("apps.slack.events.reaction_added.ReactionAlert.release") + record = mocker.patch("apps.slack.events.reaction_added.ReactionAlert.record") + return acquire, release, record + + +class TestReactionAdded: + def test_handle_event_stops_when_no_rule(self, mocker): + """Test missing reaction rules skip Slack lookups and alerts.""" + client = mock_client() + patch_rule_lookup(mocker, missing=True) + acquire, _, record = patch_alert_lock(mocker) + + ReactionAdded().handle_event(EVENT, client) + + client.reactions_get.assert_not_called() + acquire.assert_not_called() + record.assert_not_called() + client.chat_postMessage.assert_not_called() + + def test_handle_event_posts_alert_and_records_it(self, mocker): + """Test threshold hit posts a Slack alert with reporters and records it.""" + client = mock_client() + rule = mock_rule(threshold=2) + patch_rule_lookup(mocker, rule) + _, release, record = patch_alert_lock(mocker) + + ReactionAdded().handle_event(EVENT, client) + + client.reactions_get.assert_called_once_with( + channel="C_SOURCE", + full=True, + timestamp="123.000", + ) + client.chat_postMessage.assert_called_once() + client.chat_getPermalink.assert_not_called() + _, kwargs = client.chat_postMessage.call_args + assert kwargs["channel"] == "C_ALERT" + assert "<@U_MOD>" in kwargs["text"] + assert "A message in <#C_SOURCE> reached the spam report threshold." in kwargs["text"] + assert ( + "Reported by: <@U_REACTOR> <@U_OTHER> using the following emojis: :spam:" + in kwargs["text"] + ) + assert "https://slack.test/message" in kwargs["text"] + record.assert_called_once_with( + rule.conversation, + "123.000", + "spam", + 2, + "999.000", + reporter_user_ids=["U_REACTOR", "U_OTHER"], + ) + release.assert_called_once_with(rule.conversation, "123.000", "spam", LOCK_OWNER) + + def test_handle_event_skips_recorded_alert(self, mocker): + """Test an existing reaction alert skips Slack lookups and posts.""" + client = mock_client() + patch_rule_lookup(mocker) + acquire, release, record = patch_alert_lock(mocker, recorded=True) + + ReactionAdded().handle_event(EVENT, client) + + client.reactions_get.assert_not_called() + client.chat_postMessage.assert_not_called() + acquire.assert_not_called() + record.assert_not_called() + release.assert_not_called() + + def test_handle_event_skips_existing_alert(self, mocker): + """Test an in-flight reaction alert suppresses duplicate Slack posts.""" + client = mock_client() + patch_rule_lookup(mocker) + _, release, record = patch_alert_lock(mocker, acquired=False) + + ReactionAdded().handle_event(EVENT, client) + + client.reactions_get.assert_called_once() + client.chat_postMessage.assert_not_called() + record.assert_not_called() + release.assert_not_called() + + def test_handle_event_stops_below_threshold(self, mocker): + """Test reactions below the configured threshold do not create alerts.""" + client = mock_client( + { + "message": { + "permalink": "https://slack.test/message", + "reactions": [{"name": "spam", "count": 1, "users": ["U_REACTOR"]}], + } + } + ) + patch_rule_lookup(mocker, mock_rule(threshold=2)) + acquire, _, record = patch_alert_lock(mocker) + + ReactionAdded().handle_event(EVENT, client) + + acquire.assert_not_called() + record.assert_not_called() + client.chat_postMessage.assert_not_called() + + def test_handle_event_releases_lock_when_post_fails(self, mocker): + """Test a Slack post failure releases the lock and does not record an alert.""" + client = mock_client() + client.chat_postMessage.side_effect = slack_error("channel_not_found") + rule = mock_rule() + patch_rule_lookup(mocker, rule) + _, release, record = patch_alert_lock(mocker) + + ReactionAdded().handle_event(EVENT, client) + + record.assert_not_called() + release.assert_called_once_with(rule.conversation, "123.000", "spam", LOCK_OWNER) + + def test_handle_event_skips_post_when_lock_lease_is_lost(self, mocker): + """Test a lost in-flight lock skips posting and still releases the owned lock.""" + client = mock_client() + rule = mock_rule() + patch_rule_lookup(mocker, rule) + _, release, record = patch_alert_lock(mocker, renewed=False) + + ReactionAdded().handle_event(EVENT, client) + + client.chat_postMessage.assert_not_called() + record.assert_not_called() + release.assert_called_once_with(rule.conversation, "123.000", "spam", LOCK_OWNER) + + def test_handle_event_skips_when_reactions_get_fails(self, mocker): + """Test a reactions.get failure does not post or lock.""" + client = mock_client() + client.reactions_get.side_effect = slack_error("message_not_found") + patch_rule_lookup(mocker) + acquire, release, record = patch_alert_lock(mocker) + + ReactionAdded().handle_event(EVENT, client) + + acquire.assert_not_called() + record.assert_not_called() + release.assert_not_called() + client.chat_postMessage.assert_not_called() + + def test_handle_event_skips_when_emoji_missing_from_message(self, mocker): + """Test a reactions.get payload without the emoji does not post.""" + client = mock_client( + { + "message": { + "permalink": "https://slack.test/message", + "reactions": [{"name": "thumbsup", "count": 4, "users": ["U_OTHER"]}], + } + } + ) + patch_rule_lookup(mocker) + acquire, _, record = patch_alert_lock(mocker) + + ReactionAdded().handle_event(EVENT, client) + + acquire.assert_not_called() + record.assert_not_called() + client.chat_postMessage.assert_not_called() + + def test_handle_event_counts_unique_reporters_across_emojis(self, mocker): + """Test a shared emoji list counts each Slack user once toward the threshold.""" + client = mock_client( + { + "message": { + "permalink": "https://slack.test/message", + "reactions": [ + {"name": "spam", "count": 2, "users": ["U1", "U2"]}, + {"name": "flag", "count": 2, "users": ["U2", "U3"]}, + ], + } + } + ) + rule = mock_rule(threshold=3, emojis=["spam", "flag"]) + patch_rule_lookup(mocker, rule) + _, _, record = patch_alert_lock(mocker) + + ReactionAdded().handle_event(EVENT, client) + + client.chat_postMessage.assert_called_once() + _, kwargs = client.chat_postMessage.call_args + assert ( + "Reported by: <@U1> <@U2> <@U3> using the following emojis: :spam: :flag:" + in kwargs["text"] + ) + record.assert_called_once_with( + rule.conversation, + "123.000", + "spam", + 3, + "999.000", + reporter_user_ids=["U1", "U2", "U3"], + ) + + def test_handle_event_names_only_matched_emojis(self, mocker): + """Test unused configured emojis are omitted from the alert text.""" + client = mock_client() + rule = mock_rule(threshold=2, emojis=["spam", "flag"]) + patch_rule_lookup(mocker, rule) + patch_alert_lock(mocker) + + ReactionAdded().handle_event(EVENT, client) + + _, kwargs = client.chat_postMessage.call_args + assert ( + "Reported by: <@U_REACTOR> <@U_OTHER> using the following emojis: :spam:" + in kwargs["text"] + ) + assert ":flag:" not in kwargs["text"] + + def test_handle_event_skips_non_message_items(self, mocker): + """Test file reactions do not look up Slack reactions.""" + client = mock_client() + patch_rule_lookup(mocker) + event = {**EVENT, "item": {"type": "file", "channel": "C_SOURCE"}} + + ReactionAdded().handle_event(event, client) + + client.reactions_get.assert_not_called() + client.chat_postMessage.assert_not_called() + + def test_handle_event_fetches_permalink_when_missing(self, mocker): + """Test a missing reactions.get permalink is fetched before posting.""" + client = mock_client( + { + "message": { + "reactions": [{"name": "spam", "count": 1, "users": ["U_REACTOR"]}], + } + } + ) + client.chat_getPermalink.return_value = {"permalink": "https://slack.test/fallback"} + patch_rule_lookup(mocker) + _, _, record = patch_alert_lock(mocker) + + ReactionAdded().handle_event(EVENT, client) + + client.chat_getPermalink.assert_called_once_with( + channel="C_SOURCE", + message_ts="123.000", + ) + client.chat_postMessage.assert_called_once() + _, kwargs = client.chat_postMessage.call_args + assert "https://slack.test/fallback" in kwargs["text"] + record.assert_called_once() + + def test_handle_event_posts_when_permalink_unavailable(self, mocker): + """Test a threshold hit is still posted when no permalink can be fetched.""" + client = mock_client( + { + "message": { + "reactions": [{"name": "spam", "count": 1, "users": ["U_REACTOR"]}], + } + } + ) + client.chat_getPermalink.side_effect = slack_error("message_not_found") + patch_rule_lookup(mocker) + _, release, record = patch_alert_lock(mocker) + + ReactionAdded().handle_event(EVENT, client) + + client.chat_postMessage.assert_called_once() + _, kwargs = client.chat_postMessage.call_args + assert "https://" not in kwargs["text"] + record.assert_called_once() + release.assert_called_once() + + def test_handle_event_posts_when_permalink_transport_fails(self, mocker): + """Test a permalink transport failure still posts the threshold alert.""" + client = mock_client( + { + "message": { + "reactions": [{"name": "spam", "count": 1, "users": ["U_REACTOR"]}], + } + } + ) + client.chat_getPermalink.side_effect = SlackRequestError("connection failed") + patch_rule_lookup(mocker) + _, release, record = patch_alert_lock(mocker) + + ReactionAdded().handle_event(EVENT, client) + + client.chat_postMessage.assert_called_once() + _, kwargs = client.chat_postMessage.call_args + assert "https://" not in kwargs["text"] + record.assert_called_once() + release.assert_called_once() diff --git a/backend/tests/unit/apps/slack/events/url_verification_test.py b/backend/tests/unit/apps/slack/events/url_verification_test.py index d74910ac6d..41d05d707d 100644 --- a/backend/tests/unit/apps/slack/events/url_verification_test.py +++ b/backend/tests/unit/apps/slack/events/url_verification_test.py @@ -1,28 +1,15 @@ -from unittest.mock import MagicMock, patch +from unittest.mock import MagicMock -import pytest - -from apps.slack.apps import SlackConfig from apps.slack.events.url_verification import UrlVerification -@pytest.fixture -def mock_slack_app(): - mock_app = MagicMock() - mock_app.event = MagicMock() - return mock_app - - -@pytest.fixture -def slack_bot(mock_slack_app): - """Provide mocked SlackConfig.app, restored after the test.""" - with patch.object(SlackConfig, "app", mock_slack_app): - yield SlackConfig - - class TestUrlVerification: - def test_url_verification_handler(self, slack_bot): + def test_url_verification_handler_acknowledges_challenge(self): + """Test Slack URL verification responds with the challenge value.""" event = {"challenge": "test_challenge"} + ack = MagicMock() handler = UrlVerification() - response = handler.handle_event(event, client=None) - assert response == "test_challenge" + + handler.handler(event, client=None, ack=ack) + + ack.assert_called_once_with("test_challenge") diff --git a/backend/tests/unit/apps/slack/models/reaction_alert_test.py b/backend/tests/unit/apps/slack/models/reaction_alert_test.py new file mode 100644 index 0000000000..3ec6405d6a --- /dev/null +++ b/backend/tests/unit/apps/slack/models/reaction_alert_test.py @@ -0,0 +1,167 @@ +from unittest.mock import Mock + +import pytest +from django.db import IntegrityError + +from apps.slack.models.reaction_alert import LOCK_TTL_SECONDS, ReactionAlert + +LOCK_OWNER = "lock-owner" +LOCK_KEY = "slack:reaction-alert:7:123.000:spam" + + +class TestReactionAlert: + def test_lock_key(self): + """Test lock keys include conversation, message, and report type.""" + conversation = Mock(pk=7) + + assert ReactionAlert.lock_key(conversation, "123.000", "spam") == LOCK_KEY + + def test_acquire_returns_none_when_alert_exists(self, mocker): + """Test an existing alert row skips the in-flight lock.""" + conversation = Mock(pk=7) + manager = mocker.patch("apps.slack.models.reaction_alert.ReactionAlert.objects") + manager.filter.return_value.exists.return_value = True + add = mocker.patch("apps.slack.models.reaction_alert.cache.add") + + assert ReactionAlert.acquire(conversation, "123.000", "spam") is None + add.assert_not_called() + + def test_acquire_returns_none_when_lock_held(self, mocker): + """Test a held cache lock skips posting.""" + conversation = Mock(pk=7) + manager = mocker.patch("apps.slack.models.reaction_alert.ReactionAlert.objects") + manager.filter.return_value.exists.return_value = False + mocker.patch("apps.slack.models.reaction_alert.cache.add", return_value=False) + + assert ReactionAlert.acquire(conversation, "123.000", "spam") is None + + def test_acquire_returns_owner_when_lock_taken(self, mocker): + """Test a free lock is acquired when no alert row exists.""" + conversation = Mock(pk=7) + manager = mocker.patch("apps.slack.models.reaction_alert.ReactionAlert.objects") + manager.filter.return_value.exists.return_value = False + mocker.patch( + "apps.slack.models.reaction_alert.uuid4", + return_value=Mock(hex=LOCK_OWNER), + ) + add = mocker.patch("apps.slack.models.reaction_alert.cache.add", return_value=True) + + assert ReactionAlert.acquire(conversation, "123.000", "spam") == LOCK_OWNER + add.assert_called_once_with(LOCK_KEY, LOCK_OWNER, timeout=LOCK_TTL_SECONDS) + + def test_acquire_releases_lock_if_alert_appears_after_add(self, mocker): + """Test a row created during lock acquisition releases the owned lock.""" + conversation = Mock(pk=7) + manager = mocker.patch("apps.slack.models.reaction_alert.ReactionAlert.objects") + manager.filter.return_value.exists.side_effect = [False, True] + mocker.patch( + "apps.slack.models.reaction_alert.uuid4", + return_value=Mock(hex=LOCK_OWNER), + ) + mocker.patch("apps.slack.models.reaction_alert.cache.add", return_value=True) + mocker.patch("apps.slack.models.reaction_alert.cache.get", return_value=LOCK_OWNER) + delete = mocker.patch("apps.slack.models.reaction_alert.cache.delete") + + assert ReactionAlert.acquire(conversation, "123.000", "spam") is None + delete.assert_called_once_with(LOCK_KEY) + + def test_exists_for_uses_conversation_message_and_report_type(self, mocker): + """Test exists_for looks up the unique alert row.""" + conversation = Mock() + manager = mocker.patch("apps.slack.models.reaction_alert.ReactionAlert.objects") + manager.filter.return_value.exists.return_value = True + + assert ReactionAlert.exists_for(conversation, "123.000", "spam") is True + manager.filter.assert_called_once_with( + conversation=conversation, + message_ts="123.000", + report_type="spam", + ) + + def test_record_creates_alert(self, mocker): + """Test a successful Slack post is stored as a reaction alert.""" + conversation = Mock() + manager = mocker.patch("apps.slack.models.reaction_alert.ReactionAlert.objects") + + ReactionAlert.record( + conversation, + "123.000", + "spam", + 2, + "999.000", + reporter_user_ids=["U1", "U2"], + ) + + manager.create.assert_called_once_with( + alert_message_ts="999.000", + conversation=conversation, + message_ts="123.000", + reaction_count=2, + report_type="spam", + reporter_user_ids=["U1", "U2"], + ) + + def test_record_ignores_existing_row(self, mocker): + """Test a concurrent unique insert does not raise.""" + manager = mocker.patch("apps.slack.models.reaction_alert.ReactionAlert.objects") + manager.create.side_effect = IntegrityError + manager.filter.return_value.exists.return_value = True + + ReactionAlert.record( + Mock(), + "123.000", + "spam", + 1, + "999.000", + reporter_user_ids=["U1"], + ) + + def test_record_raises_unexpected_integrity_error(self, mocker): + """Test a non-unique IntegrityError is not swallowed.""" + manager = mocker.patch("apps.slack.models.reaction_alert.ReactionAlert.objects") + manager.create.side_effect = IntegrityError + manager.filter.return_value.exists.return_value = False + + with pytest.raises(IntegrityError): + ReactionAlert.record( + Mock(), + "123.000", + "spam", + 1, + "999.000", + reporter_user_ids=["U1"], + ) + + def test_release_deletes_owned_lock(self, mocker): + """Test release deletes the lock only when this process still owns it.""" + mocker.patch("apps.slack.models.reaction_alert.cache.get", return_value=LOCK_OWNER) + delete = mocker.patch("apps.slack.models.reaction_alert.cache.delete") + + ReactionAlert.release(Mock(pk=7), "123.000", "spam", LOCK_OWNER) + + delete.assert_called_once_with(LOCK_KEY) + + def test_release_skips_delete_when_owner_mismatch(self, mocker): + """Test release does not delete a lock taken by another process.""" + mocker.patch("apps.slack.models.reaction_alert.cache.get", return_value="other-owner") + delete = mocker.patch("apps.slack.models.reaction_alert.cache.delete") + + ReactionAlert.release(Mock(pk=7), "123.000", "spam", LOCK_OWNER) + + delete.assert_not_called() + + def test_renew_extends_owned_lock(self, mocker): + """Test renew touches the lock when this process still owns it.""" + mocker.patch("apps.slack.models.reaction_alert.cache.get", return_value=LOCK_OWNER) + touch = mocker.patch("apps.slack.models.reaction_alert.cache.touch", return_value=True) + + assert ReactionAlert.renew(Mock(pk=7), "123.000", "spam", LOCK_OWNER) is True + touch.assert_called_once_with(LOCK_KEY, LOCK_TTL_SECONDS) + + def test_renew_returns_false_when_owner_mismatch(self, mocker): + """Test renew does not extend a lock taken by another process.""" + mocker.patch("apps.slack.models.reaction_alert.cache.get", return_value="other-owner") + touch = mocker.patch("apps.slack.models.reaction_alert.cache.touch") + + assert ReactionAlert.renew(Mock(pk=7), "123.000", "spam", LOCK_OWNER) is False + touch.assert_not_called() diff --git a/backend/tests/unit/apps/slack/models/reaction_rule_test.py b/backend/tests/unit/apps/slack/models/reaction_rule_test.py new file mode 100644 index 0000000000..9e6ce8354f --- /dev/null +++ b/backend/tests/unit/apps/slack/models/reaction_rule_test.py @@ -0,0 +1,99 @@ +from unittest.mock import Mock + +import pytest +from django.core.exceptions import ValidationError + +from apps.slack.models.conversation import Conversation +from apps.slack.models.reaction_rule import ReactionRule +from apps.slack.models.workspace import Workspace + + +class TestReactionRule: + def test_str(self): + """Test reaction rule string includes conversation and emojis.""" + workspace = Workspace(name="OWASP") + conversation = Conversation(name="general", workspace=workspace) + rule = ReactionRule(conversation=conversation, emojis=["spam", "flag"]) + + assert str(rule) == "OWASP #general :spam: :flag:" + + def test_report_type_defaults_to_spam(self): + """Test report type is limited to spam.""" + assert ReactionRule.ReportType.SPAM == "spam" + assert ReactionRule.ReportType.choices == [("spam", "Spam")] + + def test_unique_conversation_report_type_constraint(self): + """Test one reaction rule is allowed per conversation and report type.""" + constraint = next( + item + for item in ReactionRule._meta.constraints + if item.name == "unique_reactionrule_conversation_report_type" + ) + + assert tuple(constraint.fields) == ("conversation", "report_type") + + def test_for_emoji_returns_matching_rule(self, mocker): + """Test reaction rule lookup returns the active rule that lists the emoji.""" + rule = Mock(emojis=["spam", "flag"]) + manager = mocker.patch("apps.slack.models.reaction_rule.ReactionRule.objects") + manager.select_related.return_value.filter.return_value = [rule] + + result = ReactionRule.for_emoji("C123", "flag") + + assert result is rule + manager.select_related.assert_called_once_with("conversation") + manager.select_related.return_value.filter.assert_called_once_with( + conversation__slack_channel_id="C123", + is_active=True, + ) + + def test_for_emoji_returns_none_when_missing(self, mocker): + """Test reaction rule lookup returns None when no rule lists the emoji.""" + manager = mocker.patch("apps.slack.models.reaction_rule.ReactionRule.objects") + manager.select_related.return_value.filter.return_value = [Mock(emojis=["spam"])] + + assert ReactionRule.for_emoji("C123", "flag") is None + + def test_clean_accepts_plain_emojis(self, mocker): + """Test valid emojis are stripped and stored.""" + manager = mocker.patch("apps.slack.models.reaction_rule.ReactionRule.objects") + manager.filter.return_value = [] + rule = ReactionRule(emojis=[" spam ", "flag"]) + rule.conversation_id = 7 + + rule.clean() + + assert rule.emojis == ["spam", "flag"] + + def test_clean_rejects_empty_emojis(self): + """Test a rule requires at least one emoji.""" + rule = ReactionRule(emojis=[]) + + with pytest.raises(ValidationError, match="at least one"): + rule.clean() + + def test_clean_rejects_colon_wrapped_names(self): + """Test emojis must be stored without Slack colons.""" + rule = ReactionRule(emojis=[":spam:"]) + + with pytest.raises(ValidationError, match="without colons"): + rule.clean() + + def test_clean_rejects_duplicate_names(self): + """Test a rule cannot list the same emoji twice.""" + rule = ReactionRule(emojis=["spam", "spam"]) + + with pytest.raises(ValidationError, match="Duplicate"): + rule.clean() + + def test_clean_rejects_overlapping_emoji_on_same_conversation(self, mocker): + """Test two active rules on one channel cannot share an emoji.""" + manager = mocker.patch("apps.slack.models.reaction_rule.ReactionRule.objects") + manager.filter.return_value = [Mock(emojis=["flag", "x"])] + rule = ReactionRule(emojis=["spam", "flag"]) + rule.conversation_id = 7 + + with pytest.raises(ValidationError, match="already used"): + rule.clean() + + manager.filter.assert_called_once_with(conversation_id=7, is_active=True) diff --git a/backend/tests/unit/apps/slack/utils/__init__.py b/backend/tests/unit/apps/slack/utils/__init__.py new file mode 100644 index 0000000000..cc54cc7df2 --- /dev/null +++ b/backend/tests/unit/apps/slack/utils/__init__.py @@ -0,0 +1 @@ +"""Slack utils unit tests.""" diff --git a/backend/tests/unit/apps/slack/utils/reaction_test.py b/backend/tests/unit/apps/slack/utils/reaction_test.py new file mode 100644 index 0000000000..8966f5d7d4 --- /dev/null +++ b/backend/tests/unit/apps/slack/utils/reaction_test.py @@ -0,0 +1,128 @@ +from apps.slack.utils.reaction import ( + format_emojis, + mention_users, + parse_message_reaction, + reaction_from_payload, +) + +EVENT = { + "item": {"type": "message", "channel": "C_SOURCE", "ts": "123.000"}, + "reaction": "spam", + "user": "U_REACTOR", +} + +PAYLOAD = { + "message": { + "permalink": "https://slack.test/message", + "reactions": [ + {"name": "thumbsup", "count": 1, "users": ["U_OTHER"]}, + {"name": "spam", "count": 2, "users": ["U_REACTOR", "U_OTHER"]}, + ], + } +} + + +class TestParseMessageReaction: + def test_parse_message_reaction_returns_details(self): + """Test message reaction events expose channel, timestamp, and emoji.""" + assert parse_message_reaction(EVENT) == ("C_SOURCE", "123.000", "spam") + + def test_parse_message_reaction_skips_non_message_items(self): + """Test file reactions are ignored.""" + event = {**EVENT, "item": {"type": "file", "channel": "C_SOURCE"}} + + assert parse_message_reaction(event) is None + + def test_parse_message_reaction_skips_missing_user(self): + """Test reaction events without a user are ignored.""" + event = {**EVENT, "user": ""} + + assert parse_message_reaction(event) is None + + +class TestReactionFromPayload: + def test_reaction_from_payload_returns_matching_emoji(self): + """Test reactions.get payloads expose unique reporters and permalink.""" + assert reaction_from_payload(PAYLOAD, ["spam"]) == ( + 2, + ["U_REACTOR", "U_OTHER"], + "https://slack.test/message", + ["spam"], + ) + + def test_reaction_from_payload_returns_none_when_emoji_missing(self): + """Test an unmatched emoji set does not produce a snapshot.""" + assert reaction_from_payload(PAYLOAD, ["flag"]) is None + + def test_reaction_from_payload_unions_unique_reporters(self): + """Test listed emojis share one unique-reporter count.""" + payload = { + "message": { + "permalink": "https://slack.test/message", + "reactions": [ + {"name": "spam", "count": 2, "users": ["U1", "U2"]}, + {"name": "flag", "count": 2, "users": ["U2", "U3"]}, + {"name": "thumbsup", "count": 4, "users": ["U4"]}, + ], + } + } + + assert reaction_from_payload(payload, ["spam", "flag"]) == ( + 3, + ["U1", "U2", "U3"], + "https://slack.test/message", + ["spam", "flag"], + ) + + def test_reaction_from_payload_returns_matched_emojis_only(self): + """Test unused configured emojis are omitted from the snapshot.""" + assert reaction_from_payload(PAYLOAD, ["spam", "flag"]) == ( + 2, + ["U_REACTOR", "U_OTHER"], + "https://slack.test/message", + ["spam"], + ) + + def test_reaction_from_payload_ignores_non_list_emojis(self): + """Test a JSON scalar emoji list does not produce a snapshot.""" + assert reaction_from_payload(PAYLOAD, "spam") is None + + +class TestFormatEmojis: + def test_format_emojis_joins_names(self): + """Test emoji names are formatted as Slack emoji markup.""" + assert format_emojis(["spam", "flag"]) == ":spam: :flag:" + + def test_format_emojis_handles_empty(self): + """Test missing emoji names produce no markup.""" + assert format_emojis([]) == "" + assert format_emojis(None) == "" + + def test_format_emojis_ignores_non_list_values(self): + """Test JSON scalars and objects are not treated as emoji lists.""" + assert format_emojis("spam") == "" + + def test_format_emojis_ignores_empty_names(self): + """Test blank emoji names are omitted from markup.""" + assert format_emojis(["", "spam", None, "flag"]) == ":spam: :flag:" + + +class TestMentionUsers: + def test_mention_users_joins_ids(self): + """Test Slack user IDs are formatted as mentions.""" + assert mention_users(["U1", "U2"]) == "<@U1> <@U2>" + + def test_mention_users_handles_empty(self): + """Test missing user IDs produce no mention markup.""" + assert mention_users([]) == "" + assert mention_users(None) == "" + + def test_mention_users_ignores_non_list_values(self): + """Test JSON scalars and objects are not treated as user ID lists.""" + assert mention_users("U123") == "" + assert mention_users({"U123": True}) == "" + assert mention_users(123) == "" + + def test_mention_users_ignores_empty_ids(self): + """Test blank user IDs are omitted from mention markup.""" + assert mention_users(["", "U1", None, "U2"]) == "<@U1> <@U2>"