/** * rMeets module — video meetings powered by Jitsi + Meeting Intelligence. * * Hub page with Quick Meet + room name input, * room pages embed Jitsi via renderExternalAppShell, * recordings/search pages pull from the Meeting Intelligence API. */ import { Hono } from "hono"; import { renderShell, renderExternalAppShell, escapeHtml } from "../../server/shell"; import { getModuleInfoList } from "../../shared/module"; import type { RSpaceModule } from "../../shared/module"; import { renderLanding } from "./landing"; const JITSI_URL = process.env.JITSI_URL || "https://jeffsi.localvibe.live"; const MI_API_URL = process.env.MEETING_INTELLIGENCE_API_URL || "http://meeting-intelligence-api:8000"; const routes = new Hono(); // ── Meeting Intelligence API helper ── async function miApiFetch(path: string): Promise<{ ok: boolean; data?: any; error?: string }> { try { const controller = new AbortController(); const timeout = setTimeout(() => controller.abort(), 8000); const res = await fetch(`${MI_API_URL}${path}`, { signal: controller.signal }); clearTimeout(timeout); if (!res.ok) return { ok: false, error: `API returned ${res.status}` }; return { ok: true, data: await res.json() }; } catch (e: any) { return { ok: false, error: e?.message || "Connection failed" }; } } function miUnavailableHtml(message = "Meeting intelligence is temporarily unavailable. Please try again later."): string { return `
🔌

${escapeHtml(message)}

`; } // ── Shared styles for MI pages ── const MI_STYLES = ``; // ── Room embed ── routes.get("/room/:room", (c) => { const space = c.req.param("space") || "demo"; const room = c.req.param("room"); const useApi = c.req.query("api") === "1"; if (useApi) { const director = c.req.query("director") === "1"; const sessionId = c.req.query("session") || ""; return c.html(renderShell({ title: `${room} — rMeets | rSpace`, moduleId: "rmeets", spaceSlug: space, modules: getModuleInfoList(), theme: "dark", body: ``, scripts: ``, })); } return c.html(renderExternalAppShell({ title: `${room} — rMeets | rSpace`, moduleId: "rmeets", spaceSlug: space, modules: getModuleInfoList(), appUrl: `${JITSI_URL}/${encodeURIComponent(room)}`, appName: "Jitsi Meet", theme: "dark", })); }); // ── Direct Jitsi lobby ── routes.get("/meet", (c) => { const space = c.req.param("space") || "demo"; return c.html(renderExternalAppShell({ title: `Jitsi Meet — rMeets | rSpace`, moduleId: "rmeets", spaceSlug: space, modules: getModuleInfoList(), appUrl: JITSI_URL, appName: "Jitsi Meet", theme: "dark", })); }); // ── Recordings list ── routes.get("/recordings", async (c) => { const space = c.req.param("space") || "demo"; const base = `/${escapeHtml(space)}/rmeets`; const result = await miApiFetch("/meetings?limit=50&sort=-created_at"); let body: string; if (!result.ok) { body = miUnavailableHtml(); } else { const meetings = Array.isArray(result.data) ? result.data : (result.data?.meetings ?? result.data?.items ?? []); if (meetings.length === 0) { body = `

Recordings

No recorded meetings yet. Start a meeting and enable recording to see it here.

`; } else { const cards = meetings.map((m: any) => { const date = m.created_at ? new Date(m.created_at).toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" }) : ""; const duration = m.duration_seconds ? `${Math.round(m.duration_seconds / 60)}min` : ""; const participants = m.participant_count ?? m.participants?.length ?? ""; const status = (m.status || "completed").toLowerCase(); const badgeClass = `mi-badge--${status}`; const hasTranscript = m.has_transcript ?? m.transcript_status === "completed"; const hasSummary = m.has_summary ?? m.summary_status === "completed"; return `

${escapeHtml(m.title || m.room_name || `Meeting ${m.id}`)}

${[date, duration, participants ? `${participants} participants` : ""].filter(Boolean).join(" · ")}
${escapeHtml(status)}
${hasTranscript ? `Transcript` : `No transcript`} ${hasSummary ? `Summary` : `No summary`}
`; }).join("\n"); body = `

Recordings

Past meetings with transcripts and AI summaries

