+8
-2
backend/src/backend/_internal/moderation.py
+8
-2
backend/src/backend/_internal/moderation.py
···
90
90
.where(Track.id == track_id)
91
91
)
92
92
if track and track.artist:
93
-
notified = await notification_service.send_copyright_notification(
93
+
(
94
+
artist_result,
95
+
admin_result,
96
+
) = await notification_service.send_copyright_notification(
94
97
track_id=track_id,
95
98
track_title=track.title,
96
99
artist_did=track.artist_did,
···
98
101
highest_score=scan.highest_score,
99
102
matches=scan.matches,
100
103
)
101
-
if notified:
104
+
# mark as notified if at least one succeeded
105
+
if (artist_result and artist_result.success) or (
106
+
admin_result and admin_result.success
107
+
):
102
108
scan.notified_at = datetime.now(UTC)
103
109
await db.commit()
104
110
+168
-71
backend/src/backend/_internal/notifications.py
+168
-71
backend/src/backend/_internal/notifications.py
···
1
1
"""notification service for relay events."""
2
2
3
3
import logging
4
+
from dataclasses import dataclass
4
5
6
+
import logfire
5
7
from atproto import AsyncClient, models
6
8
7
9
from backend.config import settings
8
10
from backend.models import Track
9
11
10
12
logger = logging.getLogger(__name__)
13
+
14
+
15
+
@dataclass
16
+
class NotificationResult:
17
+
"""result of a notification attempt."""
18
+
19
+
success: bool
20
+
recipient_did: str
21
+
error: str | None = None
22
+
error_type: str | None = None # "dm_blocked", "network", "auth", "unknown"
11
23
12
24
13
25
class NotificationService:
···
68
80
self.dm_client = None
69
81
self.recipient_did = None
70
82
71
-
async def _send_dm_to_did(self, recipient_did: str, message_text: str) -> bool:
83
+
async def _send_dm_to_did(
84
+
self, recipient_did: str, message_text: str
85
+
) -> NotificationResult:
72
86
"""send a DM to a specific DID.
73
87
74
-
returns True if sent successfully, False otherwise.
88
+
returns NotificationResult with success status and error details.
75
89
"""
76
90
if not self.dm_client:
77
-
logger.warning("dm client not authenticated, skipping notification")
78
-
return False
91
+
return NotificationResult(
92
+
success=False,
93
+
recipient_did=recipient_did,
94
+
error="dm client not authenticated",
95
+
error_type="auth",
96
+
)
79
97
80
-
try:
81
-
dm = self.dm_client.chat.bsky.convo
98
+
with logfire.span(
99
+
"send_dm",
100
+
recipient_did=recipient_did,
101
+
message_length=len(message_text),
102
+
) as span:
103
+
try:
104
+
dm = self.dm_client.chat.bsky.convo
82
105
83
-
convo_response = await dm.get_convo_for_members(
84
-
models.ChatBskyConvoGetConvoForMembers.Params(members=[recipient_did])
85
-
)
106
+
convo_response = await dm.get_convo_for_members(
107
+
models.ChatBskyConvoGetConvoForMembers.Params(
108
+
members=[recipient_did]
109
+
)
110
+
)
86
111
87
-
if not convo_response.convo or not convo_response.convo.id:
88
-
raise ValueError("failed to get conversation ID")
112
+
if not convo_response.convo or not convo_response.convo.id:
113
+
span.set_attribute("error_type", "no_convo")
114
+
return NotificationResult(
115
+
success=False,
116
+
recipient_did=recipient_did,
117
+
error="failed to get conversation ID - user may have DMs disabled",
118
+
error_type="dm_blocked",
119
+
)
89
120
90
-
await dm.send_message(
91
-
models.ChatBskyConvoSendMessage.Data(
92
-
convo_id=convo_response.convo.id,
93
-
message=models.ChatBskyConvoDefs.MessageInput(text=message_text),
121
+
await dm.send_message(
122
+
models.ChatBskyConvoSendMessage.Data(
123
+
convo_id=convo_response.convo.id,
124
+
message=models.ChatBskyConvoDefs.MessageInput(
125
+
text=message_text
126
+
),
127
+
)
94
128
)
95
-
)
96
-
return True
129
+
130
+
span.set_attribute("success", True)
131
+
return NotificationResult(success=True, recipient_did=recipient_did)
97
132
98
-
except Exception:
99
-
logger.exception(f"error sending DM to {recipient_did}")
100
-
return False
133
+
except Exception as e:
134
+
error_str = str(e)
135
+
error_type = "unknown"
136
+
137
+
# try to categorize the error
138
+
if "blocked" in error_str.lower() or "not allowed" in error_str.lower():
139
+
error_type = "dm_blocked"
140
+
elif "timeout" in error_str.lower() or "connect" in error_str.lower():
141
+
error_type = "network"
142
+
elif "auth" in error_str.lower() or "401" in error_str:
143
+
error_type = "auth"
144
+
145
+
span.set_attribute("error_type", error_type)
146
+
span.set_attribute("error", error_str)
147
+
logger.exception(f"error sending DM to {recipient_did}")
148
+
149
+
return NotificationResult(
150
+
success=False,
151
+
recipient_did=recipient_did,
152
+
error=error_str,
153
+
error_type=error_type,
154
+
)
101
155
102
156
async def send_copyright_notification(
103
157
self,
···
107
161
artist_handle: str,
108
162
highest_score: int,
109
163
matches: list[dict],
110
-
) -> bool:
164
+
) -> tuple[NotificationResult | None, NotificationResult | None]:
111
165
"""send notification about a copyright flag to both artist and admin.
112
166
113
-
returns True if at least one notification was sent successfully.
167
+
returns (artist_result, admin_result) tuple with details of each attempt.
114
168
"""
115
-
if not self.dm_client:
116
-
logger.warning("dm client not authenticated, skipping notification")
117
-
return False
169
+
with logfire.span(
170
+
"copyright_notification",
171
+
track_id=track_id,
172
+
track_title=track_title,
173
+
artist_did=artist_did,
174
+
artist_handle=artist_handle,
175
+
highest_score=highest_score,
176
+
match_count=len(matches),
177
+
) as span:
178
+
if not self.dm_client:
179
+
logfire.warn("dm client not authenticated, skipping notification")
180
+
return None, None
181
+
182
+
# format match info
183
+
match_count = len(matches)
184
+
primary_match = None
185
+
if matches:
186
+
m = matches[0]
187
+
primary_match = (
188
+
f"{m.get('title', 'Unknown')} by {m.get('artist', 'Unknown')}"
189
+
)
190
+
191
+
# build track URL if available
192
+
track_url = None
193
+
frontend_url = settings.frontend.url
194
+
if frontend_url and "localhost" not in frontend_url:
195
+
track_url = f"{frontend_url}/track/{track_id}"
118
196
119
-
# format match info
120
-
match_count = len(matches)
121
-
primary_match = None
122
-
if matches:
123
-
m = matches[0]
124
-
primary_match = (
125
-
f"{m.get('title', 'Unknown')} by {m.get('artist', 'Unknown')}"
197
+
# message for the artist (uploader)
198
+
artist_message = (
199
+
f"⚠️ copyright notice for your track on {settings.app.name}\n\n"
200
+
f"track: '{track_title}'\n"
201
+
f"match confidence: {highest_score}%\n"
202
+
)
203
+
if primary_match:
204
+
artist_message += f"potential match: {primary_match}\n"
205
+
artist_message += (
206
+
"\nif you believe this is an error, please reply to this message. "
207
+
"otherwise, the track may be removed after review."
126
208
)
127
209
128
-
# build track URL if available
129
-
track_url = None
130
-
frontend_url = settings.frontend.url
131
-
if frontend_url and "localhost" not in frontend_url:
132
-
track_url = f"{frontend_url}/track/{track_id}"
210
+
# message for admin
211
+
admin_message = (
212
+
f"🚨 copyright flag on {settings.app.name}\n\n"
213
+
f"track: '{track_title}'\n"
214
+
f"artist: @{artist_handle}\n"
215
+
f"score: {highest_score}%\n"
216
+
f"matches: {match_count}\n"
217
+
)
218
+
if primary_match:
219
+
admin_message += f"primary: {primary_match}\n"
220
+
if track_url:
221
+
admin_message += f"\n{track_url}"
133
222
134
-
# message for the artist (uploader)
135
-
artist_message = (
136
-
f"⚠️ copyright notice for your track on {settings.app.name}\n\n"
137
-
f"track: '{track_title}'\n"
138
-
f"match confidence: {highest_score}%\n"
139
-
)
140
-
if primary_match:
141
-
artist_message += f"potential match: {primary_match}\n"
142
-
artist_message += (
143
-
"\nif you believe this is an error, please reply to this message. "
144
-
"otherwise, the track may be removed after review."
145
-
)
223
+
# send to artist
224
+
artist_result = await self._send_dm_to_did(artist_did, artist_message)
225
+
span.set_attribute("artist_success", artist_result.success)
226
+
if not artist_result.success:
227
+
span.set_attribute("artist_error_type", artist_result.error_type)
228
+
logfire.warn(
229
+
"failed to notify artist",
230
+
artist_handle=artist_handle,
231
+
error_type=artist_result.error_type,
232
+
error=artist_result.error,
233
+
)
146
234
147
-
# message for admin
148
-
admin_message = (
149
-
f"🚨 copyright flag on {settings.app.name}\n\n"
150
-
f"track: '{track_title}'\n"
151
-
f"artist: @{artist_handle}\n"
152
-
f"score: {highest_score}%\n"
153
-
f"matches: {match_count}\n"
154
-
)
155
-
if primary_match:
156
-
admin_message += f"primary: {primary_match}\n"
157
-
if track_url:
158
-
admin_message += f"\n{track_url}"
235
+
# send to admin
236
+
admin_result = None
237
+
if self.recipient_did:
238
+
admin_result = await self._send_dm_to_did(
239
+
self.recipient_did, admin_message
240
+
)
241
+
span.set_attribute("admin_success", admin_result.success)
242
+
if not admin_result.success:
243
+
span.set_attribute("admin_error_type", admin_result.error_type)
244
+
logfire.warn(
245
+
"failed to notify admin",
246
+
error_type=admin_result.error_type,
247
+
error=admin_result.error,
248
+
)
159
249
160
-
# send to both
161
-
artist_sent = await self._send_dm_to_did(artist_did, artist_message)
162
-
admin_sent = False
163
-
if self.recipient_did:
164
-
admin_sent = await self._send_dm_to_did(self.recipient_did, admin_message)
250
+
# summary
251
+
any_success = artist_result.success or (
252
+
admin_result and admin_result.success
253
+
)
254
+
span.set_attribute("any_success", any_success)
165
255
166
-
if artist_sent:
167
-
logger.info(f"sent copyright notification to artist {artist_handle}")
168
-
if admin_sent:
169
-
logger.info(f"sent copyright notification to admin for track {track_id}")
256
+
if artist_result.success:
257
+
logfire.info(
258
+
"sent copyright notification to artist",
259
+
artist_handle=artist_handle,
260
+
track_id=track_id,
261
+
)
262
+
if admin_result and admin_result.success:
263
+
logfire.info(
264
+
"sent copyright notification to admin",
265
+
track_id=track_id,
266
+
)
170
267
171
-
return artist_sent or admin_sent
268
+
return artist_result, admin_result
172
269
173
270
async def send_image_flag_notification(
174
271
self,