Skip to content

Add get chat messages endpoint #244

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 14 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 40 additions & 0 deletions bots/bot_controller/bot_controller.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,8 @@
BotMediaRequestMediaTypes,
BotMediaRequestStates,
BotStates,
ChatMessage,
ChatMessageToOptions,
Credentials,
MeetingTypes,
Participant,
Expand Down Expand Up @@ -80,6 +82,7 @@ def get_google_meet_bot_adapter(self):
wants_any_video_frames_callback=None,
add_mixed_audio_chunk_callback=None,
upsert_caption_callback=self.closed_caption_manager.upsert_caption,
upsert_chat_message_callback=self.on_new_chat_message,
automatic_leave_configuration=self.automatic_leave_configuration,
add_encoded_mp4_chunk_callback=None,
recording_view=self.bot_in_db.recording_view(),
Expand All @@ -102,6 +105,7 @@ def get_teams_bot_adapter(self):
wants_any_video_frames_callback=None,
add_mixed_audio_chunk_callback=None,
upsert_caption_callback=self.closed_caption_manager.upsert_caption,
upsert_chat_message_callback=self.on_new_chat_message,
automatic_leave_configuration=self.automatic_leave_configuration,
add_encoded_mp4_chunk_callback=None,
recording_view=self.bot_in_db.recording_view(),
Expand Down Expand Up @@ -137,6 +141,7 @@ def get_zoom_bot_adapter(self):
add_video_frame_callback=self.gstreamer_pipeline.on_new_video_frame,
wants_any_video_frames_callback=self.gstreamer_pipeline.wants_any_video_frames,
add_mixed_audio_chunk_callback=self.gstreamer_pipeline.on_mixed_audio_raw_data_received_callback,
upsert_chat_message_callback=self.on_new_chat_message,
automatic_leave_configuration=self.automatic_leave_configuration,
video_frame_size=self.bot_in_db.recording_dimensions(),
)
Expand Down Expand Up @@ -740,6 +745,41 @@ def save_individual_audio_utterance(self, message):
process_utterance.delay(utterance.id)
return

def on_new_chat_message(self, chat_message):
GLib.idle_add(lambda: self.upsert_chat_message(chat_message))

def upsert_chat_message(self, chat_message):
logger.info(f"Upserting chat message: {chat_message}")

participant = self.adapter.get_participant(chat_message["participant_uuid"])

if participant is None:
logger.warning(f"Warning: No participant found for chat message: {chat_message}")
return

participant, _ = Participant.objects.get_or_create(
bot=self.bot_in_db,
uuid=participant["participant_uuid"],
defaults={
"user_uuid": participant["participant_user_uuid"],
"full_name": participant["participant_full_name"],
},
)

ChatMessage.objects.update_or_create(
bot=self.bot_in_db,
source_uuid=chat_message["message_uuid"],
defaults={
"timestamp": chat_message["timestamp"],
"to": ChatMessageToOptions.ONLY_BOT if chat_message.get("to_bot") else ChatMessageToOptions.EVERYONE,
"text": chat_message["text"],
"participant": participant,
"additional_data": chat_message.get("additional_data", {}),
},
)

return

def on_message_from_adapter(self, message):
GLib.idle_add(lambda: self.take_action_based_on_message_from_adapter(message))

Expand Down
3 changes: 2 additions & 1 deletion bots/bot_pod_creator/bot_pod_creator.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,8 @@ def __init__(self, namespace: str = "attendee"):

# Parse instance from version (matches your pattern of {hash}-{timestamp})
self.app_instance = f"{self.app_name}-{self.app_version.split('-')[-1]}"
self.image = f"nduncan{self.app_name}/{self.app_name}:{self.app_version}"
default_pod_image = f"nduncan{self.app_name}/{self.app_name}"
self.image = f"{os.getenv('BOT_POD_IMAGE', default_pod_image)}:{self.app_version}"

def create_bot_pod(
self,
Expand Down
5 changes: 5 additions & 0 deletions bots/bots_api_urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,11 @@
bots_api_views.SpeechView.as_view(),
name="bot-speech",
),
path(
"bots/<str:object_id>/chat_messages",
bots_api_views.ChatMessagesView.as_view(),
name="bot-chat-messages",
),
path(
"bots/<str:object_id>/delete_data",
bots_api_views.DeleteDataView.as_view(),
Expand Down
93 changes: 93 additions & 0 deletions bots/bots_api_views.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@
extend_schema,
)
from rest_framework import status
from rest_framework.generics import GenericAPIView
from rest_framework.pagination import CursorPagination
from rest_framework.response import Response
from rest_framework.views import APIView