${cards}
`; } } return c.html(renderShell({ title: `Recordings — rMeets | rSpace`, moduleId: "rmeets", spaceSlug: space, modules: getModuleInfoList(), styles: MI_STYLES, body, })); }); // ── Recording detail redirect ── routes.get("/recordings/:id", (c) => { const space = c.req.param("space") || "demo"; const id = c.req.param("id"); return c.redirect(`/${space}/rmeets/recordings/${id}/overview`); }); // ── Recording detail with tabs ── routes.get("/recordings/:id/:tab", async (c) => { const space = c.req.param("space") || "demo"; const base = `/${escapeHtml(space)}/rmeets`; const id = c.req.param("id"); const tab = c.req.param("tab") || "overview"; const validTabs = ["overview", "transcript", "summary", "speakers"]; const activeTab = validTabs.includes(tab) ? tab : "overview"; const skip = { ok: false as const, data: undefined, error: "skipped" }; const [meetingRes, transcriptRes, summaryRes, speakersRes] = await Promise.all([ miApiFetch(`/meetings/${id}`), activeTab === "transcript" ? miApiFetch(`/meetings/${id}/transcript`) : Promise.resolve(skip), activeTab === "summary" ? miApiFetch(`/meetings/${id}/summary`) : Promise.resolve(skip), (activeTab === "speakers" || activeTab === "overview") ? miApiFetch(`/meetings/${id}/speakers`) : Promise.resolve(skip), ]); if (!meetingRes.ok) { return c.html(renderShell({ title: `Recording — rMeets | rSpace`, moduleId: "rmeets", spaceSlug: space, modules: getModuleInfoList(), styles: MI_STYLES, body: miUnavailableHtml("Could not load this recording. The meeting intelligence service may be temporarily unavailable."), })); } const m = meetingRes.data; const title = m.title || m.room_name || `Meeting ${id}`; const date = m.created_at ? new Date(m.created_at).toLocaleDateString("en-US", { weekday: "long", month: "long", day: "numeric", year: "numeric" }) : ""; const duration = m.duration_seconds ? `${Math.round(m.duration_seconds / 60)} minutes` : ""; const participants = m.participant_count ?? m.participants?.length ?? ""; const tabs = validTabs.map(t => { const label = t.charAt(0).toUpperCase() + t.slice(1); const cls = t === activeTab ? "mi-tab mi-tab--active" : "mi-tab"; return `${label}`; }).join(""); let tabContent: string; if (activeTab === "overview") { const speakers = speakersRes.ok ? (Array.isArray(speakersRes.data) ? speakersRes.data : speakersRes.data?.speakers ?? []) : []; const speakerList = speakers.length > 0 ? `
${speakers.map((s: any) => { const time = s.speaking_time_seconds ? `${Math.round(s.speaking_time_seconds / 60)}min speaking` : ""; return `

${escapeHtml(s.name || s.label || `Speaker ${s.id}`)}

${escapeHtml(time)}

`; }).join("")}
` : `

No speaker data available.

`; tabContent = `
${date ? `${escapeHtml(date)}` : ""} ${duration ? `${escapeHtml(duration)}` : ""} ${participants ? `${participants} participants` : ""}

Speakers

${speakerList}`; } else if (activeTab === "transcript") { if (!transcriptRes.ok) { tabContent = `

Transcript not available yet.

`; } else { const segments = Array.isArray(transcriptRes.data) ? transcriptRes.data : (transcriptRes.data?.segments ?? transcriptRes.data?.transcript ?? []); if (segments.length === 0) { tabContent = `

Transcript is empty.

`; } else { tabContent = `
${segments.map((seg: any) => { const speaker = seg.speaker || seg.speaker_name || seg.label || "Unknown"; const ts = seg.start_time != null ? formatTimestamp(seg.start_time) : ""; const text = seg.text || seg.content || ""; return `
${escapeHtml(speaker)}${escapeHtml(ts)}
${escapeHtml(text)}
`; }).join("")}
`; } } } else if (activeTab === "summary") { if (!summaryRes.ok) { tabContent = `

Summary not available yet.

`; } else { const summary = summaryRes.data; const overview = summary.overview || summary.summary || summary.text || ""; const keyPoints = summary.key_points ?? summary.keyPoints ?? []; const actionItems = summary.action_items ?? summary.actionItems ?? []; const decisions = summary.decisions ?? []; let html = `
`; if (overview) html += `

Overview

${escapeHtml(overview)}

`; if (keyPoints.length > 0) html += `

Key Points

`; if (actionItems.length > 0) html += `

Action Items

`; if (decisions.length > 0) html += `

Decisions

`; if (!overview && keyPoints.length === 0 && actionItems.length === 0) html += `

Summary content is empty.

`; html += `
`; tabContent = html; } } else if (activeTab === "speakers") { const speakers = speakersRes.ok ? (Array.isArray(speakersRes.data) ? speakersRes.data : speakersRes.data?.speakers ?? []) : []; if (speakers.length === 0) { tabContent = `

No speaker data available.

