/**
* 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 `
`;
}
// ── 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
${keyPoints.map((p: string) => `- ${escapeHtml(p)}
`).join("")}
`;
if (actionItems.length > 0) html += `
Action Items
${actionItems.map((a: any) => `- ${escapeHtml(typeof a === "string" ? a : a.text || a.description || "")}
`).join("")}
`;
if (decisions.length > 0) html += `
Decisions
${decisions.map((d: any) => `- ${escapeHtml(typeof d === "string" ? d : d.text || d.description || "")}
`).join("")}
`;
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: ``,
}));
});
// ── 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: ``,
}));
});
// ── 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" },
],
};