feat(memoro/server): port meetings module to Hono/Bun (Phase 7)

- services/meetings.ts: proxy service for meeting-bot API + direct Supabase
  queries (bots, recordings, signed URLs, credit updates)
- routes/meetings.ts: authenticated routes — create/list/get/stop bots,
  list/get recordings, convert recording to memo via internal /api/v1/memos
- routes/meetings-webhooks.ts: HMAC-verified webhook handler for
  recording.completed / recording.failed events, in-memory idempotency
- index.ts: mount /api/v1/meetings (auth) and /meetings/webhooks (HMAC)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-04-01 10:55:03 +02:00
parent 3f0811043e
commit aa645c28fd
4 changed files with 607 additions and 0 deletions

View file

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

View file

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

View file

@ -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<string, unknown>;
return c.json({ error: (errData.message as string) || 'Failed to process recording' }, 400);
}
const result = await response.json() as Record<string, unknown>;
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);
}
});

View file

@ -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<CreateBotResponse> {
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<string, unknown>;
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<void> {
// 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<string, unknown>;
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<MeetingBotWithRecording[]> {
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<MeetingBotWithRecording | null> {
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<string | null> {
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<MeetingRecording> {
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<MeetingRecording[]> {
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<MeetingRecording | null> {
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<void> {
const supabase = createServiceClient();
const update: Record<string, unknown> = {
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');
}