Merge pull request #408 from Monadical-SAS/update-zulip-message

Update zulip message
This commit is contained in:
2024-09-06 18:30:14 +02:00
committed by GitHub
12 changed files with 180 additions and 164 deletions

View File

@@ -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 ###

View File

@@ -54,6 +54,7 @@ transcripts = sqlalchemy.Table(
"meeting_id", "meeting_id",
sqlalchemy.String, sqlalchemy.String,
), ),
sqlalchemy.Column("zulip_message_id", sqlalchemy.Integer, nullable=True),
) )
@@ -150,6 +151,7 @@ class Transcript(BaseModel):
audio_location: str = "local" audio_location: str = "local"
reviewed: bool = False reviewed: bool = False
meeting_id: str | None = None meeting_id: str | None = None
zulip_message_id: int | None = None
def add_event(self, event: str, data: BaseModel) -> TranscriptEvent: def add_event(self, event: str, data: BaseModel) -> TranscriptEvent:
ev = TranscriptEvent(event=event, data=data.model_dump()) ev = TranscriptEvent(event=event, data=data.model_dump())

View File

@@ -587,7 +587,10 @@ async def pipeline_post_to_zulip(transcript: Transcript, logger: Logger):
if room.zulip_auto_post: if room.zulip_auto_post:
message = get_zulip_message(transcript=transcript) 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") logger.info("Posted to zulip")

View File

@@ -16,6 +16,12 @@ from reflector.db.transcripts import (
from reflector.processors.types import Transcript as ProcessorTranscript from reflector.processors.types import Transcript as ProcessorTranscript
from reflector.processors.types import Word from reflector.processors.types import Word
from reflector.settings import settings from reflector.settings import settings
from reflector.zulip import (
InvalidMessageError,
get_zulip_message,
send_message_to_zulip,
update_zulip_message,
)
router = APIRouter() router = APIRouter()
@@ -323,3 +329,35 @@ async def transcript_get_topics_with_words_per_speaker(
# convert to GetTranscriptTopicWithWordsPerSpeaker # convert to GetTranscriptTopicWithWordsPerSpeaker
return GetTranscriptTopicWithWordsPerSpeaker.from_transcript_topic(topic) 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"]}
)

View File

@@ -6,10 +6,11 @@ from reflector.db.transcripts import Transcript
from reflector.settings import settings from reflector.settings import settings
def send_message_to_zulip(stream: str, topic: str, message: str): class InvalidMessageError(Exception):
if not stream or not topic or not message: pass
raise ValueError("Missing required parameters")
def send_message_to_zulip(stream: str, topic: str, content: str):
try: try:
response = requests.post( response = requests.post(
f"https://{settings.ZULIP_REALM}/api/v1/messages", 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", "type": "stream",
"to": stream, "to": stream,
"topic": topic, "topic": topic,
"content": message, "content": content,
}, },
auth=(settings.ZULIP_BOT_EMAIL, settings.ZULIP_API_KEY), auth=(settings.ZULIP_BOT_EMAIL, settings.ZULIP_API_KEY),
headers={"Content-Type": "application/x-www-form-urlencoded"}, 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}") 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}" transcript_url = f"{settings.UI_BASE_URL}/transcripts/{transcript.id}"
header_text = f"# Reflector {transcript.title or 'Unnamed recording'}\n\n" header_text = f"# Reflector {transcript.title or 'Unnamed recording'}\n\n"
@@ -40,7 +66,7 @@ def get_zulip_message(transcript: Transcript):
topic_text = "" topic_text = ""
if transcript.topics: if include_topics and transcript.topics:
topic_text = "```spoiler Topics\n" topic_text = "```spoiler Topics\n"
for topic in transcript.topics: for topic in transcript.topics:
topic_text += f"1. [{format_time(topic.timestamp)}] {topic.title}\n" 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 = "```spoiler Summary\n"
summary += transcript.long_summary summary += transcript.long_summary
summary += "```\n\n" summary += "\n```\n\n"
message = header_text + summary + topic_text + "-----\n" message = header_text + summary + topic_text + "-----\n"
return message return message

View File

