diff --git a/server/migrations/versions/764ce6db4388_add_zulip_message_id.py b/server/migrations/versions/764ce6db4388_add_zulip_message_id.py new file mode 100644 index 00000000..ba66a27e --- /dev/null +++ b/server/migrations/versions/764ce6db4388_add_zulip_message_id.py @@ -0,0 +1,30 @@ +"""Add zulip message id + +Revision ID: 764ce6db4388 +Revises: 62dea3db63a5 +Create Date: 2024-09-06 14:02:06.649665 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = '764ce6db4388' +down_revision: Union[str, None] = '62dea3db63a5' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.add_column('transcript', sa.Column('zulip_message_id', sa.Integer(), nullable=True)) + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.drop_column('transcript', 'zulip_message_id') + # ### end Alembic commands ### diff --git a/server/reflector/db/transcripts.py b/server/reflector/db/transcripts.py index 37c63384..2f8999c0 100644 --- a/server/reflector/db/transcripts.py +++ b/server/reflector/db/transcripts.py @@ -54,6 +54,7 @@ transcripts = sqlalchemy.Table( "meeting_id", sqlalchemy.String, ), + sqlalchemy.Column("zulip_message_id", sqlalchemy.Integer, nullable=True), ) @@ -150,6 +151,7 @@ class Transcript(BaseModel): audio_location: str = "local" reviewed: bool = False meeting_id: str | None = None + zulip_message_id: int | None = None def add_event(self, event: str, data: BaseModel) -> TranscriptEvent: ev = TranscriptEvent(event=event, data=data.model_dump()) diff --git a/server/reflector/pipelines/main_live_pipeline.py b/server/reflector/pipelines/main_live_pipeline.py index 3dde5175..0e4d5582 100644 --- a/server/reflector/pipelines/main_live_pipeline.py +++ b/server/reflector/pipelines/main_live_pipeline.py @@ -587,7 +587,10 @@ async def pipeline_post_to_zulip(transcript: Transcript, logger: Logger): if room.zulip_auto_post: message = get_zulip_message(transcript=transcript) - send_message_to_zulip(room.zulip_stream, room.zulip_topic, message) + response = send_message_to_zulip(room.zulip_stream, room.zulip_topic, message) + await transcripts_controller.update( + transcript, {"zulip_message_id": response["id"]} + ) logger.info("Posted to zulip") diff --git a/server/reflector/views/transcripts.py b/server/reflector/views/transcripts.py index b390e556..fe67970a 100644 --- a/server/reflector/views/transcripts.py +++ b/server/reflector/views/transcripts.py @@ -16,6 +16,11 @@ from reflector.db.transcripts import ( from reflector.processors.types import Transcript as ProcessorTranscript from reflector.processors.types import Word from reflector.settings import settings +from reflector.zulip import ( + get_zulip_message, + send_message_to_zulip, + update_zulip_message, +) router = APIRouter() @@ -323,3 +328,28 @@ async def transcript_get_topics_with_words_per_speaker( # convert to GetTranscriptTopicWithWordsPerSpeaker return GetTranscriptTopicWithWordsPerSpeaker.from_transcript_topic(topic) + + +@router.post("/transcripts/{transcript_id}/zulip") +async def transcript_post_to_zulip( + transcript_id: str, + stream: str, + topic: str, + include_topics: bool, + user: Annotated[Optional[auth.UserInfo], Depends(auth.current_user_optional)], +): + user_id = user["sub"] if user else None + transcript = await transcripts_controller.get_by_id_for_http( + transcript_id, user_id=user_id + ) + if not transcript: + raise HTTPException(status_code=404, detail="Transcript not found") + + content = get_zulip_message(transcript, include_topics) + if transcript.zulip_message_id: + update_zulip_message(transcript.zulip_message_id, stream, topic, content) + else: + response = send_message_to_zulip(stream, topic, content) + await transcripts_controller.update( + transcript, {"zulip_message_id": response["id"]} + ) diff --git a/server/reflector/zulip.py b/server/reflector/zulip.py index cc1b738e..fe8e1e61 100644 --- a/server/reflector/zulip.py +++ b/server/reflector/zulip.py @@ -6,10 +6,7 @@ from reflector.db.transcripts import Transcript from reflector.settings import settings -def send_message_to_zulip(stream: str, topic: str, message: str): - if not stream or not topic or not message: - raise ValueError("Missing required parameters") - +def send_message_to_zulip(stream: str, topic: str, content: str): try: response = requests.post( f"https://{settings.ZULIP_REALM}/api/v1/messages", @@ -17,7 +14,7 @@ def send_message_to_zulip(stream: str, topic: str, message: str): "type": "stream", "to": stream, "topic": topic, - "content": message, + "content": content, }, auth=(settings.ZULIP_BOT_EMAIL, settings.ZULIP_API_KEY), headers={"Content-Type": "application/x-www-form-urlencoded"}, @@ -30,7 +27,29 @@ def send_message_to_zulip(stream: str, topic: str, message: str): raise Exception(f"Failed to send message to Zulip: {error}") -def get_zulip_message(transcript: Transcript): +def update_zulip_message(message_id: int, stream: str, topic: str, content: str): + try: + response = requests.patch( + f"https://{settings.ZULIP_REALM}/api/v1/messages/{message_id}", + data={ + "type": "stream", + "to": stream, + "topic": topic, + "content": content, + }, + auth=(settings.ZULIP_BOT_EMAIL, settings.ZULIP_API_KEY), + headers={"Content-Type": "application/x-www-form-urlencoded"}, + ) + + response.raise_for_status() + + return response.json() + except requests.RequestException as error: + print(content) + raise Exception(f"Failed to update Zulip message: {error}") + + +def get_zulip_message(transcript: Transcript, include_topics: bool): transcript_url = f"{settings.UI_BASE_URL}/transcripts/{transcript.id}" header_text = f"# Reflector – {transcript.title or 'Unnamed recording'}\n\n" @@ -40,7 +59,7 @@ def get_zulip_message(transcript: Transcript): topic_text = "" - if transcript.topics: + if include_topics and transcript.topics: topic_text = "```spoiler Topics\n" for topic in transcript.topics: topic_text += f"1. [{format_time(topic.timestamp)}] {topic.title}\n" @@ -48,7 +67,7 @@ def get_zulip_message(transcript: Transcript): summary = "```spoiler Summary\n" summary += transcript.long_summary - summary += "```\n\n" + summary += "\n```\n\n" message = header_text + summary + topic_text + "-----\n" return message diff --git a/www/app/(app)/transcripts/[transcriptId]/shareModal.tsx b/www/app/(app)/transcripts/[transcriptId]/shareModal.tsx index d21297de..c79165ee 100644 --- a/www/app/(app)/transcripts/[transcriptId]/shareModal.tsx +++ b/www/app/(app)/transcripts/[transcriptId]/shareModal.tsx @@ -1,9 +1,9 @@ -import React, { useContext, useState, useEffect } from "react"; +import React, { useContext, useState, useEffect, useCallback } from "react"; import SelectSearch from "react-select-search"; -import { getZulipMessage, sendZulipMessage } from "../../../lib/zulip"; import { GetTranscript, GetTranscriptTopic } from "../../../api"; import "react-select-search/style.css"; import { DomainContext } from "../../../domainContext"; +import useApi from "../../../lib/useApi"; type ShareModal = { show: boolean; @@ -30,6 +30,7 @@ const ShareModal = (props: ShareModal) => { const [isLoading, setIsLoading] = useState(true); const [streams, setStreams] = useState([]); const { zulip_streams } = useContext(DomainContext); + const api = useApi(); useEffect(() => { fetch(zulip_streams + "/streams.json") @@ -52,12 +53,22 @@ const ShareModal = (props: ShareModal) => { }); }, []); - const handleSendToZulip = () => { + const handleSendToZulip = async () => { if (!props.transcript) return; - const msg = getZulipMessage(props.transcript, props.topics, includeTopics); - - if (stream && topic) sendZulipMessage(stream, topic, msg); + if (stream && topic) { + if (!api) return; + try { + await api.v1TranscriptPostToZulip({ + transcriptId: props.transcript.id, + stream, + topic, + includeTopics, + }); + } catch (error) { + console.log(error); + } + } }; if (props.show && isLoading) { diff --git a/www/app/api/services.gen.ts b/www/app/api/services.gen.ts index 1bd59275..0bffb1d4 100644 --- a/www/app/api/services.gen.ts +++ b/www/app/api/services.gen.ts @@ -30,6 +30,8 @@ import type { V1TranscriptGetTopicsWithWordsResponse, V1TranscriptGetTopicsWithWordsPerSpeakerData, V1TranscriptGetTopicsWithWordsPerSpeakerResponse, + V1TranscriptPostToZulipData, + V1TranscriptPostToZulipResponse, V1TranscriptHeadAudioMp3Data, V1TranscriptHeadAudioMp3Response, V1TranscriptGetAudioMp3Data, @@ -373,6 +375,36 @@ export class DefaultService { }); } + /** + * Transcript Post To Zulip + * @param data The data for the request. + * @param data.transcriptId + * @param data.stream + * @param data.topic + * @param data.includeTopics + * @returns unknown Successful Response + * @throws ApiError + */ + public v1TranscriptPostToZulip( + data: V1TranscriptPostToZulipData, + ): CancelablePromise { + return this.httpRequest.request({ + method: "POST", + url: "/v1/transcripts/{transcript_id}/zulip", + path: { + transcript_id: data.transcriptId, + }, + query: { + stream: data.stream, + topic: data.topic, + include_topics: data.includeTopics, + }, + errors: { + 422: "Validation Error", + }, + }); + } + /** * Transcript Get Audio Mp3 * @param data The data for the request. diff --git a/www/app/api/types.gen.ts b/www/app/api/types.gen.ts index e91d8ed9..72d7b168 100644 --- a/www/app/api/types.gen.ts +++ b/www/app/api/types.gen.ts @@ -319,6 +319,15 @@ export type V1TranscriptGetTopicsWithWordsPerSpeakerData = { export type V1TranscriptGetTopicsWithWordsPerSpeakerResponse = GetTranscriptTopicWithWordsPerSpeaker; +export type V1TranscriptPostToZulipData = { + includeTopics: boolean; + stream: string; + topic: string; + transcriptId: string; +}; + +export type V1TranscriptPostToZulipResponse = unknown; + export type V1TranscriptHeadAudioMp3Data = { token?: string | null; transcriptId: string; @@ -614,6 +623,21 @@ export type $OpenApiTs = { }; }; }; + "/v1/transcripts/{transcript_id}/zulip": { + post: { + req: V1TranscriptPostToZulipData; + res: { + /** + * Successful Response + */ + 200: unknown; + /** + * Validation Error + */ + 422: HTTPValidationError; + }; + }; + }; "/v1/transcripts/{transcript_id}/audio/mp3": { head: { req: V1TranscriptHeadAudioMp3Data;