diff --git a/apps/memoro/apps/server/src/index.ts b/apps/memoro/apps/server/src/index.ts index aa6d6e652..75552e1d1 100644 --- a/apps/memoro/apps/server/src/index.ts +++ b/apps/memoro/apps/server/src/index.ts @@ -17,6 +17,8 @@ import { creditRoutes } from './routes/credits'; import { internalRoutes } from './routes/internal'; import { settingsRoutes } from './routes/settings'; import { cleanupRoutes } from './routes/cleanup'; +import { meetingRoutes } from './routes/meetings'; +import { meetingWebhookRoutes } from './routes/meetings-webhooks'; import { COSTS } from './lib/credits'; const app = new Hono(); @@ -68,6 +70,9 @@ app.route('/api/v1/internal', internalRoutes); // Cleanup uses internal API key app.route('/api/v1/cleanup', cleanupRoutes); +// Meeting bot webhooks — HMAC-verified, no JWT +app.route('/meetings/webhooks', meetingWebhookRoutes); + // ── Authenticated routes ─────────────────────────────────────────────────────── app.use('/api/v1/*', authMiddleware()); @@ -77,6 +82,7 @@ app.route('/api/v1/spaces', spaceRoutes); app.route('/api/v1/invites', inviteRoutes); app.route('/api/v1/credits', creditRoutes); app.route('/api/v1/settings', settingsRoutes); +app.route('/api/v1/meetings', meetingRoutes); // ── Start ────────────────────────────────────────────────────────────────────── diff --git a/apps/memoro/apps/server/src/routes/meetings-webhooks.ts b/apps/memoro/apps/server/src/routes/meetings-webhooks.ts new file mode 100644 index 000000000..c0bb6aa65 --- /dev/null +++ b/apps/memoro/apps/server/src/routes/meetings-webhooks.ts @@ -0,0 +1,121 @@ +/** + * Meeting bot webhook routes — no JWT auth, HMAC signature verification. + */ + +import { Hono } from 'hono'; +import { createHmac, timingSafeEqual } from 'crypto'; +import { consumeCredits, COSTS } from '../lib/credits'; +import { updateBotCredits, type WebhookEvent } from '../services/meetings'; + +export const meetingWebhookRoutes = new Hono(); + +const WEBHOOK_SECRET = process.env.MEETING_BOT_WEBHOOK_SECRET ?? ''; + +// In-memory idempotency store (last 1000 events) +const processedEvents = new Set(); + +function verifySignature(payload: string, signature: string): boolean { + if (!WEBHOOK_SECRET) return true; // Dev: no secret configured + + if (!signature) return false; + + const [algorithm, provided] = signature.split('='); + if (algorithm !== 'sha256' || !provided) return false; + + try { + const expected = createHmac('sha256', WEBHOOK_SECRET).update(payload).digest('hex'); + const providedBuf = Buffer.from(provided, 'hex'); + const expectedBuf = Buffer.from(expected, 'hex'); + if (providedBuf.length !== expectedBuf.length) return false; + return timingSafeEqual(providedBuf, expectedBuf); + } catch { + return false; + } +} + +function idempotencyKey(event: WebhookEvent): string { + return `${event.bot.id}:${event.event}:${event.timestamp}`; +} + +function calcCredits(durationSeconds: number): number { + const minutes = durationSeconds / 60; + return Math.max(Math.ceil(minutes * COSTS.MEETING_RECORDING_PER_MINUTE), 2); +} + +// POST /bot-events +meetingWebhookRoutes.post('/bot-events', async (c) => { + const rawBody = await c.req.text(); + const signature = c.req.header('x-webhook-signature') ?? ''; + + if (WEBHOOK_SECRET && !verifySignature(rawBody, signature)) { + return c.json({ error: 'Invalid webhook signature' }, 401); + } + + let payload: WebhookEvent; + try { + payload = JSON.parse(rawBody) as WebhookEvent; + } catch { + return c.json({ error: 'Invalid JSON payload' }, 400); + } + + const key = idempotencyKey(payload); + if (processedEvents.has(key)) { + return c.json({ success: true, message: 'Event already processed' }); + } + + processedEvents.add(key); + // Trim old entries + if (processedEvents.size > 1000) { + const iter = processedEvents.values(); + for (let i = 0; i < 500; i++) processedEvents.delete(iter.next().value as string); + } + + try { + if (payload.event === 'recording.completed') { + const { bot, recording } = payload; + const durationSeconds = recording?.duration_seconds ?? 0; + const credits = calcCredits(durationSeconds); + + try { + await consumeCredits( + bot.user_id, + 'meeting_recording', + credits, + `Meeting recording completed - ${Math.round(durationSeconds / 60)} minutes`, + { botId: bot.id, recordingId: payload.recording?.id, durationSeconds }, + bot.space_id ? { type: 'guild', guildId: bot.space_id } : undefined + ); + await updateBotCredits(bot.id, credits, durationSeconds); + } catch (err) { + // Don't fail the webhook — recording is still valid, credits can be reconciled + console.error('[meetings-webhook] Failed to consume credits:', err); + } + + return c.json({ + success: true, + message: 'Recording completed processed', + botId: bot.id, + recordingId: recording?.id, + creditsConsumed: credits, + }); + } + + if (payload.event === 'recording.failed') { + const { bot, error } = payload; + console.error(`[meetings-webhook] Bot ${bot.id} failed: ${error?.code} - ${error?.message}`); + return c.json({ + success: true, + message: 'Recording failure processed', + botId: bot.id, + error: error?.message, + }); + } + + return c.json({ success: true, message: 'Unknown event type' }); + } catch (err) { + // Remove from processed set so it can be retried + processedEvents.delete(key); + console.error('[meetings-webhook] Error processing event:', err); + return c.json({ error: 'Failed to process webhook event' }, 500); + } +}); diff --git a/apps/memoro/apps/server/src/routes/meetings.ts b/apps/memoro/apps/server/src/routes/meetings.ts new file mode 100644 index 000000000..49bf16c81 --- /dev/null +++ b/apps/memoro/apps/server/src/routes/meetings.ts @@ -0,0 +1,189 @@ +/** + * Meetings routes — authenticated. + * Handles meeting bot management and recording → memo conversion. + */ + +import { Hono } from 'hono'; +import type { AuthVariables } from '@manacore/shared-hono'; +import { validateCredits, COSTS } from '../lib/credits'; +import { + validateMeetingUrl, + createBot, + stopBot, + getBots, + getBotById, + getRecordings, + getRecordingById, +} from '../services/meetings'; + +export const meetingRoutes = new Hono<{ Variables: AuthVariables }>(); + +// Minimum credits required to start a recording (5 minutes worth) +const MINIMUM_RECORDING_CREDITS = 10; + +// POST /bots — create a meeting bot +meetingRoutes.post('/bots', async (c) => { + const userId = c.get('userId') as string; + const body = await c.req.json<{ meeting_url?: string; space_id?: string }>(); + + if (!body.meeting_url || !validateMeetingUrl(body.meeting_url)) { + return c.json( + { error: 'Please provide a valid Teams, Google Meet, or Zoom meeting URL' }, + 400 + ); + } + + // Validate minimum credits + const creditCheck = await validateCredits(userId, 'meeting_recording', MINIMUM_RECORDING_CREDITS); + if (!creditCheck.hasCredits) { + return c.json( + { + error: 'InsufficientCredits', + message: `Not enough credits to start recording. Need at least ${MINIMUM_RECORDING_CREDITS} credits.`, + details: { + requiredCredits: MINIMUM_RECORDING_CREDITS, + availableCredits: creditCheck.availableCredits, + }, + }, + 402 + ); + } + + try { + const webhookBaseUrl = (process.env.MEMORO_SERVER_URL ?? `http://localhost:${process.env.PORT ?? 3015}`).replace(/\/$/, ''); + const bot = await createBot(userId, body.meeting_url, webhookBaseUrl, body.space_id); + return c.json({ + success: true, + bot, + message: 'Meeting bot created. It will join the meeting shortly.', + creditInfo: { + estimatedCostPerMinute: COSTS.MEETING_RECORDING_PER_MINUTE, + minimumCredits: MINIMUM_RECORDING_CREDITS, + availableCredits: creditCheck.availableCredits, + }, + }); + } catch (err) { + const msg = err instanceof Error ? err.message : 'Failed to create meeting bot'; + return c.json({ error: msg }, 400); + } +}); + +// GET /bots — list bots +meetingRoutes.get('/bots', async (c) => { + const userId = c.get('userId') as string; + const spaceId = c.req.query('space_id'); + const limit = Number(c.req.query('limit') ?? 50); + const offset = Number(c.req.query('offset') ?? 0); + + try { + const bots = await getBots(userId, spaceId, limit, offset); + return c.json({ success: true, bots, total: bots.length }); + } catch (err) { + const msg = err instanceof Error ? err.message : 'Failed to fetch bots'; + return c.json({ error: msg }, 500); + } +}); + +// GET /bots/:id — get single bot +meetingRoutes.get('/bots/:id', async (c) => { + const userId = c.get('userId') as string; + const botId = c.req.param('id'); + + const bot = await getBotById(botId, userId); + if (!bot) return c.json({ error: 'Bot not found' }, 404); + + return c.json({ success: true, bot }); +}); + +// POST /bots/:id/stop — stop a bot +meetingRoutes.post('/bots/:id/stop', async (c) => { + const userId = c.get('userId') as string; + const botId = c.req.param('id'); + + try { + await stopBot(botId, userId); + return c.json({ success: true, message: 'Bot stop signal sent. Recording will end shortly.' }); + } catch (err) { + const msg = err instanceof Error ? err.message : 'Failed to stop bot'; + const status = msg === 'Bot not found' ? 404 : 400; + return c.json({ error: msg }, status); + } +}); + +// GET /recordings — list recordings +meetingRoutes.get('/recordings', async (c) => { + const userId = c.get('userId') as string; + const spaceId = c.req.query('space_id'); + const limit = Number(c.req.query('limit') ?? 50); + const offset = Number(c.req.query('offset') ?? 0); + + try { + const recordings = await getRecordings(userId, spaceId, limit, offset); + return c.json({ success: true, recordings, total: recordings.length }); + } catch (err) { + const msg = err instanceof Error ? err.message : 'Failed to fetch recordings'; + return c.json({ error: msg }, 500); + } +}); + +// GET /recordings/:id — get single recording +meetingRoutes.get('/recordings/:id', async (c) => { + const userId = c.get('userId') as string; + const recordingId = c.req.param('id'); + + const recording = await getRecordingById(recordingId, userId); + if (!recording) return c.json({ error: 'Recording not found' }, 404); + + return c.json({ success: true, recording }); +}); + +// POST /recordings/:id/to-memo — convert recording to memo +meetingRoutes.post('/recordings/:id/to-memo', async (c) => { + const userId = c.get('userId') as string; + const recordingId = c.req.param('id'); + const body = await c.req.json<{ blueprintId?: string }>().catch(() => ({ blueprintId: undefined })); + const authHeader = c.req.header('Authorization'); + + const recording = await getRecordingById(recordingId, userId); + if (!recording) return c.json({ error: 'Recording not found' }, 404); + + const filePath = recording.audio_url || recording.video_url; + if (!filePath) return c.json({ error: 'Recording has no audio or video file' }, 400); + + const duration = recording.duration_seconds ?? 45; + + try { + const port = process.env.PORT ?? 3015; + const response = await fetch(`http://localhost:${port}/api/v1/memos`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: authHeader ?? '', + }, + body: JSON.stringify({ + filePath, + duration, + spaceId: recording.space_id, + blueprintId: body.blueprintId, + }), + }); + + if (!response.ok) { + const errData = await response.json().catch(() => ({ message: 'Unknown error' })) as Record; + return c.json({ error: (errData.message as string) || 'Failed to process recording' }, 400); + } + + const result = await response.json() as Record; + return c.json({ + success: true, + memoId: result.memoId, + memo: result.memo, + audioPath: filePath, + status: result.status, + message: 'Recording converted to memo. Transcription in progress.', + }); + } catch (err) { + const msg = err instanceof Error ? err.message : 'Failed to convert recording to memo'; + return c.json({ error: msg }, 500); + } +}); diff --git a/apps/memoro/apps/server/src/services/meetings.ts b/apps/memoro/apps/server/src/services/meetings.ts new file mode 100644 index 000000000..cef8749a8 --- /dev/null +++ b/apps/memoro/apps/server/src/services/meetings.ts @@ -0,0 +1,291 @@ +/** + * Meetings proxy service — proxies to meeting-bot service + direct Supabase queries. + */ + +import { createServiceClient } from '../lib/supabase'; + +export type MeetingBotState = + | 'registering' + | 'provisioning' + | 'joining' + | 'waiting_room' + | 'joined' + | 'recording' + | 'recording_error' + | 'leaving' + | 'left' + | 'error'; + +export type MeetingVendor = 'teams' | 'meet' | 'zoom'; + +export interface MeetingBot { + id: string; + created_at: string; + ended_at?: string; + updated_at: string; + vendor: MeetingVendor; + state: MeetingBotState; + meeting_id?: string; + meeting_code: string; + meeting_url?: string; + external_bot_id?: string; + user_id: string; + space_id?: string; + credits_consumed?: number; + duration_seconds?: number; +} + +export interface MeetingRecording { + id: string; + created_at: string; + updated_at: string; + file_url?: string; + video_url?: string; + audio_url?: string; + transcript?: string; + duration_seconds?: number; + bot_id: string; + user_id: string; + space_id?: string; + audio_signed_url?: string | null; + video_signed_url?: string | null; +} + +export interface MeetingBotWithRecording extends MeetingBot { + recording?: MeetingRecording; +} + +export interface CreateBotResponse { + id: string; + external_bot_id: string; + meeting_url: string; + state: MeetingBotState; + created_at: string; +} + +export interface WebhookEvent { + event: 'recording.completed' | 'recording.failed'; + timestamp: string; + bot: { + id: string; + external_bot_id: string; + user_id: string; + space_id?: string; + state: string; + completed_at?: string; + failed_at?: string; + }; + recording?: { + id: string; + video_url?: string; + audio_url?: string; + file_url?: string; + transcript?: string; + speakers?: object; + duration_seconds?: number; + created_at: string; + }; + error?: { + code: string; + message: string; + }; +} + +const MEETING_BOT_API_URL = process.env.MEETING_BOT_API_URL ?? ''; +const MEETING_BOT_API_KEY = process.env.MEETING_BOT_API_KEY ?? ''; +const USER_UPLOADS_BUCKET = process.env.USER_UPLOADS_BUCKET ?? 'user-uploads'; + +export function detectPlatform(meetingUrl: string): MeetingVendor { + if (/teams\.microsoft\.com/i.test(meetingUrl)) return 'teams'; + if (/meet\.google\.com/i.test(meetingUrl)) return 'meet'; + if (/zoom\.(us|com)/i.test(meetingUrl)) return 'zoom'; + throw new Error('Unsupported meeting platform. Use Teams, Meet, or Zoom.'); +} + +export function validateMeetingUrl(url: string): boolean { + if (!url) return false; + return /(teams\.microsoft\.com|meet\.google\.com|zoom\.(us|com))/i.test(url); +} + +export async function createBot( + userId: string, + meetingUrl: string, + webhookBaseUrl: string, + spaceId?: string +): Promise { + if (!MEETING_BOT_API_URL || !MEETING_BOT_API_KEY) { + throw new Error('Meeting bot service not configured'); + } + + const response = await fetch(`${MEETING_BOT_API_URL}/bots`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'x-api-key': MEETING_BOT_API_KEY, + }, + body: JSON.stringify({ + user_id: userId, + space_id: spaceId, + meeting_url: meetingUrl, + completed_webhook_url: `${webhookBaseUrl}/meetings/webhooks/bot-events`, + failed_webhook_url: `${webhookBaseUrl}/meetings/webhooks/bot-events`, + }), + }); + + if (!response.ok) { + const err = await response.json().catch(() => ({})) as Record; + throw new Error((err.message as string) || `Failed to create meeting bot: ${response.statusText}`); + } + + return response.json(); +} + +export async function stopBot(botId: string, userId: string): Promise { + // Verify ownership first + const bot = await getBotById(botId, userId); + if (!bot) throw new Error('Bot not found'); + + if (!MEETING_BOT_API_URL || !MEETING_BOT_API_KEY) { + throw new Error('Meeting bot service not configured'); + } + + const response = await fetch(`${MEETING_BOT_API_URL}/bots/${botId}/stop`, { + method: 'POST', + headers: { 'x-api-key': MEETING_BOT_API_KEY }, + }); + + if (!response.ok) { + const err = await response.json().catch(() => ({})) as Record; + throw new Error((err.message as string) || `Failed to stop meeting bot: ${response.statusText}`); + } +} + +export async function getBots( + userId: string, + spaceId?: string, + limit = 50, + offset = 0 +): Promise { + const supabase = createServiceClient(); + + let query = supabase + .from('meeting_bots') + .select('*') + .eq('user_id', userId) + .order('created_at', { ascending: false }) + .range(offset, offset + limit - 1); + + if (spaceId) query = query.eq('space_id', spaceId); + + const { data: bots, error } = await query; + if (error) throw new Error('Failed to fetch bots'); + + return Promise.all( + (bots ?? []).map(async (bot: MeetingBot) => { + const { data: recordings } = await supabase + .from('meeting_recordings') + .select('*') + .eq('bot_id', bot.id) + .limit(1); + return { ...bot, recording: recordings?.[0] ?? undefined }; + }) + ); +} + +export async function getBotById( + botId: string, + userId: string +): Promise { + const supabase = createServiceClient(); + + const { data: bot, error } = await supabase + .from('meeting_bots') + .select('*') + .eq('id', botId) + .eq('user_id', userId) + .single(); + + if (error || !bot) return null; + + const { data: recordings } = await supabase + .from('meeting_recordings') + .select('*') + .eq('bot_id', botId) + .limit(1); + + return { ...bot, recording: recordings?.[0] ?? undefined }; +} + +async function generateSignedUrl(storagePath: string): Promise { + if (!storagePath) return null; + const supabase = createServiceClient(); + const { data, error } = await supabase.storage + .from(USER_UPLOADS_BUCKET) + .createSignedUrl(storagePath, 3600); + if (error) return null; + return data?.signedUrl ?? null; +} + +async function addSignedUrls(recording: MeetingRecording): Promise { + const [audioSignedUrl, videoSignedUrl] = await Promise.all([ + recording.audio_url ? generateSignedUrl(recording.audio_url) : null, + recording.video_url ? generateSignedUrl(recording.video_url) : null, + ]); + return { ...recording, audio_signed_url: audioSignedUrl, video_signed_url: videoSignedUrl }; +} + +export async function getRecordings( + userId: string, + spaceId?: string, + limit = 50, + offset = 0 +): Promise { + const supabase = createServiceClient(); + + let query = supabase + .from('meeting_recordings') + .select('*') + .eq('user_id', userId) + .order('created_at', { ascending: false }) + .range(offset, offset + limit - 1); + + if (spaceId) query = query.eq('space_id', spaceId); + + const { data: recordings, error } = await query; + if (error) throw new Error('Failed to fetch recordings'); + + return Promise.all((recordings ?? []).map(addSignedUrls)); +} + +export async function getRecordingById( + recordingId: string, + userId: string +): Promise { + const supabase = createServiceClient(); + + const { data: recording, error } = await supabase + .from('meeting_recordings') + .select('*') + .eq('id', recordingId) + .eq('user_id', userId) + .single(); + + if (error || !recording) return null; + return addSignedUrls(recording); +} + +export async function updateBotCredits( + botId: string, + creditsConsumed: number, + durationSeconds?: number +): Promise { + const supabase = createServiceClient(); + const update: Record = { + credits_consumed: creditsConsumed, + updated_at: new Date().toISOString(), + }; + if (durationSeconds !== undefined) update.duration_seconds = durationSeconds; + + const { error } = await supabase.from('meeting_bots').update(update).eq('id', botId); + if (error) throw new Error('Failed to update bot credits'); +}