feat: add voice recordings feature with database integration
- Implemented PostgreSQL and SQLite schemas for voice recordings. - Created repository functions for inserting, updating, and listing voice recordings. - Developed uploader logic to handle file uploads and database updates. - Added routes for accessing voice recordings via API. - Enhanced UI state to include recordings tab. - Introduced moderation WebSocket event for uploaded recordings.
This commit is contained in:
@@ -0,0 +1,20 @@
|
||||
CREATE TABLE "voice_recordings" (
|
||||
"id" text PRIMARY KEY NOT NULL,
|
||||
"user_id" text NOT NULL,
|
||||
"username" text NOT NULL,
|
||||
"avatar_url" text,
|
||||
"guild_id" text,
|
||||
"channel_id" text,
|
||||
"channel_name" text,
|
||||
"filename" text NOT NULL,
|
||||
"size_bytes" integer NOT NULL,
|
||||
"download_url" text,
|
||||
"upload_status" text DEFAULT 'pending' NOT NULL,
|
||||
"upload_error" text,
|
||||
"created_at" bigint NOT NULL,
|
||||
"uploaded_at" bigint
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE INDEX "idx_voice_recordings_user_id" ON "voice_recordings" USING btree ("user_id");--> statement-breakpoint
|
||||
CREATE INDEX "idx_voice_recordings_channel_id" ON "voice_recordings" USING btree ("channel_id");--> statement-breakpoint
|
||||
CREATE INDEX "idx_voice_recordings_created_at" ON "voice_recordings" USING btree ("created_at");
|
||||
@@ -0,0 +1,943 @@
|
||||
{
|
||||
"id": "a4dd8687-4a2c-4520-9cc5-03aa1774e383",
|
||||
"prevId": "79348c3a-b314-4bfd-88f3-7ddfbea4427e",
|
||||
"version": "7",
|
||||
"dialect": "postgresql",
|
||||
"tables": {
|
||||
"public.ai_analysis_runs": {
|
||||
"name": "ai_analysis_runs",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"conversation_key": {
|
||||
"name": "conversation_key",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"target_message_ids": {
|
||||
"name": "target_message_ids",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"model": {
|
||||
"name": "model",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"request_tokens_estimate": {
|
||||
"name": "request_tokens_estimate",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"response_raw": {
|
||||
"name": "response_raw",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"status": {
|
||||
"name": "status",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "'pending'"
|
||||
},
|
||||
"error": {
|
||||
"name": "error",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "bigint",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"completed_at": {
|
||||
"name": "completed_at",
|
||||
"type": "bigint",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"idx_ai_analysis_runs_conversation_key": {
|
||||
"name": "idx_ai_analysis_runs_conversation_key",
|
||||
"columns": [
|
||||
{
|
||||
"expression": "conversation_key",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
}
|
||||
],
|
||||
"isUnique": false,
|
||||
"concurrently": false,
|
||||
"method": "btree",
|
||||
"with": {}
|
||||
},
|
||||
"idx_ai_analysis_runs_status": {
|
||||
"name": "idx_ai_analysis_runs_status",
|
||||
"columns": [
|
||||
{
|
||||
"expression": "status",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
}
|
||||
],
|
||||
"isUnique": false,
|
||||
"concurrently": false,
|
||||
"method": "btree",
|
||||
"with": {}
|
||||
},
|
||||
"idx_ai_analysis_runs_created_at": {
|
||||
"name": "idx_ai_analysis_runs_created_at",
|
||||
"columns": [
|
||||
{
|
||||
"expression": "created_at",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
}
|
||||
],
|
||||
"isUnique": false,
|
||||
"concurrently": false,
|
||||
"method": "btree",
|
||||
"with": {}
|
||||
}
|
||||
},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.attachments": {
|
||||
"name": "attachments",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"message_id": {
|
||||
"name": "message_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"guild_id": {
|
||||
"name": "guild_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"channel_id": {
|
||||
"name": "channel_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"thread_id": {
|
||||
"name": "thread_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"user_id": {
|
||||
"name": "user_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"filename": {
|
||||
"name": "filename",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"size": {
|
||||
"name": "size",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"type": {
|
||||
"name": "type",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"discord_url": {
|
||||
"name": "discord_url",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"uploaded_url": {
|
||||
"name": "uploaded_url",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"upload_status": {
|
||||
"name": "upload_status",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "'pending'"
|
||||
},
|
||||
"upload_error": {
|
||||
"name": "upload_error",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "bigint",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"uploaded_at": {
|
||||
"name": "uploaded_at",
|
||||
"type": "bigint",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"idx_attachments_channel": {
|
||||
"name": "idx_attachments_channel",
|
||||
"columns": [
|
||||
{
|
||||
"expression": "channel_id",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
}
|
||||
],
|
||||
"isUnique": false,
|
||||
"concurrently": false,
|
||||
"method": "btree",
|
||||
"with": {}
|
||||
},
|
||||
"idx_attachments_message": {
|
||||
"name": "idx_attachments_message",
|
||||
"columns": [
|
||||
{
|
||||
"expression": "message_id",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
}
|
||||
],
|
||||
"isUnique": false,
|
||||
"concurrently": false,
|
||||
"method": "btree",
|
||||
"with": {}
|
||||
},
|
||||
"idx_attachments_status": {
|
||||
"name": "idx_attachments_status",
|
||||
"columns": [
|
||||
{
|
||||
"expression": "upload_status",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
}
|
||||
],
|
||||
"isUnique": false,
|
||||
"concurrently": false,
|
||||
"method": "btree",
|
||||
"with": {}
|
||||
},
|
||||
"idx_attachments_channel_created": {
|
||||
"name": "idx_attachments_channel_created",
|
||||
"columns": [
|
||||
{
|
||||
"expression": "channel_id",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
},
|
||||
{
|
||||
"expression": "created_at",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
},
|
||||
{
|
||||
"expression": "id",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
}
|
||||
],
|
||||
"isUnique": false,
|
||||
"concurrently": false,
|
||||
"method": "btree",
|
||||
"with": {}
|
||||
},
|
||||
"idx_attachments_thread_created": {
|
||||
"name": "idx_attachments_thread_created",
|
||||
"columns": [
|
||||
{
|
||||
"expression": "thread_id",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
},
|
||||
{
|
||||
"expression": "created_at",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
},
|
||||
{
|
||||
"expression": "id",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
}
|
||||
],
|
||||
"isUnique": false,
|
||||
"concurrently": false,
|
||||
"method": "btree",
|
||||
"with": {}
|
||||
}
|
||||
},
|
||||
"foreignKeys": {
|
||||
"fk_attachments_message_id": {
|
||||
"name": "fk_attachments_message_id",
|
||||
"tableFrom": "attachments",
|
||||
"tableTo": "messages",
|
||||
"columnsFrom": [
|
||||
"message_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.messages": {
|
||||
"name": "messages",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"guild_id": {
|
||||
"name": "guild_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"channel_id": {
|
||||
"name": "channel_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"thread_id": {
|
||||
"name": "thread_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"user_id": {
|
||||
"name": "user_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"username": {
|
||||
"name": "username",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"avatar_url": {
|
||||
"name": "avatar_url",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"content": {
|
||||
"name": "content",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"edited_content": {
|
||||
"name": "edited_content",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "bigint",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"edited_at": {
|
||||
"name": "edited_at",
|
||||
"type": "bigint",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"deleted_at": {
|
||||
"name": "deleted_at",
|
||||
"type": "bigint",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"type": {
|
||||
"name": "type",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "'text'"
|
||||
},
|
||||
"metadata": {
|
||||
"name": "metadata",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"ai_status": {
|
||||
"name": "ai_status",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "'pending'"
|
||||
},
|
||||
"ai_moderation_flags": {
|
||||
"name": "ai_moderation_flags",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"ai_moderation_score": {
|
||||
"name": "ai_moderation_score",
|
||||
"type": "real",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"ai_moderation_raw": {
|
||||
"name": "ai_moderation_raw",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"ai_analysis": {
|
||||
"name": "ai_analysis",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"ai_analyzed_at": {
|
||||
"name": "ai_analyzed_at",
|
||||
"type": "bigint",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"ai_error": {
|
||||
"name": "ai_error",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"idx_messages_channel": {
|
||||
"name": "idx_messages_channel",
|
||||
"columns": [
|
||||
{
|
||||
"expression": "channel_id",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
}
|
||||
],
|
||||
"isUnique": false,
|
||||
"concurrently": false,
|
||||
"method": "btree",
|
||||
"with": {}
|
||||
},
|
||||
"idx_messages_user": {
|
||||
"name": "idx_messages_user",
|
||||
"columns": [
|
||||
{
|
||||
"expression": "user_id",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
}
|
||||
],
|
||||
"isUnique": false,
|
||||
"concurrently": false,
|
||||
"method": "btree",
|
||||
"with": {}
|
||||
},
|
||||
"idx_messages_created": {
|
||||
"name": "idx_messages_created",
|
||||
"columns": [
|
||||
{
|
||||
"expression": "created_at",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
}
|
||||
],
|
||||
"isUnique": false,
|
||||
"concurrently": false,
|
||||
"method": "btree",
|
||||
"with": {}
|
||||
},
|
||||
"idx_messages_thread": {
|
||||
"name": "idx_messages_thread",
|
||||
"columns": [
|
||||
{
|
||||
"expression": "thread_id",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
}
|
||||
],
|
||||
"isUnique": false,
|
||||
"concurrently": false,
|
||||
"method": "btree",
|
||||
"with": {}
|
||||
},
|
||||
"idx_messages_channel_created": {
|
||||
"name": "idx_messages_channel_created",
|
||||
"columns": [
|
||||
{
|
||||
"expression": "channel_id",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
},
|
||||
{
|
||||
"expression": "created_at",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
},
|
||||
{
|
||||
"expression": "id",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
}
|
||||
],
|
||||
"isUnique": false,
|
||||
"concurrently": false,
|
||||
"method": "btree",
|
||||
"with": {}
|
||||
},
|
||||
"idx_messages_thread_created": {
|
||||
"name": "idx_messages_thread_created",
|
||||
"columns": [
|
||||
{
|
||||
"expression": "thread_id",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
},
|
||||
{
|
||||
"expression": "created_at",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
},
|
||||
{
|
||||
"expression": "id",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
}
|
||||
],
|
||||
"isUnique": false,
|
||||
"concurrently": false,
|
||||
"method": "btree",
|
||||
"with": {}
|
||||
},
|
||||
"idx_messages_ai_status_created": {
|
||||
"name": "idx_messages_ai_status_created",
|
||||
"columns": [
|
||||
{
|
||||
"expression": "ai_status",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
},
|
||||
{
|
||||
"expression": "created_at",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
},
|
||||
{
|
||||
"expression": "id",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
}
|
||||
],
|
||||
"isUnique": false,
|
||||
"concurrently": false,
|
||||
"method": "btree",
|
||||
"with": {}
|
||||
},
|
||||
"idx_messages_guild_ai_status_created": {
|
||||
"name": "idx_messages_guild_ai_status_created",
|
||||
"columns": [
|
||||
{
|
||||
"expression": "guild_id",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
},
|
||||
{
|
||||
"expression": "ai_status",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
},
|
||||
{
|
||||
"expression": "created_at",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
},
|
||||
{
|
||||
"expression": "id",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
}
|
||||
],
|
||||
"isUnique": false,
|
||||
"concurrently": false,
|
||||
"method": "btree",
|
||||
"with": {}
|
||||
}
|
||||
},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.muxer_jobs": {
|
||||
"name": "muxer_jobs",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"data": {
|
||||
"name": "data",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"status": {
|
||||
"name": "status",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "'pending'"
|
||||
},
|
||||
"attempts": {
|
||||
"name": "attempts",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": 0
|
||||
},
|
||||
"maxAttempts": {
|
||||
"name": "maxAttempts",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": 3
|
||||
},
|
||||
"createdAt": {
|
||||
"name": "createdAt",
|
||||
"type": "bigint",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"updatedAt": {
|
||||
"name": "updatedAt",
|
||||
"type": "bigint",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"error": {
|
||||
"name": "error",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"idx_muxer_jobs_status": {
|
||||
"name": "idx_muxer_jobs_status",
|
||||
"columns": [
|
||||
{
|
||||
"expression": "status",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
}
|
||||
],
|
||||
"isUnique": false,
|
||||
"concurrently": false,
|
||||
"method": "btree",
|
||||
"with": {}
|
||||
},
|
||||
"idx_muxer_jobs_createdAt": {
|
||||
"name": "idx_muxer_jobs_createdAt",
|
||||
"columns": [
|
||||
{
|
||||
"expression": "createdAt",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
}
|
||||
],
|
||||
"isUnique": false,
|
||||
"concurrently": false,
|
||||
"method": "btree",
|
||||
"with": {}
|
||||
}
|
||||
},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.ui_state": {
|
||||
"name": "ui_state",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"key": {
|
||||
"name": "key",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"value": {
|
||||
"name": "value",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "bigint",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.voice_recordings": {
|
||||
"name": "voice_recordings",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"user_id": {
|
||||
"name": "user_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"username": {
|
||||
"name": "username",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"avatar_url": {
|
||||
"name": "avatar_url",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"guild_id": {
|
||||
"name": "guild_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"channel_id": {
|
||||
"name": "channel_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"channel_name": {
|
||||
"name": "channel_name",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"filename": {
|
||||
"name": "filename",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"size_bytes": {
|
||||
"name": "size_bytes",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"download_url": {
|
||||
"name": "download_url",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"upload_status": {
|
||||
"name": "upload_status",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "'pending'"
|
||||
},
|
||||
"upload_error": {
|
||||
"name": "upload_error",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "bigint",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"uploaded_at": {
|
||||
"name": "uploaded_at",
|
||||
"type": "bigint",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"idx_voice_recordings_user_id": {
|
||||
"name": "idx_voice_recordings_user_id",
|
||||
"columns": [
|
||||
{
|
||||
"expression": "user_id",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
}
|
||||
],
|
||||
"isUnique": false,
|
||||
"concurrently": false,
|
||||
"method": "btree",
|
||||
"with": {}
|
||||
},
|
||||
"idx_voice_recordings_channel_id": {
|
||||
"name": "idx_voice_recordings_channel_id",
|
||||
"columns": [
|
||||
{
|
||||
"expression": "channel_id",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
}
|
||||
],
|
||||
"isUnique": false,
|
||||
"concurrently": false,
|
||||
"method": "btree",
|
||||
"with": {}
|
||||
},
|
||||
"idx_voice_recordings_created_at": {
|
||||
"name": "idx_voice_recordings_created_at",
|
||||
"columns": [
|
||||
{
|
||||
"expression": "created_at",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
}
|
||||
],
|
||||
"isUnique": false,
|
||||
"concurrently": false,
|
||||
"method": "btree",
|
||||
"with": {}
|
||||
}
|
||||
},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
}
|
||||
},
|
||||
"enums": {},
|
||||
"schemas": {},
|
||||
"sequences": {},
|
||||
"roles": {},
|
||||
"policies": {},
|
||||
"views": {},
|
||||
"_meta": {
|
||||
"columns": {},
|
||||
"schemas": {},
|
||||
"tables": {}
|
||||
}
|
||||
}
|
||||
@@ -15,6 +15,13 @@
|
||||
"when": 1778764447718,
|
||||
"tag": "0001_curious_zodiak",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 2,
|
||||
"version": "7",
|
||||
"when": 1779109619461,
|
||||
"tag": "0002_dark_omega_flight",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
+14
-2
@@ -5,6 +5,7 @@ import { MessagesPanel } from "./components/messages/MessagesPanel";
|
||||
import { ReviewPanel } from "./components/review/ReviewPanel";
|
||||
import { Tabs, TabsContent } from "./components/ui/tabs";
|
||||
import { VoicePanel } from "./components/voice/VoicePanel";
|
||||
import { RecordingsPanel } from "./components/recordings/RecordingsPanel";
|
||||
import { AuthOverlay } from "./components/layout/AuthOverlay";
|
||||
import { useDashboardSocket } from "./hooks/useDashboardSocket";
|
||||
import { mergeMessages, useMessages } from "./hooks/useMessages";
|
||||
@@ -75,6 +76,10 @@ export default function App() {
|
||||
onMessageAnalyzed: (message) => messages.setMessages((prev) => mergeMessages(prev, [message])),
|
||||
onAttachmentUploaded: () => messages.fetchMessages(selectedTextChannel).catch(() => undefined),
|
||||
onMediaState: media.setMediaState,
|
||||
onVoiceRecordingUploaded: (recording) => {
|
||||
const event = new CustomEvent("voice_recording_uploaded", { detail: recording });
|
||||
window.dispatchEvent(event);
|
||||
},
|
||||
onPcm: handleIncomingPcm,
|
||||
});
|
||||
|
||||
@@ -178,7 +183,7 @@ export default function App() {
|
||||
await patchUIState({ isListening: true });
|
||||
}, [isListening, patchUIState]);
|
||||
|
||||
const tabs = useMemo(() => ["voice", "media", "messages", "review"] as DashboardTab[], []);
|
||||
const tabs = useMemo(() => ["voice", "media", "messages", "recordings", "review"] as DashboardTab[], []);
|
||||
|
||||
return (
|
||||
<DashboardLayout
|
||||
@@ -189,7 +194,7 @@ export default function App() {
|
||||
>
|
||||
<div className="md:hidden">
|
||||
<Tabs value={activeTab} onValueChange={(value) => patchUIState({ activeTab: value as DashboardTab })}>
|
||||
<div className="mb-4 grid grid-cols-4 gap-2 rounded-2xl bg-muted p-1">
|
||||
<div className="mb-4 grid grid-cols-5 gap-2 rounded-2xl bg-muted p-1">
|
||||
{tabs.map((tab) => (
|
||||
<button key={tab} className={`rounded-xl px-2 py-2 text-xs font-medium ${activeTab === tab ? "bg-background text-foreground" : "text-muted-foreground"}`} onClick={() => patchUIState({ activeTab: tab })}>
|
||||
{tab}
|
||||
@@ -250,6 +255,13 @@ export default function App() {
|
||||
onReanalyze={messages.reanalyze}
|
||||
/>
|
||||
</TabsContent>
|
||||
<TabsContent value="recordings">
|
||||
{!isAuthenticated ? (
|
||||
<AuthOverlay onAuthenticated={() => setIsAuthenticated(true)} />
|
||||
) : (
|
||||
<RecordingsPanel />
|
||||
)}
|
||||
</TabsContent>
|
||||
<TabsContent value="review">
|
||||
<ReviewPanel messages={messages.messages} onReanalyze={messages.reanalyze} />
|
||||
</TabsContent>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Bot, MessageSquare, Music2, ShieldAlert, Volume2 } from "lucide-react";
|
||||
import { Bot, MessageSquare, Music2, ShieldAlert, Volume2, Mic } from "lucide-react";
|
||||
import type { DashboardTab } from "../../types/ui";
|
||||
import { cn } from "../../lib/utils";
|
||||
import { Button } from "../ui/button";
|
||||
@@ -7,6 +7,7 @@ const navItems: Array<{ id: DashboardTab; label: string; icon: typeof Volume2 }>
|
||||
{ id: "voice", label: "Voice", icon: Volume2 },
|
||||
{ id: "media", label: "Media", icon: Music2 },
|
||||
{ id: "messages", label: "Messages", icon: MessageSquare },
|
||||
{ id: "recordings", label: "Recordings", icon: Mic },
|
||||
{ id: "review", label: "Review", icon: ShieldAlert },
|
||||
];
|
||||
|
||||
|
||||
@@ -15,6 +15,7 @@ export interface DashboardSocketHandlers {
|
||||
onMessageAnalyzed?: (message: MessageRecord) => void;
|
||||
onAttachmentUploaded?: () => void;
|
||||
onMediaState?: (state: MediaState) => void;
|
||||
onVoiceRecordingUploaded?: (recording: any) => void;
|
||||
onPcm?: (data: ArrayBuffer) => void;
|
||||
}
|
||||
|
||||
@@ -75,6 +76,9 @@ export function useDashboardSocket(handlers: DashboardSocketHandlers) {
|
||||
case "media_state":
|
||||
handlersRef.current.onMediaState?.(message.state);
|
||||
break;
|
||||
case "voice_recording_uploaded":
|
||||
handlersRef.current.onVoiceRecordingUploaded?.(message.data);
|
||||
break;
|
||||
}
|
||||
} catch {
|
||||
// ignore malformed socket messages
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
export type DashboardTab = "voice" | "media" | "messages" | "review";
|
||||
export type DashboardTab = "voice" | "media" | "messages" | "review" | "recordings";
|
||||
|
||||
export interface UIState {
|
||||
selectedGuild?: string;
|
||||
|
||||
@@ -201,6 +201,41 @@ export const pgAIAnalysisRunsTable = pgTable(
|
||||
}),
|
||||
);
|
||||
|
||||
/**
|
||||
* Voice Recordings Table (PostgreSQL)
|
||||
* Stores voice recording segment metadata and upload status
|
||||
*/
|
||||
export const pgVoiceRecordingsTable = pgTable(
|
||||
"voice_recordings",
|
||||
{
|
||||
id: pgText("id").primaryKey(),
|
||||
user_id: pgText("user_id").notNull(),
|
||||
username: pgText("username").notNull(),
|
||||
avatar_url: pgText("avatar_url"),
|
||||
guild_id: pgText("guild_id"),
|
||||
channel_id: pgText("channel_id"),
|
||||
channel_name: pgText("channel_name"),
|
||||
filename: pgText("filename").notNull(),
|
||||
size_bytes: pgInteger("size_bytes").notNull(),
|
||||
download_url: pgText("download_url"),
|
||||
upload_status: pgText("upload_status", {
|
||||
enum: ["pending", "uploaded", "failed"],
|
||||
})
|
||||
.notNull()
|
||||
.default("pending"),
|
||||
upload_error: pgText("upload_error"),
|
||||
created_at: pgBigint("created_at", { mode: "number" }).notNull(),
|
||||
uploaded_at: pgBigint("uploaded_at", { mode: "number" }),
|
||||
},
|
||||
(table) => ({
|
||||
userIdIdx: pgIndex("idx_voice_recordings_user_id").on(table.user_id),
|
||||
channelIdIdx: pgIndex("idx_voice_recordings_channel_id").on(
|
||||
table.channel_id,
|
||||
),
|
||||
createdIdx: pgIndex("idx_voice_recordings_created_at").on(table.created_at),
|
||||
}),
|
||||
);
|
||||
|
||||
// SQLite Schema
|
||||
// =============
|
||||
|
||||
@@ -378,6 +413,43 @@ export const sqliteAIAnalysisRunsTable = sqliteTable(
|
||||
}),
|
||||
);
|
||||
|
||||
/**
|
||||
* Voice Recordings Table (SQLite)
|
||||
* Stores voice recording segment metadata and upload status
|
||||
*/
|
||||
export const sqliteVoiceRecordingsTable = sqliteTable(
|
||||
"voice_recordings",
|
||||
{
|
||||
id: sqliteText("id").primaryKey(),
|
||||
user_id: sqliteText("user_id").notNull(),
|
||||
username: sqliteText("username").notNull(),
|
||||
avatar_url: sqliteText("avatar_url"),
|
||||
guild_id: sqliteText("guild_id"),
|
||||
channel_id: sqliteText("channel_id"),
|
||||
channel_name: sqliteText("channel_name"),
|
||||
filename: sqliteText("filename").notNull(),
|
||||
size_bytes: sqliteInteger("size_bytes").notNull(),
|
||||
download_url: sqliteText("download_url"),
|
||||
upload_status: sqliteText("upload_status", {
|
||||
enum: ["pending", "uploaded", "failed"],
|
||||
})
|
||||
.notNull()
|
||||
.default("pending"),
|
||||
upload_error: sqliteText("upload_error"),
|
||||
created_at: sqliteInteger("created_at").notNull(),
|
||||
uploaded_at: sqliteInteger("uploaded_at"),
|
||||
},
|
||||
(table) => ({
|
||||
userIdIdx: sqliteIndex("idx_voice_recordings_user_id").on(table.user_id),
|
||||
channelIdIdx: sqliteIndex("idx_voice_recordings_channel_id").on(
|
||||
table.channel_id,
|
||||
),
|
||||
createdIdx: sqliteIndex("idx_voice_recordings_created_at").on(
|
||||
table.created_at,
|
||||
),
|
||||
}),
|
||||
);
|
||||
|
||||
// Runtime table selection based on config
|
||||
// ========================================
|
||||
|
||||
@@ -400,6 +472,11 @@ export const aiAnalysisRunsTable =
|
||||
? pgAIAnalysisRunsTable
|
||||
: sqliteAIAnalysisRunsTable;
|
||||
|
||||
export const voiceRecordingsTable =
|
||||
config.DATABASE_TYPE === "postgres"
|
||||
? pgVoiceRecordingsTable
|
||||
: sqliteVoiceRecordingsTable;
|
||||
|
||||
// Export table types for use in queries
|
||||
export type MuxerJob = typeof muxerJobsTable.$inferSelect;
|
||||
export type MuxerJobInsert = typeof muxerJobsTable.$inferInsert;
|
||||
@@ -415,3 +492,6 @@ export type UIStateInsert = typeof uiStateTable.$inferInsert;
|
||||
|
||||
export type AIAnalysisRun = typeof aiAnalysisRunsTable.$inferSelect;
|
||||
export type AIAnalysisRunInsert = typeof aiAnalysisRunsTable.$inferInsert;
|
||||
|
||||
export type VoiceRecording = typeof voiceRecordingsTable.$inferSelect;
|
||||
export type VoiceRecordingInsert = typeof voiceRecordingsTable.$inferInsert;
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
import { desc, eq } from "drizzle-orm";
|
||||
import { createChildLogger } from "../logger";
|
||||
import { getDatabase } from "./drizzle";
|
||||
import {
|
||||
type VoiceRecording,
|
||||
type VoiceRecordingInsert,
|
||||
voiceRecordingsTable,
|
||||
} from "./schema";
|
||||
|
||||
const logger = createChildLogger("voice-recording-repo");
|
||||
|
||||
interface QueryBuilder<T = unknown> extends PromiseLike<T> {
|
||||
from(...args: unknown[]): QueryBuilder<T>;
|
||||
where(...args: unknown[]): QueryBuilder<T>;
|
||||
orderBy(...args: unknown[]): QueryBuilder<T>;
|
||||
limit(...args: unknown[]): QueryBuilder<T>;
|
||||
offset(...args: unknown[]): QueryBuilder<T>;
|
||||
values(...args: unknown[]): QueryBuilder<T>;
|
||||
onConflictDoNothing(...args: unknown[]): QueryBuilder<T>;
|
||||
returning(...args: unknown[]): QueryBuilder<T>;
|
||||
set(...args: unknown[]): QueryBuilder<T>;
|
||||
}
|
||||
|
||||
interface RecordingDatabase {
|
||||
select<T = unknown[]>(...args: unknown[]): QueryBuilder<T>;
|
||||
insert<T = unknown>(...args: unknown[]): QueryBuilder<T>;
|
||||
update(...args: unknown[]): QueryBuilder<unknown>;
|
||||
}
|
||||
|
||||
function db(): RecordingDatabase {
|
||||
return getDatabase() as unknown as RecordingDatabase;
|
||||
}
|
||||
|
||||
export async function insertVoiceRecording(
|
||||
recording: VoiceRecordingInsert,
|
||||
): Promise<void> {
|
||||
try {
|
||||
await db()
|
||||
.insert(voiceRecordingsTable)
|
||||
.values(recording)
|
||||
.onConflictDoNothing();
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
{
|
||||
id: recording.id,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
},
|
||||
"Failed to insert voice recording",
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateVoiceRecordingAsUploaded(
|
||||
id: string,
|
||||
downloadUrl: string,
|
||||
uploadedAt: number,
|
||||
): Promise<void> {
|
||||
try {
|
||||
await db()
|
||||
.update(voiceRecordingsTable)
|
||||
.set({
|
||||
download_url: downloadUrl,
|
||||
upload_status: "uploaded",
|
||||
uploaded_at: uploadedAt,
|
||||
})
|
||||
.where(eq(voiceRecordingsTable.id, id));
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
{ id, error: error instanceof Error ? error.message : String(error) },
|
||||
"Failed to update voice recording status to uploaded",
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateVoiceRecordingAsFailed(
|
||||
id: string,
|
||||
error: string,
|
||||
): Promise<void> {
|
||||
try {
|
||||
await db()
|
||||
.update(voiceRecordingsTable)
|
||||
.set({
|
||||
upload_status: "failed",
|
||||
upload_error: error,
|
||||
})
|
||||
.where(eq(voiceRecordingsTable.id, id));
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
{ id, error: error instanceof Error ? error.message : String(error) },
|
||||
"Failed to update voice recording status to failed",
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function listVoiceRecordings(
|
||||
limit = 100,
|
||||
): Promise<VoiceRecording[]> {
|
||||
try {
|
||||
const rows = await db()
|
||||
.select()
|
||||
.from(voiceRecordingsTable)
|
||||
.orderBy(desc(voiceRecordingsTable.created_at))
|
||||
.limit(limit);
|
||||
return rows as VoiceRecording[];
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
{ error: error instanceof Error ? error.message : String(error) },
|
||||
"Failed to list voice recordings",
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { createChildLogger } from "../logger";
|
||||
import { AppError } from "../errors";
|
||||
import { createChildLogger } from "../logger";
|
||||
import { discordPlayer } from "../player";
|
||||
import { MediaQueue } from "./mediaQueue";
|
||||
import { resolveMediaSource } from "./mediaResolver";
|
||||
|
||||
@@ -128,7 +128,8 @@ export type ModerationWsEvent =
|
||||
| { type: "message_analyzed"; data: MessageRecord }
|
||||
| { type: "attachment_created"; data: AttachmentRecord }
|
||||
| { type: "analysis_queue_status"; data: AnalysisQueueStatus }
|
||||
| { type: "media_state"; state: MediaState };
|
||||
| { type: "media_state"; state: MediaState }
|
||||
| { type: "voice_recording_uploaded"; data: any };
|
||||
|
||||
export interface AnalysisQueueStatus {
|
||||
queuedConversations: number;
|
||||
|
||||
@@ -25,6 +25,7 @@ import {
|
||||
finalizeRecordingSession,
|
||||
type RecordingSession,
|
||||
} from "./recorder/sessionRecording";
|
||||
import { uploadRecordingSegment } from "./recorder/uploader";
|
||||
import { retryWithBackoff } from "./retry";
|
||||
import type { PcmBroadcaster } from "./types";
|
||||
|
||||
@@ -211,6 +212,24 @@ export async function startRecording(
|
||||
"Metadata saved",
|
||||
);
|
||||
}
|
||||
|
||||
// Trigger async voice segment upload
|
||||
const segmentId = `${userId}-${currentSegment.startTime}`;
|
||||
uploadRecordingSegment({
|
||||
id: segmentId,
|
||||
oggPath: currentSegment.filename,
|
||||
userId: userMetadata.userId,
|
||||
username: userMetadata.username,
|
||||
avatarUrl: userMetadata.avatarUrl,
|
||||
guildId: channel.guild.id,
|
||||
channelId: channel.id,
|
||||
channelName: channel.name,
|
||||
}).catch((err) => {
|
||||
logger.error(
|
||||
{ segmentId, error: err.message },
|
||||
"Upload segment trigger failed",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
currentSegment.out.on("error", (err) => {
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import {
|
||||
insertVoiceRecording,
|
||||
updateVoiceRecordingAsFailed,
|
||||
updateVoiceRecordingAsUploaded,
|
||||
} from "../database/voiceRecordingRepo";
|
||||
import { createChildLogger } from "../logger";
|
||||
import { retryWithBackoff } from "../retry";
|
||||
|
||||
const logger = createChildLogger("recording-uploader");
|
||||
|
||||
export interface UploadResponse {
|
||||
download_url: string;
|
||||
public_id: string;
|
||||
file_name: string;
|
||||
size_bytes: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Uploads a recorded segment OGG file to external server and registers in database
|
||||
*/
|
||||
export async function uploadRecordingSegment(input: {
|
||||
id: string;
|
||||
oggPath: string;
|
||||
userId: string;
|
||||
username: string;
|
||||
avatarUrl: string | null;
|
||||
guildId: string | null;
|
||||
channelId: string | null;
|
||||
channelName: string | null;
|
||||
}): Promise<void> {
|
||||
const {
|
||||
id,
|
||||
oggPath,
|
||||
userId,
|
||||
username,
|
||||
avatarUrl,
|
||||
guildId,
|
||||
channelId,
|
||||
channelName,
|
||||
} = input;
|
||||
const fileName = path.basename(oggPath);
|
||||
|
||||
try {
|
||||
// 1. Get file size and insert initial pending state to DB
|
||||
const stats = fs.statSync(oggPath);
|
||||
await insertVoiceRecording({
|
||||
id,
|
||||
user_id: userId,
|
||||
username,
|
||||
avatar_url: avatarUrl,
|
||||
guild_id: guildId,
|
||||
channel_id: channelId,
|
||||
channel_name: channelName,
|
||||
filename: fileName,
|
||||
size_bytes: stats.size,
|
||||
upload_status: "pending",
|
||||
created_at: Date.now(),
|
||||
});
|
||||
|
||||
// 2. Perform async upload with retry logic
|
||||
const downloadUrl = await retryWithBackoff(
|
||||
async () => {
|
||||
const fileBuffer = fs.readFileSync(oggPath);
|
||||
const fileBlob = new Blob([fileBuffer], { type: "audio/ogg" });
|
||||
const formData = new FormData();
|
||||
formData.append("file", fileBlob, fileName);
|
||||
formData.append("fileName", fileName);
|
||||
|
||||
const res = await fetch("https://upload.asepharyana.tech/api/upload", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
accept: "application/json",
|
||||
},
|
||||
body: formData,
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error(`Upload failed: Status ${res.status}`);
|
||||
}
|
||||
|
||||
const data = (await res.json()) as UploadResponse;
|
||||
if (!data.download_url) {
|
||||
throw new Error("Missing download_url in response");
|
||||
}
|
||||
|
||||
return data.download_url;
|
||||
},
|
||||
{
|
||||
retries: 3,
|
||||
minTimeout: 1000,
|
||||
maxTimeout: 5000,
|
||||
logger,
|
||||
},
|
||||
);
|
||||
|
||||
// 3. Update DB to uploaded state
|
||||
await updateVoiceRecordingAsUploaded(id, downloadUrl, Date.now());
|
||||
logger.info({ id, downloadUrl }, "Recording segment uploaded successfully");
|
||||
|
||||
// 4. Broadcast via WebSocket if broadcaster exists globally
|
||||
const broadcaster = (globalThis as any).moderationBroadcaster;
|
||||
if (broadcaster) {
|
||||
const payload = JSON.stringify({
|
||||
type: "voice_recording_uploaded",
|
||||
data: {
|
||||
id,
|
||||
user_id: userId,
|
||||
username,
|
||||
avatar_url: avatarUrl,
|
||||
guild_id: guildId,
|
||||
channel_id: channelId,
|
||||
channel_name: channelName,
|
||||
filename: fileName,
|
||||
size_bytes: stats.size,
|
||||
download_url: downloadUrl,
|
||||
upload_status: "uploaded",
|
||||
created_at: Date.now(),
|
||||
uploaded_at: Date.now(),
|
||||
},
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
|
||||
broadcaster.getClients().forEach((client: any) => {
|
||||
if (client.readyState === 1) {
|
||||
try {
|
||||
client.send(payload);
|
||||
} catch (err) {
|
||||
logger.warn(
|
||||
{ err },
|
||||
"Failed to send recording upload event to client",
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
const errorMsg = error instanceof Error ? error.message : String(error);
|
||||
logger.error({ id, error: errorMsg }, "Failed to upload voice recording");
|
||||
await updateVoiceRecordingAsFailed(id, errorMsg).catch((err) => {
|
||||
logger.error({ id, err }, "Failed to write failure state to DB");
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,6 @@
|
||||
import type { Router } from "express";
|
||||
import express from "express";
|
||||
import { AppError } from "../errors";
|
||||
import type { MessageRecord } from "../moderation/types";
|
||||
import {
|
||||
getAnalysisQueueStatus,
|
||||
queueMessageAnalysis,
|
||||
@@ -11,6 +10,7 @@ import {
|
||||
searchMessages,
|
||||
updateMessageAIAnalysis,
|
||||
} from "../moderation/messageStore";
|
||||
import type { MessageRecord } from "../moderation/types";
|
||||
|
||||
export function createAnalysisRoutes(): Router {
|
||||
const router = express.Router();
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
import {
|
||||
Router,
|
||||
type Request,
|
||||
type Response,
|
||||
type NextFunction,
|
||||
} from "express";
|
||||
import { listVoiceRecordings } from "../database/voiceRecordingRepo";
|
||||
import { AppError } from "../errors";
|
||||
import { createChildLogger } from "../logger";
|
||||
|
||||
const logger = createChildLogger("recordings-routes");
|
||||
|
||||
export function createRecordingsRoutes(): Router {
|
||||
const router = Router();
|
||||
|
||||
router.get(
|
||||
"/recordings",
|
||||
async (_req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const recordings = await listVoiceRecordings(100);
|
||||
res.json(recordings);
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
{ error: error instanceof Error ? error.message : String(error) },
|
||||
"Failed to list recordings",
|
||||
);
|
||||
next(
|
||||
new AppError(
|
||||
"DATABASE_ERROR",
|
||||
"Failed to retrieve voice recordings",
|
||||
500,
|
||||
),
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
return router;
|
||||
}
|
||||
@@ -9,7 +9,7 @@ export interface SharedUIState {
|
||||
selectedVoiceChannel: string;
|
||||
selectedTextGuild: string;
|
||||
selectedTextChannel: string;
|
||||
activeTab: "voice" | "messages" | "media" | "review";
|
||||
activeTab: "voice" | "messages" | "media" | "review" | "recordings";
|
||||
isListening: boolean;
|
||||
isStreaming: boolean;
|
||||
}
|
||||
|
||||
+10
-5
@@ -28,6 +28,7 @@ import { createMessageRoutes } from "./routes/messageRoutes";
|
||||
import { createSyncRoutes } from "./routes/syncRoutes";
|
||||
import { createUIStateRoutes } from "./routes/uiStateRoutes";
|
||||
import { createVoiceRoutes } from "./routes/voiceRoutes";
|
||||
import { createRecordingsRoutes } from "./routes/recordingsRoutes";
|
||||
import { Streamer } from "./streaming";
|
||||
import type { VoiceController } from "./voiceController";
|
||||
|
||||
@@ -56,7 +57,7 @@ interface SharedUIState {
|
||||
selectedVoiceChannel: string;
|
||||
selectedTextGuild: string;
|
||||
selectedTextChannel: string;
|
||||
activeTab: "voice" | "messages" | "media" | "review";
|
||||
activeTab: "voice" | "messages" | "media" | "review" | "recordings";
|
||||
isListening: boolean;
|
||||
isStreaming: boolean;
|
||||
}
|
||||
@@ -94,11 +95,11 @@ export function normalizeSharedUIState(
|
||||
selectedVoiceChannel: value.selectedVoiceChannel ?? "",
|
||||
selectedTextGuild: value.selectedTextGuild ?? guild,
|
||||
selectedTextChannel: value.selectedTextChannel ?? "",
|
||||
activeTab: (["voice", "messages", "media", "review"].includes(
|
||||
activeTab: (["voice", "messages", "media", "review", "recordings"].includes(
|
||||
value.activeTab ?? "",
|
||||
)
|
||||
? value.activeTab
|
||||
: "voice") as "voice" | "messages" | "media" | "review",
|
||||
: "voice") as "voice" | "messages" | "media" | "review" | "recordings",
|
||||
isListening: value.isListening ?? false,
|
||||
isStreaming: value.isStreaming ?? false,
|
||||
};
|
||||
@@ -143,13 +144,16 @@ function patchSharedUIState(patch: SharedUIStatePatch) {
|
||||
sharedUIState.selectedTextChannel = patch.selectedTextChannel;
|
||||
}
|
||||
if (
|
||||
["voice", "messages", "media", "review"].includes(patch.activeTab ?? "")
|
||||
["voice", "messages", "media", "review", "recordings"].includes(
|
||||
patch.activeTab ?? "",
|
||||
)
|
||||
) {
|
||||
sharedUIState.activeTab = patch.activeTab as
|
||||
| "voice"
|
||||
| "messages"
|
||||
| "media"
|
||||
| "review";
|
||||
| "review"
|
||||
| "recordings";
|
||||
}
|
||||
if (typeof patch.isListening === "boolean") {
|
||||
sharedUIState.isListening = patch.isListening;
|
||||
@@ -325,6 +329,7 @@ export async function startWebserver(
|
||||
app.use("/api", createMessageRoutes());
|
||||
app.use("/api", createAnalysisRoutes());
|
||||
app.use("/api", createSyncRoutes(_client));
|
||||
app.use("/api", createRecordingsRoutes());
|
||||
app.use(
|
||||
"/api",
|
||||
createMediaRoutes(mediaController, {
|
||||
|
||||
Reference in New Issue
Block a user