Expand All @@ -24,6 +26,7 @@
BotMediaRequestMediaTypes,
BotMediaRequestStates,
BotStates,
ChatMessage,
Credentials,
MediaBlob,
MeetingTypes,
Expand All @@ -33,6 +36,7 @@
from .serializers import (
BotImageSerializer,
BotSerializer,
ChatMessageSerializer,
CreateBotSerializer,
RecordingSerializer,
SpeechSerializer,
Expand Down Expand Up @@ -703,3 +707,92 @@ def get(self, request, object_id):

except Bot.DoesNotExist:
return Response({"error": "Bot not found"}, status=status.HTTP_404_NOT_FOUND)


class ChatMessageCursorPagination(CursorPagination):
ordering = "created_at"
page_size = 25


class ChatMessagesView(GenericAPIView):
authentication_classes = [ApiKeyAuthentication]
pagination_class = ChatMessageCursorPagination
serializer_class = ChatMessageSerializer

@extend_schema(
operation_id="Get Chat Messages",
summary="Get chat messages sent in the meeting",
description="If the meeting is still in progress, this returns the chat messages sent so far. Results are paginated using cursor pagination.",
responses={
200: OpenApiResponse(
response=ChatMessageSerializer(many=True),
description="List of chat messages",
),
404: OpenApiResponse(description="Bot not found"),
},
parameters=[
*TokenHeaderParameter,
OpenApiParameter(
name="object_id",
type=str,
location=OpenApiParameter.PATH,
description="Bot ID",
examples=[OpenApiExample("Bot ID Example", value="bot_xxxxxxxxxxx")],
),
OpenApiParameter(
name="updated_after",
type={"type": "string", "format": "ISO 8601 datetime"},
location=OpenApiParameter.QUERY,
description="Only return chat messages created after this time. Useful when polling for updates.",
required=False,
examples=[OpenApiExample("DateTime Example", value="2024-01-18T12:34:56Z")],
),
OpenApiParameter(
name="cursor",
type=str,
location=OpenApiParameter.QUERY,
description="Cursor for pagination",
required=False,
),
],
tags=["Bots"],
)
def get(self, request, object_id):
try:
# Get the bot and verify it belongs to the project
bot = Bot.objects.get(object_id=object_id, project=request.auth.project)

# Get optional updated_after parameter
updated_after = request.query_params.get("updated_after")

# Query messages for this bot
messages_query = ChatMessage.objects.filter(bot=bot)

# Filter by updated_after if provided
if updated_after:
try:
updated_after_datetime = parse_datetime(str(updated_after))
except Exception:
updated_after_datetime = None

if not updated_after_datetime:
return Response(
{"error": "Invalid updated_after format. Use ISO 8601 format (e.g., 2024-01-18T12:34:56Z)"},
status=status.HTTP_400_BAD_REQUEST,
)
messages_query = messages_query.filter(created_at__gt=updated_after_datetime)

# Apply ordering - now using created_at for cursor pagination
messages = messages_query.order_by("created_at")

# Let the pagination class handle the rest
page = self.paginate_queryset(messages)
if page is not None:
serializer = self.get_serializer(page, many=True)
return self.get_paginated_response(serializer.data)

serializer = self.get_serializer(messages, many=True)
return Response(serializer.data)

except Bot.DoesNotExist:
return Response({"error": "Bot not found"}, status=status.HTTP_404_NOT_FOUND)
31 changes: 30 additions & 1 deletion bots/google_meet_bot_adapter/google_meet_chromedriver_payload.js
Original file line number Diff line number Diff line change
Expand Up @@ -442,6 +442,31 @@ const DEVICE_OUTPUT_TYPE = {
VIDEO: 2
}

// Chat message manager
class ChatMessageManager {
constructor(ws) {
this.ws = ws;
}

handleChatMessage(chatMessageRaw) {
try {
const chatMessage = chatMessageRaw.chatMessage;
console.log('handleChatMessage', chatMessage);

this.ws.sendJson({
type: 'ChatMessage',
message_uuid: chatMessage.messageId,
participant_uuid: chatMessage.deviceId,
timestamp: Math.floor(chatMessage.timestamp / 1000),
text: chatMessage.chatMessageContent.text,
});
}
catch (error) {
console.error('Error in handleChatMessage', error);
}
}
}

// User manager
class UserManager {
constructor(ws) {
Expand Down Expand Up @@ -1204,6 +1229,7 @@ const captionManager = new CaptionManager(ws);
const videoTrackManager = new VideoTrackManager(ws);
const styleManager = new StyleManager();
const receiverManager = new ReceiverManager();
const chatMessageManager = new ChatMessageManager(ws);
let rtpReceiverInterceptor = null;
if (window.initialData.sendPerParticipantAudio) {
rtpReceiverInterceptor = new RTCRtpReceiverInterceptor((receiver, result, ...args) => {
Expand All @@ -1215,6 +1241,7 @@ window.videoTrackManager = videoTrackManager;
window.userManager = userManager;
window.styleManager = styleManager;
window.receiverManager = receiverManager;
window.chatMessageManager = chatMessageManager;
// Create decoders for all message types
const messageDecoders = {};
messageTypes.forEach(type => {
Expand Down Expand Up @@ -1261,7 +1288,9 @@ const handleCollectionEvent = (event) => {

const chatMessageWrapper = collectionEvent.body.userInfoListWrapperAndChatWrapperWrapper?.userInfoListWrapperAndChatWrapper?.chatMessageWrapper;
if (chatMessageWrapper) {
console.log('chatMessageWrapper', chatMessageWrapper);
for (const chatMessage of chatMessageWrapper) {
window.chatMessageManager?.handleChatMessage(chatMessage);
}
}

//console.log('deviceOutputInfoList', JSON.stringify(collectionEvent.body.userInfoListWrapperAndChatWrapperWrapper?.deviceInfoWrapper?.deviceOutputInfoList));
Expand Down
30 changes: 30 additions & 0 deletions bots/migrations/0030_chatmessage.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
# Generated by Django 5.1.2 on 2025-05-23 04:55

import django.db.models.deletion
from django.db import migrations, models


class Migration(migrations.Migration):

dependencies = [
('bots', '0029_recording_transcription_failure_data_and_more'),
]

operations = [
migrations.CreateModel(
name='ChatMessage',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('text', models.TextField()),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('to', models.IntegerField(choices=[(1, 'only_bot'), (2, 'everyone')])),
('timestamp', models.IntegerField()),
('additional_data', models.JSONField(default=dict)),
('object_id', models.CharField(editable=False, max_length=32, unique=True)),
('source_uuid', models.CharField(max_length=255, null=True, unique=True)),
('bot', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='chat_messages', to='bots.bot')),
('participant', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='chat_messages', to='bots.participant')),
],
),
]
28 changes: 28 additions & 0 deletions bots/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -1486,3 +1486,31 @@ def add_to_response_body_list(self, response_body):
self.response_body_list = [response_body]
else:
self.response_body_list.append(response_body)


class ChatMessageToOptions(models.IntegerChoices):
ONLY_BOT = 1, "only_bot"
EVERYONE = 2, "everyone"


class ChatMessage(models.Model):
bot = models.ForeignKey(Bot, on_delete=models.CASCADE, related_name="chat_messages")
text = models.TextField()
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
participant = models.ForeignKey(Participant, on_delete=models.CASCADE, related_name="chat_messages")
to = models.IntegerField(choices=ChatMessageToOptions.choices, null=False)
timestamp = models.IntegerField()
additional_data = models.JSONField(null=False, default=dict)
object_id = models.CharField(max_length=32, unique=True, editable=False)

OBJECT_ID_PREFIX = "msg_"
object_id = models.CharField(max_length=32, unique=True, editable=False)
source_uuid = models.CharField(max_length=255, null=True, unique=True)

def save(self, *args, **kwargs):
if not self.object_id:
# Generate a random 16-character string
random_string = "".join(random.choices(string.ascii_letters + string.digits, k=16))
self.object_id = f"{self.OBJECT_ID_PREFIX}{random_string}"
super().save(*args, **kwargs)
15 changes: 15 additions & 0 deletions bots/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
BotEventSubTypes,
BotEventTypes,
BotStates,
ChatMessageToOptions,
MediaBlob,
MeetingTypes,
Recording,
Expand Down Expand Up @@ -733,3 +734,17 @@ def validate_text_to_speech_settings(self, value):
raise serializers.ValidationError(e.message)

return value


class ChatMessageSerializer(serializers.Serializer):
object_id = serializers.CharField()
text = serializers.CharField()
timestamp = serializers.IntegerField()
to = serializers.SerializerMethodField()
sender_name = serializers.CharField(source="participant.full_name")
sender_uuid = serializers.CharField(source="participant.uuid")
sender_user_uuid = serializers.CharField(source="participant.user_uuid", allow_null=True)
additional_data = serializers.JSONField()

def get_to(self, obj):
return ChatMessageToOptions.choices[obj.to - 1][1]
Loading