refactor: consolidate DM sending logic into _send_dm_to_did helper

- refactor send_track_notification to use _send_dm_to_did
- refactor send_image_flag_notification to use _send_dm_to_did
- all notification methods now have consistent error handling and logfire spans
- reduces code duplication (~50 lines)

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

Changed files
+42 -92
backend
src
backend
_internal
+42 -92
backend/src/backend/_internal/notifications.py
··· 273 273 severity: str, 274 274 categories: list[str], 275 275 context: str, 276 - ): 276 + ) -> NotificationResult | None: 277 277 """send notification about a flagged image. 278 278 279 279 args: ··· 282 282 categories: list of violated policy categories 283 283 context: where the image was uploaded (e.g., "track cover", "album cover") 284 284 """ 285 - if not self.dm_client or not self.recipient_did: 286 - logger.warning( 287 - "dm client not authenticated or recipient not set, skipping notification" 288 - ) 289 - return 290 - 291 - try: 292 - dm = self.dm_client.chat.bsky.convo 293 - 294 - convo_response = await dm.get_convo_for_members( 295 - models.ChatBskyConvoGetConvoForMembers.Params( 296 - members=[self.recipient_did] 297 - ) 298 - ) 299 - 300 - if not convo_response.convo or not convo_response.convo.id: 301 - raise ValueError("failed to get conversation ID") 285 + if not self.recipient_did: 286 + logger.warning("recipient not set, skipping notification") 287 + return None 302 288 303 - convo_id = convo_response.convo.id 289 + categories_str = ", ".join(categories) if categories else "unspecified" 290 + message_text = ( 291 + f"🚨 image flagged on {settings.app.name}\n\n" 292 + f"context: {context}\n" 293 + f"image_id: {image_id}\n" 294 + f"severity: {severity}\n" 295 + f"categories: {categories_str}" 296 + ) 304 297 305 - categories_str = ", ".join(categories) if categories else "unspecified" 306 - message_text = ( 307 - f"🚨 image flagged on {settings.app.name}\n\n" 308 - f"context: {context}\n" 309 - f"image_id: {image_id}\n" 310 - f"severity: {severity}\n" 311 - f"categories: {categories_str}" 312 - ) 313 - 314 - await dm.send_message( 315 - models.ChatBskyConvoSendMessage.Data( 316 - convo_id=convo_id, 317 - message=models.ChatBskyConvoDefs.MessageInput(text=message_text), 318 - ) 319 - ) 320 - 298 + result = await self._send_dm_to_did(self.recipient_did, message_text) 299 + if result.success: 321 300 logger.info(f"sent image flag notification for {image_id}") 301 + return result 322 302 323 - except Exception: 324 - logger.exception(f"error sending image flag notification for {image_id}") 325 - 326 - async def send_track_notification(self, track: Track): 303 + async def send_track_notification(self, track: Track) -> NotificationResult | None: 327 304 """send notification about a new track.""" 328 - if not self.dm_client or not self.recipient_did: 329 - logger.warning( 330 - "dm client not authenticated or recipient not set, skipping notification" 331 - ) 332 - return 333 - 334 - try: 335 - # create shortcut to convo methods 336 - dm = self.dm_client.chat.bsky.convo 337 - 338 - # get or create conversation with the target user 339 - convo_response = await dm.get_convo_for_members( 340 - models.ChatBskyConvoGetConvoForMembers.Params( 341 - members=[self.recipient_did] 342 - ) 343 - ) 305 + if not self.recipient_did: 306 + logger.warning("recipient not set, skipping notification") 307 + return None 344 308 345 - if not convo_response.convo or not convo_response.convo.id: 346 - raise ValueError("failed to get conversation ID") 309 + artist_handle = track.artist.handle 347 310 348 - convo_id = convo_response.convo.id 349 - 350 - # format the message with rich information 351 - artist_handle = track.artist.handle 311 + # only include link if we have a non-localhost frontend URL 312 + track_url = None 313 + frontend_url = settings.frontend.url 314 + if frontend_url and "localhost" not in frontend_url: 315 + track_url = f"{frontend_url}/track/{track.id}" 352 316 353 - # only include link if we have a non-localhost frontend URL 354 - track_url = None 355 - frontend_url = settings.frontend.url 356 - if frontend_url and "localhost" not in frontend_url: 357 - track_url = f"{frontend_url}/track/{track.id}" 358 - 359 - if track_url: 360 - message_text: str = ( 361 - f"🎵 new track on {settings.app.name}!\n\n" 362 - f"'{track.title}' by @{artist_handle}\n\n" 363 - f"listen: {track_url}\n" 364 - f"uploaded: {track.created_at.strftime('%b %d at %H:%M UTC')}" 365 - ) 366 - else: 367 - # dev environment - no link 368 - message_text: str = ( 369 - f"🎵 new track on {settings.app.name}!\n\n" 370 - f"'{track.title}' by @{artist_handle}\n" 371 - f"uploaded: {track.created_at.strftime('%b %d at %H:%M UTC')}" 372 - ) 373 - 374 - # send the DM 375 - await dm.send_message( 376 - models.ChatBskyConvoSendMessage.Data( 377 - convo_id=convo_id, 378 - message=models.ChatBskyConvoDefs.MessageInput(text=message_text), 379 - ) 317 + if track_url: 318 + message_text = ( 319 + f"🎵 new track on {settings.app.name}!\n\n" 320 + f"'{track.title}' by @{artist_handle}\n\n" 321 + f"listen: {track_url}\n" 322 + f"uploaded: {track.created_at.strftime('%b %d at %H:%M UTC')}" 323 + ) 324 + else: 325 + # dev environment - no link 326 + message_text = ( 327 + f"🎵 new track on {settings.app.name}!\n\n" 328 + f"'{track.title}' by @{artist_handle}\n" 329 + f"uploaded: {track.created_at.strftime('%b %d at %H:%M UTC')}" 380 330 ) 381 331 382 - logger.info(f"sent notification for track {track.id} to {convo_id}") 383 - 384 - except Exception: 385 - logger.exception(f"error sending notification for track {track.id}") 332 + result = await self._send_dm_to_did(self.recipient_did, message_text) 333 + if result.success: 334 + logger.info(f"sent notification for track {track.id}") 335 + return result 386 336 387 337 async def shutdown(self): 388 338 """cleanup resources."""