Coverage for app/backend/src/couchers/servicers/conversations.py: 88%
315 statements
« prev ^ index » next coverage.py v7.16.1, created at 2026-09-19 15:47 +0000
« prev ^ index » next coverage.py v7.16.1, created at 2026-09-19 15:47 +0000
1import logging
2from collections.abc import Sequence
3from datetime import timedelta
4from typing import Any, cast
6import grpc
7from google.protobuf import empty_pb2
8from sqlalchemy import select
9from sqlalchemy.orm import Session, contains_eager
10from sqlalchemy.sql import and_, func, not_, or_
12from couchers.constants import DATETIME_INFINITY, DATETIME_MINUS_INFINITY
13from couchers.context import CouchersContext, make_background_user_context, make_notification_user_context
14from couchers.db import session_scope
15from couchers.event_log import log_event
16from couchers.helpers.completed_profile import has_completed_profile
17from couchers.helpers.group_chats import is_newest_subscription, is_unseen, mute_info, was_subscribed_at
18from couchers.helpers.messages import message_to_pb
19from couchers.jobs.enqueue import queue_job
20from couchers.metrics import sent_messages_counter
21from couchers.models import (
22 Conversation,
23 GroupChat,
24 GroupChatRole,
25 GroupChatSubscription,
26 Message,
27 MessageType,
28 ModerationObjectType,
29 RateLimitAction,
30 User,
31)
32from couchers.models.notifications import NotificationTopicAction
33from couchers.moderation.utils import create_moderation
34from couchers.notifications.notify import mark_notifications_seen, notify
35from couchers.proto import conversations_pb2, conversations_pb2_grpc, notification_data_pb2
36from couchers.proto.internal import jobs_pb2
37from couchers.rate_limits.check import process_rate_limits_and_check_abort
38from couchers.rate_limits.definitions import RATE_LIMIT_HOURS
39from couchers.servicers.api import user_model_to_pb
40from couchers.servicers.message_threads import list_message_threads, mark_all_threads_seen
41from couchers.sql import to_bool, users_visible, where_moderated_content_visible, where_users_column_visible
42from couchers.utils import Timestamp_from_datetime, now
44logger = logging.getLogger(__name__)
46# TODO: Still needs custom pagination: GetUpdates
47DEFAULT_PAGINATION_LENGTH = 20
48MAX_PAGE_SIZE = 50
51# TODO(#7722): remove with the legacy conversations endpoints; the ListMessageThreads path filters
52# preloaded subscriptions with message_threads._member_visible_to_viewer instead
53def _get_visible_members_for_subscription(subscription: GroupChatSubscription) -> list[int]:
54 """
55 If a user leaves a group chat, they shouldn't be able to see who's added
56 after they left
57 """
58 if not subscription.left:
59 # still in the chat, we see everyone with a current subscription
60 return [sub.user_id for sub in subscription.group_chat.subscriptions.where(GroupChatSubscription.left == None)]
61 else:
62 # not in chat anymore, see everyone who was in chat when we left
63 return [
64 sub.user_id
65 for sub in subscription.group_chat.subscriptions.where(
66 was_subscribed_at(GroupChatSubscription, subscription.left)
67 )
68 ]
71def _get_visible_admins_for_subscription(subscription: GroupChatSubscription) -> list[int]:
72 """
73 If a user leaves a group chat, they shouldn't be able to see who's added
74 after they left
75 """
76 if not subscription.left:
77 # still in the chat, we see everyone with a current subscription
78 return [
79 sub.user_id
80 for sub in subscription.group_chat.subscriptions.where(GroupChatSubscription.left == None).where(
81 GroupChatSubscription.role == GroupChatRole.admin
82 )
83 ]
84 else:
85 # not in chat anymore, see everyone who was in chat when we left
86 return [
87 sub.user_id
88 for sub in subscription.group_chat.subscriptions.where(
89 GroupChatSubscription.role == GroupChatRole.admin
90 ).where(was_subscribed_at(GroupChatSubscription, subscription.left))
91 ]
94def _user_can_message(session: Session, context: CouchersContext, group_chat: GroupChat) -> bool:
95 """
96 If it is a true group chat (not a DM), user can always message. For a DM, user can message if the other participant
97 - Is not deleted/banned
98 - Has not been blocked by the user or is blocking the user
99 - Has not left the chat
100 """
101 if not group_chat.is_dm:
102 return True
104 query = select(
105 where_users_column_visible(
106 select(GroupChatSubscription)
107 .where(GroupChatSubscription.user_id != context.user_id)
108 .where(GroupChatSubscription.group_chat_id == group_chat.conversation_id)
109 .where(GroupChatSubscription.left == None),
110 context=context,
111 column=GroupChatSubscription.user_id,
112 ).exists()
113 )
114 return session.execute(query).scalar_one()
117def generate_message_notifications(payload: jobs_pb2.GenerateMessageNotificationsPayload) -> None:
118 """
119 Background job to generate notifications for a message sent to a group chat
120 """
121 logger.info(f"Fanning notifications for message_id = {payload.message_id}")
123 with session_scope() as session:
124 message, group_chat = session.execute(
125 select(Message, GroupChat)
126 .join(GroupChat, GroupChat.conversation_id == Message.conversation_id)
127 .where(Message.id == payload.message_id)
128 ).one()
130 if message.message_type != MessageType.text:
131 logger.info(f"Not a text message, not notifying. message_id = {payload.message_id}")
132 return
134 context = make_background_user_context(user_id=message.author_id)
135 user_ids_to_notify = (
136 session.execute(
137 where_users_column_visible(
138 select(GroupChatSubscription.user_id)
139 .where(GroupChatSubscription.group_chat_id == message.conversation_id)
140 .where(GroupChatSubscription.user_id != message.author_id)
141 .where(was_subscribed_at(GroupChatSubscription, message.time))
142 .where(not_(GroupChatSubscription.is_muted)),
143 context=context,
144 column=GroupChatSubscription.user_id,
145 )
146 )
147 .scalars()
148 .all()
149 )
151 for user_id in user_ids_to_notify:
152 notify(
153 session,
154 user_id=user_id,
155 topic_action=NotificationTopicAction.chat__message,
156 key=str(message.conversation_id),
157 data=notification_data_pb2.ChatMessage(
158 author=user_model_to_pb(
159 message.author,
160 session,
161 make_notification_user_context(user_id=user_id),
162 ),
163 text=message.text,
164 group_chat_id=message.conversation_id,
165 group_chat_title=group_chat.title or None,
166 # unseen_count irrelevant for this notification
167 ),
168 moderation_state_id=group_chat.moderation_state_id,
169 )
172def _add_message_to_subscription(session: Session, subscription: GroupChatSubscription, **kwargs: Any) -> Message:
173 """
174 Creates a new message for a subscription, from the user whose subscription that is. Updates last seen message id
176 Specify the keyword args for Message
177 """
178 message = Message(conversation_id=subscription.group_chat.conversation.id, author_id=subscription.user_id, **kwargs)
180 session.add(message)
181 session.flush()
183 subscription.last_seen_message_id = message.id
185 queue_job(
186 session,
187 job=generate_message_notifications,
188 payload=jobs_pb2.GenerateMessageNotificationsPayload(
189 message_id=message.id,
190 ),
191 )
193 return message
196def _create_chat(
197 session: Session,
198 creator_id: int,
199 recipient_ids: Sequence[int],
200 title: str | None = None,
201 only_admins_invite: bool = True,
202) -> GroupChat:
203 conversation = Conversation()
204 session.add(conversation)
205 session.flush()
207 # Create moderation state for UMS (starts as SHADOWED)
208 moderation_state = create_moderation(
209 session=session,
210 object_type=ModerationObjectType.group_chat,
211 object_id=conversation.id,
212 creator_user_id=creator_id,
213 )
215 chat = GroupChat(
216 conversation_id=conversation.id,
217 title=title,
218 creator_id=creator_id,
219 is_dm=True if len(recipient_ids) == 1 else False,
220 only_admins_invite=only_admins_invite,
221 moderation_state_id=moderation_state.id,
222 )
223 session.add(chat)
224 session.flush()
226 creator_subscription = GroupChatSubscription(
227 user_id=creator_id,
228 group_chat_id=chat.conversation_id,
229 role=GroupChatRole.admin,
230 )
231 session.add(creator_subscription)
233 for uid in recipient_ids:
234 session.add(
235 GroupChatSubscription(
236 user_id=uid,
237 group_chat_id=chat.conversation_id,
238 role=GroupChatRole.participant,
239 )
240 )
242 return chat
245def _get_message_subscription(session: Session, user_id: int, conversation_id: int) -> GroupChatSubscription:
246 subscription = session.execute(
247 select(GroupChatSubscription)
248 .where(GroupChatSubscription.group_chat_id == conversation_id)
249 .where(GroupChatSubscription.user_id == user_id)
250 .where(GroupChatSubscription.left == None)
251 ).scalar_one_or_none()
253 return cast(GroupChatSubscription, subscription)
256def _get_visible_message_subscription(
257 session: Session, context: CouchersContext, conversation_id: int, *, include_left: bool = False
258) -> GroupChatSubscription:
259 """
260 Get the user's newest subscription to the chat, with visibility filtering. Requires that they're
261 still in the chat unless include_left, which is only for marking a chat seen: messages left unread
262 when you leave keep counting towards the badge, so you need a way to clear it.
263 """
264 subscription = session.execute(
265 where_moderated_content_visible(
266 select(GroupChatSubscription)
267 .join(GroupChat, GroupChat.conversation_id == GroupChatSubscription.group_chat_id)
268 .where(GroupChatSubscription.group_chat_id == conversation_id)
269 .where(GroupChatSubscription.user_id == context.user_id)
270 .where(is_newest_subscription(context.user_id))
271 .where(or_(to_bool(include_left), GroupChatSubscription.left == None)),
272 context,
273 GroupChat,
274 is_list_operation=False,
275 )
276 ).scalar_one_or_none()
278 return cast(GroupChatSubscription, subscription)
281def _unseen_message_count(session: Session, subscription_id: int) -> int:
282 query = (
283 select(func.count())
284 .select_from(Message)
285 .join(GroupChatSubscription, GroupChatSubscription.group_chat_id == Message.conversation_id)
286 .where(GroupChatSubscription.id == subscription_id)
287 .where(is_unseen(Message, GroupChatSubscription))
288 )
289 return session.execute(query).scalar_one()
292class Conversations(conversations_pb2_grpc.ConversationsServicer):
293 def ListMessageThreads(
294 self, request: conversations_pb2.ListMessageThreadsReq, context: CouchersContext, session: Session
295 ) -> conversations_pb2.ListMessageThreadsRes:
296 return list_message_threads(request, context, session)
298 def MarkAllThreadsSeen(
299 self, request: conversations_pb2.MarkAllThreadsSeenReq, context: CouchersContext, session: Session
300 ) -> empty_pb2.Empty:
301 return mark_all_threads_seen(request, context, session)
303 # TODO(#7722): remove after FE migrates to ListMessageThreads
304 def ListGroupChats(
305 self, request: conversations_pb2.ListGroupChatsReq, context: CouchersContext, session: Session
306 ) -> conversations_pb2.ListGroupChatsRes:
307 page_size = request.number if request.number != 0 else DEFAULT_PAGINATION_LENGTH
308 page_size = min(page_size, MAX_PAGE_SIZE)
310 # select group chats where you have a subscription, and for each of
311 # these, the latest message from them
313 t = (
314 select(
315 GroupChatSubscription.group_chat_id.label("group_chat_id"),
316 func.max(GroupChatSubscription.id).label("group_chat_subscriptions_id"),
317 func.max(Message.id).label("message_id"),
318 )
319 .join(Message, Message.conversation_id == GroupChatSubscription.group_chat_id)
320 .where(GroupChatSubscription.user_id == context.user_id)
321 .where(is_newest_subscription(context.user_id))
322 .where(was_subscribed_at(GroupChatSubscription, Message.time))
323 .where(
324 or_(
325 to_bool(request.HasField("only_archived") == False),
326 GroupChatSubscription.is_archived == request.only_archived,
327 )
328 )
329 .group_by(GroupChatSubscription.group_chat_id)
330 .order_by(func.max(Message.id).desc())
331 .subquery()
332 )
334 results = session.execute(
335 where_moderated_content_visible(
336 select(t, GroupChat, GroupChatSubscription, Message)
337 .join(Message, Message.id == t.c.message_id)
338 .join(GroupChatSubscription, GroupChatSubscription.id == t.c.group_chat_subscriptions_id)
339 .join(GroupChat, GroupChat.conversation_id == t.c.group_chat_id)
340 .join(Conversation, Conversation.id == GroupChat.conversation_id)
341 .options(contains_eager(GroupChat.conversation))
342 .where(or_(t.c.message_id < request.last_message_id, to_bool(request.last_message_id == 0)))
343 .order_by(t.c.message_id.desc())
344 .limit(page_size + 1),
345 context,
346 GroupChat,
347 is_list_operation=True,
348 )
349 ).all()
351 # Batch: unseen message counts in one query instead of N individual queries
352 subscription_ids = [r.GroupChatSubscription.id for r in results[:page_size]]
353 unseen_counts: dict[int, int] = dict(
354 session.execute( # type: ignore[arg-type]
355 select(GroupChatSubscription.id, func.count(Message.id))
356 .join(Message, Message.conversation_id == GroupChatSubscription.group_chat_id)
357 .where(GroupChatSubscription.id.in_(subscription_ids))
358 .where(is_unseen(Message, GroupChatSubscription))
359 .group_by(GroupChatSubscription.id)
360 ).all()
361 )
363 return conversations_pb2.ListGroupChatsRes(
364 group_chats=[
365 conversations_pb2.GroupChat(
366 group_chat_id=result.GroupChat.conversation_id,
367 title=result.GroupChat.title, # TODO: proper title for DMs, etc
368 member_user_ids=_get_visible_members_for_subscription(result.GroupChatSubscription),
369 admin_user_ids=_get_visible_admins_for_subscription(result.GroupChatSubscription),
370 only_admins_invite=result.GroupChat.only_admins_invite,
371 is_dm=result.GroupChat.is_dm,
372 created=Timestamp_from_datetime(result.GroupChat.conversation.created),
373 unseen_message_count=unseen_counts.get(result.GroupChatSubscription.id, 0),
374 last_seen_message_id=result.GroupChatSubscription.last_seen_message_id,
375 latest_message=message_to_pb(result.Message) if result.Message else None,
376 mute_info=mute_info(result.GroupChatSubscription),
377 can_message=_user_can_message(session, context, result.GroupChat),
378 is_archived=result.GroupChatSubscription.is_archived,
379 )
380 for result in results[:page_size]
381 ],
382 last_message_id=(
383 min(g.Message.id if g.Message else 1 for g in results[:page_size]) if len(results) > 0 else 0
384 ), # TODO
385 no_more=len(results) <= page_size,
386 )
388 def GetGroupChat(
389 self, request: conversations_pb2.GetGroupChatReq, context: CouchersContext, session: Session
390 ) -> conversations_pb2.GroupChat:
391 result = session.execute(
392 where_moderated_content_visible(
393 select(GroupChat, GroupChatSubscription, Message)
394 .join(Message, Message.conversation_id == GroupChatSubscription.group_chat_id)
395 .join(GroupChat, GroupChat.conversation_id == GroupChatSubscription.group_chat_id)
396 .join(Conversation, Conversation.id == GroupChat.conversation_id)
397 .options(contains_eager(GroupChat.conversation))
398 .where(GroupChatSubscription.user_id == context.user_id)
399 .where(GroupChatSubscription.group_chat_id == request.group_chat_id)
400 .where(is_newest_subscription(context.user_id))
401 .where(was_subscribed_at(GroupChatSubscription, Message.time))
402 .order_by(Message.id.desc())
403 .limit(1),
404 context,
405 GroupChat,
406 is_list_operation=False,
407 )
408 ).one_or_none()
410 if not result:
411 context.abort_with_error_code(grpc.StatusCode.NOT_FOUND, "chat_not_found")
413 return conversations_pb2.GroupChat(
414 group_chat_id=result.GroupChat.conversation_id,
415 title=result.GroupChat.title,
416 member_user_ids=_get_visible_members_for_subscription(result.GroupChatSubscription),
417 admin_user_ids=_get_visible_admins_for_subscription(result.GroupChatSubscription),
418 only_admins_invite=result.GroupChat.only_admins_invite,
419 is_dm=result.GroupChat.is_dm,
420 created=Timestamp_from_datetime(result.GroupChat.conversation.created),
421 unseen_message_count=_unseen_message_count(session, result.GroupChatSubscription.id),
422 last_seen_message_id=result.GroupChatSubscription.last_seen_message_id,
423 latest_message=message_to_pb(result.Message) if result.Message else None,
424 mute_info=mute_info(result.GroupChatSubscription),
425 can_message=_user_can_message(session, context, result.GroupChat),
426 is_archived=result.GroupChatSubscription.is_archived,
427 )
429 def GetDirectMessage(
430 self, request: conversations_pb2.GetDirectMessageReq, context: CouchersContext, session: Session
431 ) -> conversations_pb2.GroupChat:
432 count = func.count(GroupChatSubscription.id).label("count")
433 subquery = (
434 select(GroupChatSubscription.group_chat_id)
435 .where(
436 or_(
437 GroupChatSubscription.user_id == context.user_id,
438 GroupChatSubscription.user_id == request.user_id,
439 )
440 )
441 .where(GroupChatSubscription.left == None)
442 .join(GroupChat, GroupChat.conversation_id == GroupChatSubscription.group_chat_id)
443 .where(GroupChat.is_dm == True)
444 .group_by(GroupChatSubscription.group_chat_id)
445 .having(count == 2)
446 .subquery()
447 )
449 result = session.execute(
450 where_moderated_content_visible(
451 select(subquery, GroupChat, GroupChatSubscription, Message)
452 .join(subquery, subquery.c.group_chat_id == GroupChat.conversation_id)
453 .join(Message, Message.conversation_id == GroupChat.conversation_id)
454 .join(Conversation, Conversation.id == GroupChat.conversation_id)
455 .options(contains_eager(GroupChat.conversation))
456 .where(GroupChatSubscription.user_id == context.user_id)
457 .where(GroupChatSubscription.group_chat_id == GroupChat.conversation_id)
458 .where(is_newest_subscription(context.user_id))
459 .where(was_subscribed_at(GroupChatSubscription, Message.time))
460 .order_by(Message.id.desc())
461 .limit(1),
462 context,
463 GroupChat,
464 is_list_operation=False,
465 )
466 ).one_or_none()
468 if not result:
469 context.abort_with_error_code(grpc.StatusCode.NOT_FOUND, "chat_not_found")
471 return conversations_pb2.GroupChat(
472 group_chat_id=result.GroupChat.conversation_id,
473 title=result.GroupChat.title,
474 member_user_ids=_get_visible_members_for_subscription(result.GroupChatSubscription),
475 admin_user_ids=_get_visible_admins_for_subscription(result.GroupChatSubscription),
476 only_admins_invite=result.GroupChat.only_admins_invite,
477 is_dm=result.GroupChat.is_dm,
478 created=Timestamp_from_datetime(result.GroupChat.conversation.created),
479 unseen_message_count=_unseen_message_count(session, result.GroupChatSubscription.id),
480 last_seen_message_id=result.GroupChatSubscription.last_seen_message_id,
481 latest_message=message_to_pb(result.Message) if result.Message else None,
482 mute_info=mute_info(result.GroupChatSubscription),
483 can_message=_user_can_message(session, context, result.GroupChat),
484 is_archived=result.GroupChatSubscription.is_archived,
485 )
487 def GetUpdates(
488 self, request: conversations_pb2.GetUpdatesReq, context: CouchersContext, session: Session
489 ) -> conversations_pb2.GetUpdatesRes:
490 results = (
491 session.execute(
492 where_moderated_content_visible(
493 select(Message)
494 .join(GroupChatSubscription, GroupChatSubscription.group_chat_id == Message.conversation_id)
495 .join(GroupChat, GroupChat.conversation_id == Message.conversation_id)
496 .where(GroupChatSubscription.user_id == context.user_id)
497 .where(was_subscribed_at(GroupChatSubscription, Message.time))
498 .where(Message.id > request.newest_message_id)
499 .order_by(Message.id.asc())
500 .limit(DEFAULT_PAGINATION_LENGTH + 1),
501 context,
502 GroupChat,
503 is_list_operation=False,
504 )
505 )
506 .scalars()
507 .all()
508 )
510 return conversations_pb2.GetUpdatesRes(
511 updates=[
512 conversations_pb2.Update(
513 group_chat_id=message.conversation_id,
514 message=message_to_pb(message),
515 )
516 for message in sorted(results, key=lambda message: message.id)[:DEFAULT_PAGINATION_LENGTH]
517 ],
518 no_more=len(results) <= DEFAULT_PAGINATION_LENGTH,
519 )
521 def GetGroupChatMessages(
522 self, request: conversations_pb2.GetGroupChatMessagesReq, context: CouchersContext, session: Session
523 ) -> conversations_pb2.GetGroupChatMessagesRes:
524 page_size = request.number if request.number != 0 else DEFAULT_PAGINATION_LENGTH
525 page_size = min(page_size, MAX_PAGE_SIZE)
527 results = (
528 session.execute(
529 where_moderated_content_visible(
530 select(Message)
531 .join(GroupChatSubscription, GroupChatSubscription.group_chat_id == Message.conversation_id)
532 .join(GroupChat, GroupChat.conversation_id == Message.conversation_id)
533 .where(GroupChatSubscription.user_id == context.user_id)
534 .where(GroupChatSubscription.group_chat_id == request.group_chat_id)
535 .where(was_subscribed_at(GroupChatSubscription, Message.time))
536 .where(or_(Message.id < request.last_message_id, to_bool(request.last_message_id == 0)))
537 .where(
538 or_(
539 and_(
540 is_newest_subscription(context.user_id),
541 is_unseen(Message, GroupChatSubscription),
542 ),
543 to_bool(request.only_unseen == 0),
544 )
545 )
546 .order_by(Message.id.desc())
547 .limit(page_size + 1),
548 context,
549 GroupChat,
550 is_list_operation=False,
551 )
552 )
553 .scalars()
554 .all()
555 )
557 return conversations_pb2.GetGroupChatMessagesRes(
558 messages=[message_to_pb(message) for message in results[:page_size]],
559 last_message_id=results[-2].id if len(results) > 1 else 0, # TODO
560 no_more=len(results) <= page_size,
561 )
563 def MarkLastSeenGroupChat(
564 self, request: conversations_pb2.MarkLastSeenGroupChatReq, context: CouchersContext, session: Session
565 ) -> empty_pb2.Empty:
566 subscription = _get_visible_message_subscription(session, context, request.group_chat_id, include_left=True)
568 if not subscription: 568 ↛ 569line 568 didn't jump to line 569 because the condition on line 568 was never true
569 context.abort_with_error_code(grpc.StatusCode.NOT_FOUND, "chat_not_found")
571 if not subscription.last_seen_message_id <= request.last_seen_message_id: 571 ↛ 572line 571 didn't jump to line 572 because the condition on line 571 was never true
572 context.abort_with_error_code(grpc.StatusCode.FAILED_PRECONDITION, "cant_unsee_messages")
574 subscription.last_seen_message_id = request.last_seen_message_id
576 mark_notifications_seen(
577 session,
578 user_id=context.user_id,
579 topic_actions_and_keys=[
580 ([NotificationTopicAction.chat__message], [str(request.group_chat_id)]),
581 # chat__missed_messages is a summary across all chats, so it's keyed with an empty string
582 # rather than a chat id: reading any chat counts as acting on it, and it gets marked seen
583 ([NotificationTopicAction.chat__missed_messages], [""]),
584 ],
585 )
587 return empty_pb2.Empty()
589 def MuteGroupChat(
590 self, request: conversations_pb2.MuteGroupChatReq, context: CouchersContext, session: Session
591 ) -> empty_pb2.Empty:
592 subscription = _get_visible_message_subscription(session, context, request.group_chat_id)
594 if not subscription: 594 ↛ 595line 594 didn't jump to line 595 because the condition on line 594 was never true
595 context.abort_with_error_code(grpc.StatusCode.NOT_FOUND, "chat_not_found")
597 if request.unmute:
598 subscription.muted_until = DATETIME_MINUS_INFINITY
599 elif request.forever:
600 subscription.muted_until = DATETIME_INFINITY
601 elif request.for_duration: 601 ↛ 607line 601 didn't jump to line 607 because the condition on line 601 was always true
602 duration = request.for_duration.ToTimedelta()
603 if duration < timedelta(seconds=0): 603 ↛ 604line 603 didn't jump to line 604 because the condition on line 603 was never true
604 context.abort_with_error_code(grpc.StatusCode.FAILED_PRECONDITION, "cant_mute_past")
605 subscription.muted_until = now() + duration
607 return empty_pb2.Empty()
609 def SetGroupChatArchiveStatus(
610 self, request: conversations_pb2.SetGroupChatArchiveStatusReq, context: CouchersContext, session: Session
611 ) -> conversations_pb2.SetGroupChatArchiveStatusRes:
612 subscription = _get_visible_message_subscription(session, context, request.group_chat_id)
614 if not subscription:
615 context.abort_with_error_code(grpc.StatusCode.NOT_FOUND, "chat_not_found")
617 subscription.is_archived = request.is_archived
619 return conversations_pb2.SetGroupChatArchiveStatusRes(
620 group_chat_id=request.group_chat_id,
621 is_archived=request.is_archived,
622 )
624 def SearchMessages(
625 self, request: conversations_pb2.SearchMessagesReq, context: CouchersContext, session: Session
626 ) -> conversations_pb2.SearchMessagesRes:
627 page_size = request.number if request.number != 0 else DEFAULT_PAGINATION_LENGTH
628 page_size = min(page_size, MAX_PAGE_SIZE)
630 results = (
631 session.execute(
632 where_moderated_content_visible(
633 select(Message)
634 .join(GroupChatSubscription, GroupChatSubscription.group_chat_id == Message.conversation_id)
635 .join(GroupChat, GroupChat.conversation_id == Message.conversation_id)
636 .where(GroupChatSubscription.user_id == context.user_id)
637 .where(was_subscribed_at(GroupChatSubscription, Message.time))
638 .where(or_(Message.id < request.last_message_id, to_bool(request.last_message_id == 0)))
639 .where(Message.text.ilike(f"%{request.query}%"))
640 .order_by(Message.id.desc())
641 .limit(page_size + 1),
642 context,
643 GroupChat,
644 is_list_operation=True,
645 )
646 )
647 .scalars()
648 .all()
649 )
651 return conversations_pb2.SearchMessagesRes(
652 results=[
653 conversations_pb2.MessageSearchResult(
654 group_chat_id=message.conversation_id,
655 message=message_to_pb(message),
656 )
657 for message in results[:page_size]
658 ],
659 last_message_id=results[-2].id if len(results) > 1 else 0,
660 no_more=len(results) <= page_size,
661 )
663 def CreateGroupChat(
664 self, request: conversations_pb2.CreateGroupChatReq, context: CouchersContext, session: Session
665 ) -> conversations_pb2.GroupChat:
666 user = session.execute(select(User).where(User.id == context.user_id)).scalar_one()
667 if not has_completed_profile(session, user):
668 context.abort_with_error_code(grpc.StatusCode.FAILED_PRECONDITION, "incomplete_profile_send_message")
670 recipient_user_ids = list(
671 session.execute(
672 select(User.id).where(users_visible(context)).where(User.id.in_(request.recipient_user_ids))
673 )
674 .scalars()
675 .all()
676 )
678 # make sure all requested users are visible
679 if len(recipient_user_ids) != len(request.recipient_user_ids): 679 ↛ 680line 679 didn't jump to line 680 because the condition on line 679 was never true
680 context.abort_with_error_code(grpc.StatusCode.INVALID_ARGUMENT, "user_not_found")
682 if not recipient_user_ids: 682 ↛ 683line 682 didn't jump to line 683 because the condition on line 682 was never true
683 context.abort_with_error_code(grpc.StatusCode.INVALID_ARGUMENT, "no_recipients")
685 if len(recipient_user_ids) != len(set(recipient_user_ids)): 685 ↛ 687line 685 didn't jump to line 687 because the condition on line 685 was never true
686 # make sure there's no duplicate users
687 context.abort_with_error_code(grpc.StatusCode.INVALID_ARGUMENT, "invalid_recipients")
689 if context.user_id in recipient_user_ids: 689 ↛ 690line 689 didn't jump to line 690 because the condition on line 689 was never true
690 context.abort_with_error_code(grpc.StatusCode.INVALID_ARGUMENT, "cant_add_self")
692 if len(recipient_user_ids) == 1:
693 # can only have one DM at a time between any two users
694 other_user_id = recipient_user_ids[0]
696 # the following sql statement selects subscriptions that are DMs and have the same group_chat_id, and have
697 # user_id either this user or the recipient user. If you find two subscriptions to the same DM group
698 # chat, you know they already have a shared group chat
699 count = func.count(GroupChatSubscription.id).label("count")
700 if session.execute(
701 where_moderated_content_visible(
702 select(count)
703 .where(
704 or_(
705 GroupChatSubscription.user_id == context.user_id,
706 GroupChatSubscription.user_id == other_user_id,
707 )
708 )
709 .where(GroupChatSubscription.left == None)
710 .join(GroupChat, GroupChat.conversation_id == GroupChatSubscription.group_chat_id)
711 .where(GroupChat.is_dm == True)
712 .group_by(GroupChatSubscription.group_chat_id)
713 .having(count == 2),
714 context,
715 GroupChat,
716 is_list_operation=False,
717 )
718 ).scalar_one_or_none():
719 context.abort_with_error_code(grpc.StatusCode.FAILED_PRECONDITION, "already_have_dm")
721 # Check if user has been initiating chats excessively
722 if process_rate_limits_and_check_abort(
723 session=session, user_id=context.user_id, action=RateLimitAction.chat_initiation
724 ):
725 context.abort_with_error_code(
726 grpc.StatusCode.RESOURCE_EXHAUSTED,
727 "chat_initiation_rate_limit2",
728 substitutions={"count": RATE_LIMIT_HOURS},
729 )
731 group_chat = _create_chat(
732 session,
733 creator_id=context.user_id,
734 recipient_ids=request.recipient_user_ids,
735 title=request.title.value,
736 )
738 your_subscription = _get_message_subscription(session, context.user_id, group_chat.conversation_id)
740 _add_message_to_subscription(session, your_subscription, message_type=MessageType.chat_created)
742 session.flush()
744 log_event(
745 context,
746 session,
747 "group_chat.created",
748 {
749 "group_chat_id": group_chat.conversation_id,
750 "is_dm": group_chat.is_dm,
751 "recipient_count": len(request.recipient_user_ids),
752 },
753 )
755 return conversations_pb2.GroupChat(
756 group_chat_id=group_chat.conversation_id,
757 title=group_chat.title,
758 member_user_ids=_get_visible_members_for_subscription(your_subscription),
759 admin_user_ids=_get_visible_admins_for_subscription(your_subscription),
760 only_admins_invite=group_chat.only_admins_invite,
761 is_dm=group_chat.is_dm,
762 created=Timestamp_from_datetime(group_chat.conversation.created),
763 mute_info=mute_info(your_subscription),
764 can_message=True,
765 )
767 def SendMessage(
768 self, request: conversations_pb2.SendMessageReq, context: CouchersContext, session: Session
769 ) -> empty_pb2.Empty:
770 if request.text == "": 770 ↛ 771line 770 didn't jump to line 771 because the condition on line 770 was never true
771 context.abort_with_error_code(grpc.StatusCode.INVALID_ARGUMENT, "invalid_message")
773 result = session.execute(
774 where_moderated_content_visible(
775 select(GroupChatSubscription, GroupChat)
776 .join(GroupChat, GroupChat.conversation_id == GroupChatSubscription.group_chat_id)
777 .where(GroupChatSubscription.group_chat_id == request.group_chat_id)
778 .where(GroupChatSubscription.user_id == context.user_id)
779 .where(GroupChatSubscription.left == None),
780 context,
781 GroupChat,
782 is_list_operation=False,
783 )
784 ).one_or_none()
785 if not result:
786 context.abort_with_error_code(grpc.StatusCode.NOT_FOUND, "chat_not_found")
788 subscription, group_chat = result._tuple()
789 if not _user_can_message(session, context, group_chat):
790 context.abort_with_error_code(grpc.StatusCode.FAILED_PRECONDITION, "cant_message_in_chat")
792 _add_message_to_subscription(session, subscription, message_type=MessageType.text, text=request.text)
794 user_gender = session.execute(select(User.gender).where(User.id == context.user_id)).scalar_one()
795 sent_messages_counter.labels(
796 user_gender, "direct message" if subscription.group_chat.is_dm else "group chat"
797 ).inc()
798 log_event(
799 context,
800 session,
801 "message.sent",
802 {"group_chat_id": request.group_chat_id, "is_dm": subscription.group_chat.is_dm},
803 )
805 return empty_pb2.Empty()
807 def SendDirectMessage(
808 self, request: conversations_pb2.SendDirectMessageReq, context: CouchersContext, session: Session
809 ) -> conversations_pb2.SendDirectMessageRes:
810 user_id = context.user_id
811 user = session.execute(select(User).where(User.id == user_id)).scalar_one()
813 recipient_id = request.recipient_user_id
815 if not has_completed_profile(session, user): 815 ↛ 816line 815 didn't jump to line 816 because the condition on line 815 was never true
816 context.abort_with_error_code(grpc.StatusCode.FAILED_PRECONDITION, "incomplete_profile_send_message")
818 if not recipient_id: 818 ↛ 819line 818 didn't jump to line 819 because the condition on line 818 was never true
819 context.abort_with_error_code(grpc.StatusCode.INVALID_ARGUMENT, "no_recipients")
821 recipient_user_id = session.execute(
822 select(User.id).where(users_visible(context)).where(User.id == recipient_id)
823 ).scalar_one_or_none()
825 if not recipient_user_id: 825 ↛ 826line 825 didn't jump to line 826 because the condition on line 825 was never true
826 context.abort_with_error_code(grpc.StatusCode.INVALID_ARGUMENT, "user_not_found")
828 if user_id == recipient_id: 828 ↛ 829line 828 didn't jump to line 829 because the condition on line 828 was never true
829 context.abort_with_error_code(grpc.StatusCode.INVALID_ARGUMENT, "cant_add_self")
831 if request.text == "": 831 ↛ 832line 831 didn't jump to line 832 because the condition on line 831 was never true
832 context.abort_with_error_code(grpc.StatusCode.INVALID_ARGUMENT, "invalid_message")
834 # Look for an existing direct message (DM) chat between the two users
835 dm_chat_ids = (
836 select(GroupChatSubscription.group_chat_id)
837 .where(GroupChatSubscription.user_id.in_([user_id, recipient_id]))
838 .group_by(GroupChatSubscription.group_chat_id)
839 .having(func.count(GroupChatSubscription.user_id) == 2)
840 )
842 chat = session.execute(
843 where_moderated_content_visible(
844 select(GroupChat)
845 .where(GroupChat.is_dm == True)
846 .where(GroupChat.conversation_id.in_(dm_chat_ids))
847 .limit(1),
848 context,
849 GroupChat,
850 is_list_operation=False,
851 )
852 ).scalar_one_or_none()
854 if not chat:
855 if process_rate_limits_and_check_abort(
856 session=session, user_id=user_id, action=RateLimitAction.chat_initiation
857 ):
858 context.abort_with_error_code(
859 grpc.StatusCode.RESOURCE_EXHAUSTED,
860 "chat_initiation_rate_limit2",
861 substitutions={"count": RATE_LIMIT_HOURS},
862 )
863 chat = _create_chat(session, user_id, [recipient_id])
865 # Retrieve the sender's active subscription to the chat
866 subscription = _get_message_subscription(session, user_id, chat.conversation_id)
868 # Add the message to the conversation
869 _add_message_to_subscription(session, subscription, message_type=MessageType.text, text=request.text)
871 user_gender = session.execute(select(User.gender).where(User.id == user_id)).scalar_one()
872 sent_messages_counter.labels(user_gender, "direct message").inc()
873 log_event(
874 context,
875 session,
876 "message.sent",
877 {"group_chat_id": chat.conversation_id, "is_dm": True, "recipient_id": recipient_id},
878 )
880 session.flush()
882 return conversations_pb2.SendDirectMessageRes(group_chat_id=chat.conversation_id)
884 def EditGroupChat(
885 self, request: conversations_pb2.EditGroupChatReq, context: CouchersContext, session: Session
886 ) -> empty_pb2.Empty:
887 subscription = _get_visible_message_subscription(session, context, request.group_chat_id)
889 if not subscription:
890 context.abort_with_error_code(grpc.StatusCode.NOT_FOUND, "chat_not_found")
892 if subscription.role != GroupChatRole.admin:
893 context.abort_with_error_code(grpc.StatusCode.PERMISSION_DENIED, "only_admin_can_edit")
895 if request.HasField("title"):
896 subscription.group_chat.title = request.title.value
898 if request.HasField("only_admins_invite"): 898 ↛ 901line 898 didn't jump to line 901 because the condition on line 898 was always true
899 subscription.group_chat.only_admins_invite = request.only_admins_invite.value
901 _add_message_to_subscription(session, subscription, message_type=MessageType.chat_edited)
903 return empty_pb2.Empty()
905 def MakeGroupChatAdmin(
906 self, request: conversations_pb2.MakeGroupChatAdminReq, context: CouchersContext, session: Session
907 ) -> empty_pb2.Empty:
908 if not session.execute(
909 select(User).where(users_visible(context)).where(User.id == request.user_id)
910 ).scalar_one_or_none():
911 context.abort_with_error_code(grpc.StatusCode.NOT_FOUND, "user_not_found")
913 your_subscription = _get_visible_message_subscription(session, context, request.group_chat_id)
915 if not your_subscription: 915 ↛ 916line 915 didn't jump to line 916 because the condition on line 915 was never true
916 context.abort_with_error_code(grpc.StatusCode.NOT_FOUND, "chat_not_found")
918 if your_subscription.role != GroupChatRole.admin:
919 context.abort_with_error_code(grpc.StatusCode.PERMISSION_DENIED, "only_admin_can_make_admin")
921 if request.user_id == context.user_id: 921 ↛ 922line 921 didn't jump to line 922 because the condition on line 921 was never true
922 context.abort_with_error_code(grpc.StatusCode.FAILED_PRECONDITION, "cant_make_self_admin")
924 their_subscription = _get_message_subscription(session, request.user_id, request.group_chat_id)
926 if not their_subscription: 926 ↛ 927line 926 didn't jump to line 927 because the condition on line 926 was never true
927 context.abort_with_error_code(grpc.StatusCode.FAILED_PRECONDITION, "user_not_in_chat")
929 if their_subscription.role != GroupChatRole.participant:
930 context.abort_with_error_code(grpc.StatusCode.FAILED_PRECONDITION, "already_admin")
932 their_subscription.role = GroupChatRole.admin
934 _add_message_to_subscription(
935 session, your_subscription, message_type=MessageType.user_made_admin, target_id=request.user_id
936 )
938 return empty_pb2.Empty()
940 def RemoveGroupChatAdmin(
941 self, request: conversations_pb2.RemoveGroupChatAdminReq, context: CouchersContext, session: Session
942 ) -> empty_pb2.Empty:
943 if not session.execute(
944 select(User).where(users_visible(context)).where(User.id == request.user_id)
945 ).scalar_one_or_none():
946 context.abort_with_error_code(grpc.StatusCode.NOT_FOUND, "user_not_found")
948 your_subscription = _get_visible_message_subscription(session, context, request.group_chat_id)
950 if not your_subscription: 950 ↛ 951line 950 didn't jump to line 951 because the condition on line 950 was never true
951 context.abort_with_error_code(grpc.StatusCode.NOT_FOUND, "chat_not_found")
953 if request.user_id == context.user_id:
954 # Race condition!
955 other_admins_count = session.execute(
956 select(func.count())
957 .select_from(GroupChatSubscription)
958 .where(GroupChatSubscription.group_chat_id == request.group_chat_id)
959 .where(GroupChatSubscription.user_id != context.user_id)
960 .where(GroupChatSubscription.role == GroupChatRole.admin)
961 .where(GroupChatSubscription.left == None)
962 ).scalar_one()
963 if not other_admins_count > 0:
964 context.abort_with_error_code(grpc.StatusCode.FAILED_PRECONDITION, "cant_remove_last_admin")
966 if your_subscription.role != GroupChatRole.admin:
967 context.abort_with_error_code(grpc.StatusCode.PERMISSION_DENIED, "only_admin_can_remove_admin")
969 their_subscription = session.execute(
970 select(GroupChatSubscription)
971 .where(GroupChatSubscription.group_chat_id == request.group_chat_id)
972 .where(GroupChatSubscription.user_id == request.user_id)
973 .where(GroupChatSubscription.left == None)
974 .where(GroupChatSubscription.role == GroupChatRole.admin)
975 ).scalar_one_or_none()
977 if not their_subscription: 977 ↛ 978line 977 didn't jump to line 978 because the condition on line 977 was never true
978 context.abort_with_error_code(grpc.StatusCode.FAILED_PRECONDITION, "user_not_admin")
980 their_subscription.role = GroupChatRole.participant
982 _add_message_to_subscription(
983 session, your_subscription, message_type=MessageType.user_removed_admin, target_id=request.user_id
984 )
986 return empty_pb2.Empty()
988 def InviteToGroupChat(
989 self, request: conversations_pb2.InviteToGroupChatReq, context: CouchersContext, session: Session
990 ) -> empty_pb2.Empty:
991 if not session.execute(
992 select(User).where(users_visible(context)).where(User.id == request.user_id)
993 ).scalar_one_or_none():
994 context.abort_with_error_code(grpc.StatusCode.NOT_FOUND, "user_not_found")
996 result = session.execute(
997 where_moderated_content_visible(
998 select(GroupChatSubscription, GroupChat)
999 .join(GroupChat, GroupChat.conversation_id == GroupChatSubscription.group_chat_id)
1000 .where(GroupChatSubscription.group_chat_id == request.group_chat_id)
1001 .where(GroupChatSubscription.user_id == context.user_id)
1002 .where(GroupChatSubscription.left == None),
1003 context,
1004 GroupChat,
1005 is_list_operation=False,
1006 )
1007 ).one_or_none()
1009 if not result:
1010 context.abort_with_error_code(grpc.StatusCode.NOT_FOUND, "chat_not_found")
1012 your_subscription, group_chat = result._tuple()
1014 if request.user_id == context.user_id: 1014 ↛ 1015line 1014 didn't jump to line 1015 because the condition on line 1014 was never true
1015 context.abort_with_error_code(grpc.StatusCode.FAILED_PRECONDITION, "cant_invite_self")
1017 if your_subscription.role != GroupChatRole.admin and your_subscription.group_chat.only_admins_invite:
1018 context.abort_with_error_code(grpc.StatusCode.PERMISSION_DENIED, "invite_permission_denied")
1020 if group_chat.is_dm:
1021 context.abort_with_error_code(grpc.StatusCode.FAILED_PRECONDITION, "cant_invite_to_dm")
1023 their_subscription = _get_message_subscription(session, request.user_id, request.group_chat_id)
1025 if their_subscription: 1025 ↛ 1026line 1025 didn't jump to line 1026 because the condition on line 1025 was never true
1026 context.abort_with_error_code(grpc.StatusCode.FAILED_PRECONDITION, "already_in_chat")
1028 # TODO: race condition!
1030 subscription = GroupChatSubscription(
1031 user_id=request.user_id,
1032 group_chat_id=your_subscription.group_chat.conversation_id,
1033 role=GroupChatRole.participant,
1034 )
1035 session.add(subscription)
1037 _add_message_to_subscription(
1038 session, your_subscription, message_type=MessageType.user_invited, target_id=request.user_id
1039 )
1041 return empty_pb2.Empty()
1043 def RemoveGroupChatUser(
1044 self, request: conversations_pb2.RemoveGroupChatUserReq, context: CouchersContext, session: Session
1045 ) -> empty_pb2.Empty:
1046 """
1047 1. Get admin info and check it's correct
1048 2. Get user data, check it's correct and remove user
1049 """
1050 # Admin info
1051 your_subscription = _get_visible_message_subscription(session, context, request.group_chat_id)
1053 # if user info is missing
1054 if not your_subscription: 1054 ↛ 1055line 1054 didn't jump to line 1055 because the condition on line 1054 was never true
1055 context.abort_with_error_code(grpc.StatusCode.NOT_FOUND, "chat_not_found")
1057 # if user not admin
1058 if your_subscription.role != GroupChatRole.admin: 1058 ↛ 1059line 1058 didn't jump to line 1059 because the condition on line 1058 was never true
1059 context.abort_with_error_code(grpc.StatusCode.PERMISSION_DENIED, "only_admin_can_remove_user")
1061 # if user wants to remove themselves
1062 if request.user_id == context.user_id: 1062 ↛ 1063line 1062 didn't jump to line 1063 because the condition on line 1062 was never true
1063 context.abort_with_error_code(grpc.StatusCode.FAILED_PRECONDITION, "cant_remove_self")
1065 # get user info
1066 their_subscription = _get_message_subscription(session, request.user_id, request.group_chat_id)
1068 # user not found
1069 if not their_subscription:
1070 context.abort_with_error_code(grpc.StatusCode.FAILED_PRECONDITION, "user_not_in_chat")
1072 _add_message_to_subscription(
1073 session, your_subscription, message_type=MessageType.user_removed, target_id=request.user_id
1074 )
1076 their_subscription.left = func.now()
1078 return empty_pb2.Empty()
1080 def LeaveGroupChat(
1081 self, request: conversations_pb2.LeaveGroupChatReq, context: CouchersContext, session: Session
1082 ) -> empty_pb2.Empty:
1083 subscription = _get_visible_message_subscription(session, context, request.group_chat_id)
1085 if not subscription:
1086 context.abort_with_error_code(grpc.StatusCode.NOT_FOUND, "chat_not_found")
1088 if subscription.role == GroupChatRole.admin:
1089 other_admins_count = session.execute(
1090 select(func.count())
1091 .select_from(GroupChatSubscription)
1092 .where(GroupChatSubscription.group_chat_id == request.group_chat_id)
1093 .where(GroupChatSubscription.user_id != context.user_id)
1094 .where(GroupChatSubscription.role == GroupChatRole.admin)
1095 .where(GroupChatSubscription.left == None)
1096 ).scalar_one()
1097 participants_count = session.execute(
1098 select(func.count())
1099 .select_from(GroupChatSubscription)
1100 .where(GroupChatSubscription.group_chat_id == request.group_chat_id)
1101 .where(GroupChatSubscription.user_id != context.user_id)
1102 .where(GroupChatSubscription.role == GroupChatRole.participant)
1103 .where(GroupChatSubscription.left == None)
1104 ).scalar_one()
1105 if not (other_admins_count > 0 or participants_count == 0):
1106 context.abort_with_error_code(grpc.StatusCode.FAILED_PRECONDITION, "last_admin_cant_leave")
1108 _add_message_to_subscription(session, subscription, message_type=MessageType.user_left)
1110 subscription.left = func.now()
1112 return empty_pb2.Empty()