feat: send DM notification when image is flagged (#690)

When Claude flags an uploaded image (track cover or album cover) as
sensitive, send a DM to the admin with details about the flag.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>

authored by zzstoatzz.io Claude Opus 4.5 and committed by GitHub de4250fb c62c6631

Changed files
+76 -2
backend
src
backend
+56
backend/src/backend/_internal/notifications.py
··· 68 68 self.dm_client = None 69 69 self.recipient_did = None 70 70 71 + async def send_image_flag_notification( 72 + self, 73 + image_id: str, 74 + severity: str, 75 + categories: list[str], 76 + context: str, 77 + ): 78 + """send notification about a flagged image. 79 + 80 + args: 81 + image_id: R2 storage ID of the flagged image 82 + severity: severity level (low, medium, high) 83 + categories: list of violated policy categories 84 + context: where the image was uploaded (e.g., "track cover", "album cover") 85 + """ 86 + if not self.dm_client or not self.recipient_did: 87 + logger.warning( 88 + "dm client not authenticated or recipient not set, skipping notification" 89 + ) 90 + return 91 + 92 + try: 93 + dm = self.dm_client.chat.bsky.convo 94 + 95 + convo_response = await dm.get_convo_for_members( 96 + models.ChatBskyConvoGetConvoForMembers.Params( 97 + members=[self.recipient_did] 98 + ) 99 + ) 100 + 101 + if not convo_response.convo or not convo_response.convo.id: 102 + raise ValueError("failed to get conversation ID") 103 + 104 + convo_id = convo_response.convo.id 105 + 106 + categories_str = ", ".join(categories) if categories else "unspecified" 107 + message_text = ( 108 + f"🚨 image flagged on {settings.app.name}\n\n" 109 + f"context: {context}\n" 110 + f"image_id: {image_id}\n" 111 + f"severity: {severity}\n" 112 + f"categories: {categories_str}" 113 + ) 114 + 115 + await dm.send_message( 116 + models.ChatBskyConvoSendMessage.Data( 117 + convo_id=convo_id, 118 + message=models.ChatBskyConvoDefs.MessageInput(text=message_text), 119 + ) 120 + ) 121 + 122 + logger.info(f"sent image flag notification for {image_id}") 123 + 124 + except Exception: 125 + logger.exception(f"error sending image flag notification for {image_id}") 126 + 71 127 async def send_track_notification(self, track: Track): 72 128 """send notification about a new track.""" 73 129 if not self.dm_client or not self.recipient_did:
+11 -1
backend/src/backend/api/albums.py
··· 26 26 from backend._internal import require_artist_profile 27 27 from backend._internal.auth import get_session 28 28 from backend._internal.moderation_client import get_moderation_client 29 + from backend._internal.notifications import notification_service 29 30 from backend.config import settings 30 31 from backend.models import Album, Artist, Track, TrackLike, get_db 31 32 from backend.schemas import TrackResponse ··· 453 454 ".png": "image/png", 454 455 ".webp": "image/webp", 455 456 }.get(ext, "image/png") 456 - await client.scan_image(bytes(image_data), image_id, content_type) 457 + result = await client.scan_image( 458 + bytes(image_data), image_id, content_type 459 + ) 457 460 # if image is flagged, it's automatically added to sensitive_images 458 461 # by the moderation service. the image is still saved and returned. 462 + if not result.is_safe: 463 + await notification_service.send_image_flag_notification( 464 + image_id=image_id, 465 + severity=result.severity, 466 + categories=result.violated_categories, 467 + context="album cover", 468 + ) 459 469 except Exception as e: 460 470 # log but don't block upload - moderation is best-effort 461 471 logger.warning("image moderation failed for %s: %s", image_id, e)
+9 -1
backend/src/backend/api/tracks/metadata_service.py
··· 13 13 from backend._internal.atproto.handles import resolve_handle 14 14 from backend._internal.image import ImageFormat 15 15 from backend._internal.moderation_client import get_moderation_client 16 + from backend._internal.notifications import notification_service 16 17 from backend.config import settings 17 18 from backend.models import Track 18 19 from backend.storage import storage ··· 139 140 try: 140 141 client = get_moderation_client() 141 142 content_type = image_format.mime_type if image_format else "image/png" 142 - await client.scan_image(image_data, image_id, content_type) 143 + result = await client.scan_image(image_data, image_id, content_type) 143 144 # note: if image is flagged, it's automatically added to sensitive_images 144 145 # by the moderation service. the image is still saved and returned - 145 146 # sensitive images are just blurred in the UI, not rejected. 147 + if not result.is_safe: 148 + await notification_service.send_image_flag_notification( 149 + image_id=image_id, 150 + severity=result.severity, 151 + categories=result.violated_categories, 152 + context="track cover", 153 + ) 146 154 except Exception as e: 147 155 # log but don't block upload - moderation is best-effort 148 156 logger.warning("image moderation failed for %s: %s", image_id, e)