`; } else { tabContent = `
${speakers.map((s: any) => { const time = s.speaking_time_seconds ? `${Math.round(s.speaking_time_seconds / 60)}min speaking` : ""; const segments = s.segment_count ?? s.num_segments ?? ""; return `

${escapeHtml(s.name || s.label || `Speaker ${s.id}`)}

${[time, segments ? `${segments} segments` : ""].filter(Boolean).join(" · ")}

`; }).join("")}
`; } } else { tabContent = ""; } return c.html(renderShell({ title: `${title} — rMeets | rSpace`, moduleId: "rmeets", spaceSlug: space, modules: getModuleInfoList(), styles: MI_STYLES, body: `

${escapeHtml(title)}

${tabs}
${tabContent}
`, })); }); // ── Search ── routes.get("/search", async (c) => { const space = c.req.param("space") || "demo"; const base = `/${escapeHtml(space)}/rmeets`; const q = c.req.query("q") || ""; let resultsHtml = ""; if (q) { const result = await miApiFetch(`/search?q=${encodeURIComponent(q)}&limit=30`); if (!result.ok) { resultsHtml = miUnavailableHtml(); } else { const hits = Array.isArray(result.data) ? result.data : (result.data?.results ?? result.data?.hits ?? []); if (hits.length === 0) { resultsHtml = `

No results found for "${escapeHtml(q)}".

`; } else { resultsHtml = hits.map((h: any) => { const meetingId = h.meeting_id ?? h.meetingId ?? h.id; const title = h.meeting_title || h.title || `Meeting ${meetingId}`; const excerpt = h.highlight || h.excerpt || h.text || ""; return `

${escapeHtml(title)}

${excerpt}
`; }).join(""); } } } return c.html(renderShell({ title: `Search — rMeets | rSpace`, moduleId: "rmeets", spaceSlug: space, modules: getModuleInfoList(), styles: MI_STYLES, body: `

Search Transcripts

Full-text search across all meeting transcripts

${resultsHtml}
`, })); }); // ── MI API proxy (for client-side JS, avoids CORS) ── routes.all("/api/mi-proxy/*", async (c) => { const proxyPath = c.req.path.replace(/^\/[^/]+\/rmeets\/api\/mi-proxy/, ""); const url = new URL(`${MI_API_URL}${proxyPath}`); // Forward query params const query = c.req.query(); for (const [k, v] of Object.entries(query)) { if (typeof v === "string") url.searchParams.set(k, v); } try { const controller = new AbortController(); const timeout = setTimeout(() => controller.abort(), 8000); const upstream = await fetch(url.toString(), { method: c.req.method, headers: { "Content-Type": "application/json" }, body: c.req.method !== "GET" && c.req.method !== "HEAD" ? await c.req.text() : undefined, signal: controller.signal, }); clearTimeout(timeout); const body = await upstream.text(); return new Response(body, { status: upstream.status, headers: { "Content-Type": upstream.headers.get("Content-Type") || "application/json" }, }); } catch { return c.json({ error: "Meeting intelligence service unavailable" }, 502); } }); // ── Hub page ── routes.get("/", (c) => { const space = c.req.param("space") || "demo"; const base = `/${escapeHtml(space)}/rmeets`; const randomId = Math.random().toString(36).slice(2, 10); return c.html(renderShell({ title: `rMeets — ${space} | rSpace`, moduleId: "rmeets", spaceSlug: space, modules: getModuleInfoList(), styles: ``, body: `

rMeets

Video meetings powered by Jitsi — no account required

`, })); }); // ── Helpers ── function formatTimestamp(seconds: number): string { const h = Math.floor(seconds / 3600); const m = Math.floor((seconds % 3600) / 60); const s = Math.floor(seconds % 60); if (h > 0) return `${h}:${String(m).padStart(2, "0")}:${String(s).padStart(2, "0")}`; return `${m}:${String(s).padStart(2, "0")}`; } // ── Module export ── export const meetsModule: RSpaceModule = { id: "rmeets", name: "rMeets", icon: "📹", description: "Video meetings powered by Jitsi", scoping: { defaultScope: "space", userConfigurable: false }, routes, landingPage: renderLanding, externalApp: { url: JITSI_URL, name: "Jitsi Meet" }, outputPaths: [ { path: "meet", name: "Create Call", icon: "🚀", description: "Start a new video meeting" }, { path: "rooms", name: "Rooms", icon: "🚪", description: "Meeting rooms and video calls" }, ], onboardingActions: [ { label: "Start a Meeting", icon: "🚀", description: "Jump into an instant video call", type: "create", href: "/{space}/rmeets/room/quick" }, { label: "View Recordings", icon: "🎥", description: "Browse past meetings with transcripts and AI summaries", type: "link", href: "/{space}/rmeets/recordings" }, ], };