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..c9551eef 100644 --- a/server/reflector/views/transcripts.py +++ b/server/reflector/views/transcripts.py @@ -16,6 +16,12 @@ 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 ( + InvalidMessageError, + get_zulip_message, + send_message_to_zulip, + update_zulip_message, +) router = APIRouter() @@ -323,3 +329,35 @@ 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) + + message_updated = False + if transcript.zulip_message_id: + try: + update_zulip_message(transcript.zulip_message_id, stream, topic, content) + message_updated = True + except InvalidMessageError: + pass + + if not message_updated: + 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..ee35fe13 100644 --- a/server/reflector/zulip.py +++ b/server/reflector/zulip.py @@ -6,10 +6,11 @@ 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") +class InvalidMessageError(Exception): + pass + +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 +18,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 +31,32 @@ 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={ + "topic": topic, + "content": content, + }, + auth=(settings.ZULIP_BOT_EMAIL, settings.ZULIP_API_KEY), + headers={"Content-Type": "application/x-www-form-urlencoded"}, + ) + + if ( + response.status_code == 400 + and response.json()["msg"] == "Invalid message(s)" + ): + raise InvalidMessageError(f"There is no message with id: {message_id}") + + response.raise_for_status() + + return response.json() + except requests.RequestException as error: + 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 +66,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 +74,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; diff --git a/www/app/lib/zulip.ts b/www/app/lib/zulip.ts deleted file mode 100644 index 042f501c..00000000 --- a/www/app/lib/zulip.ts +++ /dev/null @@ -1,80 +0,0 @@ -import { GetTranscript, GetTranscriptTopic } from "../api"; -import { formatTime, formatTimeMs } from "./time"; -import { extractDomain } from "./utils"; - -export async function sendZulipMessage( - stream: string, - topic: string, - message: string, -) { - console.log("Sendiing zulip message", stream, topic); - try { - const response = await fetch("/api/send-zulip-message", { - method: "POST", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify({ stream, topic, message }), - }); - return await response.json(); - } catch (error) { - console.error("Error:", error); - throw error; - } -} - -export const ZULIP_MSG_MAX_LENGTH = 10000; - -export function getZulipMessage( - transcript: GetTranscript, - topics: GetTranscriptTopic[] | null, - includeTopics: boolean, -) { - const date = new Date(transcript.created_at); - - // Get the timezone offset in minutes and convert it to hours and minutes - const timezoneOffset = -date.getTimezoneOffset(); - const offsetHours = String( - Math.floor(Math.abs(timezoneOffset) / 60), - ).padStart(2, "0"); - const offsetMinutes = String(Math.abs(timezoneOffset) % 60).padStart(2, "0"); - const offsetSign = timezoneOffset >= 0 ? "+" : "-"; - - // Combine to get the formatted timezone offset - const formattedOffset = `${offsetSign}${offsetHours}:${offsetMinutes}`; - - // Now you can format your date and time string using this offset - const formattedDate = date.toISOString().slice(0, 10); - const hours = String(date.getHours()).padStart(2, "0"); - const minutes = String(date.getMinutes()).padStart(2, "0"); - const seconds = String(date.getSeconds()).padStart(2, "0"); - - const dateTimeString = `${formattedDate}T${hours}:${minutes}:${seconds}${formattedOffset}`; - - const domain = window.location.origin; // Gives you "http://localhost:3000" or your deployment base URL - const link = `${domain}/transcripts/${transcript.id}`; - - let headerText = `# Reflector – ${transcript.title ?? "Unnamed recording"} - -**Date**: -**Link**: [${extractDomain(link)}](${link}) -**Duration**: ${formatTimeMs(transcript.duration)} - -`; - let topicText = ""; - - if (topics && includeTopics) { - topicText = "```spoiler Topics\n"; - topics.forEach((topic) => { - topicText += `1. [${formatTime(topic.timestamp)}] ${topic.title}\n`; - }); - topicText += "```\n\n"; - } - - let summary = "```spoiler Summary\n"; - summary += transcript.long_summary; - summary += "```\n\n"; - - const message = headerText + summary + topicText + "-----\n"; - return message; -} diff --git a/www/pages/_app.jsx b/www/pages/_app.jsx deleted file mode 100644 index 819ee566..00000000 --- a/www/pages/_app.jsx +++ /dev/null @@ -1,12 +0,0 @@ -import { SessionProvider } from "next-auth/react"; - -export default function App({ - Component, - pageProps: { session, ...pageProps }, -}) { - return ( - - - - ); -} diff --git a/www/pages/api/send-zulip-message.ts b/www/pages/api/send-zulip-message.ts deleted file mode 100644 index 2f971241..00000000 --- a/www/pages/api/send-zulip-message.ts +++ /dev/null @@ -1,51 +0,0 @@ -import axios from "axios"; -import { URLSearchParams } from "url"; -import { getConfig } from "../../app/lib/edgeConfig"; - -export default async function handler(req, res) { - const config = await getConfig(); - const { sendToZulip } = config.features; - - if (req.method === "POST") { - const { stream, topic, message } = req.body; - - if (!stream || !topic || !message) { - return res.status(400).json({ error: "Missing required parameters" }); - } - - if (!sendToZulip) { - return res.status(403).json({ error: "Zulip integration disabled" }); - } - - try { - // Construct URL-encoded data - const params = new URLSearchParams(); - params.append("type", "stream"); - params.append("to", stream); - params.append("topic", topic); - params.append("content", message); - - // Send the request1 - const zulipResponse = await axios.post( - `https://${process.env.ZULIP_REALM}/api/v1/messages`, - params, - { - auth: { - username: process.env.ZULIP_BOT_EMAIL || "?", - password: process.env.ZULIP_API_KEY || "?", - }, - headers: { - "Content-Type": "application/x-www-form-urlencoded", - }, - }, - ); - - return res.status(200).json(zulipResponse.data); - } catch (error) { - return res.status(500).json({ failed: true, error: error }); - } - } else { - res.setHeader("Allow", ["POST"]); - res.status(405).end(`Method ${req.method} Not Allowed`); - } -} diff --git a/www/pages/forbidden.tsx b/www/pages/forbidden.tsx deleted file mode 100644 index ada3d424..00000000 --- a/www/pages/forbidden.tsx +++ /dev/null @@ -1,7 +0,0 @@ -import type { NextPage } from "next"; - -const Forbidden: NextPage = () => { - return

Sorry, you are not authorized to access this page

; -}; - -export default Forbidden;