@@ -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 SelectSearch from "react-select-search";
import { getZulipMessage, sendZulipMessage } from "../../../lib/zulip";
import { GetTranscript, GetTranscriptTopic } from "../../../api"; import { GetTranscript, GetTranscriptTopic } from "../../../api";
import "react-select-search/style.css"; import "react-select-search/style.css";
import { DomainContext } from "../../../domainContext"; import { DomainContext } from "../../../domainContext";
import useApi from "../../../lib/useApi";
type ShareModal = { type ShareModal = {
show: boolean; show: boolean;
@@ -30,6 +30,7 @@ const ShareModal = (props: ShareModal) => {
const [isLoading, setIsLoading] = useState(true); const [isLoading, setIsLoading] = useState(true);
const [streams, setStreams] = useState<Stream[]>([]); const [streams, setStreams] = useState<Stream[]>([]);
const { zulip_streams } = useContext(DomainContext); const { zulip_streams } = useContext(DomainContext);
const api = useApi();
useEffect(() => { useEffect(() => {
fetch(zulip_streams + "/streams.json") fetch(zulip_streams + "/streams.json")
@@ -52,12 +53,22 @@ const ShareModal = (props: ShareModal) => {
}); });
}, []); }, []);
const handleSendToZulip = () => { const handleSendToZulip = async () => {
if (!props.transcript) return; if (!props.transcript) return;
const msg = getZulipMessage(props.transcript, props.topics, includeTopics); if (stream && topic) {
if (!api) return;
if (stream && topic) sendZulipMessage(stream, topic, msg); try {
await api.v1TranscriptPostToZulip({
transcriptId: props.transcript.id,
stream,
topic,
includeTopics,
});
} catch (error) {
console.log(error);
}
}
}; };
if (props.show && isLoading) { if (props.show && isLoading) {

View File

@@ -30,6 +30,8 @@ import type {
V1TranscriptGetTopicsWithWordsResponse, V1TranscriptGetTopicsWithWordsResponse,
V1TranscriptGetTopicsWithWordsPerSpeakerData, V1TranscriptGetTopicsWithWordsPerSpeakerData,
V1TranscriptGetTopicsWithWordsPerSpeakerResponse, V1TranscriptGetTopicsWithWordsPerSpeakerResponse,
V1TranscriptPostToZulipData,
V1TranscriptPostToZulipResponse,
V1TranscriptHeadAudioMp3Data, V1TranscriptHeadAudioMp3Data,
V1TranscriptHeadAudioMp3Response, V1TranscriptHeadAudioMp3Response,
V1TranscriptGetAudioMp3Data, 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<V1TranscriptPostToZulipResponse> {
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 * Transcript Get Audio Mp3
* @param data The data for the request. * @param data The data for the request.

View File

@@ -319,6 +319,15 @@ export type V1TranscriptGetTopicsWithWordsPerSpeakerData = {
export type V1TranscriptGetTopicsWithWordsPerSpeakerResponse = export type V1TranscriptGetTopicsWithWordsPerSpeakerResponse =
GetTranscriptTopicWithWordsPerSpeaker; GetTranscriptTopicWithWordsPerSpeaker;
export type V1TranscriptPostToZulipData = {
includeTopics: boolean;
stream: string;
topic: string;
transcriptId: string;
};
export type V1TranscriptPostToZulipResponse = unknown;
export type V1TranscriptHeadAudioMp3Data = { export type V1TranscriptHeadAudioMp3Data = {
token?: string | null; token?: string | null;
transcriptId: string; 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": { "/v1/transcripts/{transcript_id}/audio/mp3": {
head: { head: {
req: V1TranscriptHeadAudioMp3Data; req: V1TranscriptHeadAudioMp3Data;

View File

@@ -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**: <time:${dateTimeString}>
**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;
}

View File

@@ -1,12 +0,0 @@
import { SessionProvider } from "next-auth/react";
export default function App({
Component,
pageProps: { session, ...pageProps },
}) {
return (
<SessionProvider session={session}>
<Component {...pageProps} />
</SessionProvider>
);
}

View File

@@ -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`);
}
}

View File

@@ -1,7 +0,0 @@
import type { NextPage } from "next";
const Forbidden: NextPage = () => {
return <h2>Sorry, you are not authorized to access this page</h2>;
};
export default Forbidden;