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:
MythEclipse
2026-05-18 20:15:38 +07:00
parent 9d5559a45b
commit 3b9e87ee6c
17 changed files with 1404 additions and 13 deletions
@@ -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");
+943
View File
@@ -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": {}
}
}
+7
View File
@@ -15,6 +15,13 @@
"when": 1778764447718, "when": 1778764447718,
"tag": "0001_curious_zodiak", "tag": "0001_curious_zodiak",
"breakpoints": true "breakpoints": true
},
{
"idx": 2,
"version": "7",
"when": 1779109619461,
"tag": "0002_dark_omega_flight",
"breakpoints": true
} }
] ]
} }
+14 -2
View File
@@ -5,6 +5,7 @@ import { MessagesPanel } from "./components/messages/MessagesPanel";
import { ReviewPanel } from "./components/review/ReviewPanel"; import { ReviewPanel } from "./components/review/ReviewPanel";
import { Tabs, TabsContent } from "./components/ui/tabs"; import { Tabs, TabsContent } from "./components/ui/tabs";
import { VoicePanel } from "./components/voice/VoicePanel"; import { VoicePanel } from "./components/voice/VoicePanel";
import { RecordingsPanel } from "./components/recordings/RecordingsPanel";
import { AuthOverlay } from "./components/layout/AuthOverlay"; import { AuthOverlay } from "./components/layout/AuthOverlay";
import { useDashboardSocket } from "./hooks/useDashboardSocket"; import { useDashboardSocket } from "./hooks/useDashboardSocket";
import { mergeMessages, useMessages } from "./hooks/useMessages"; import { mergeMessages, useMessages } from "./hooks/useMessages";
@@ -75,6 +76,10 @@ export default function App() {
onMessageAnalyzed: (message) => messages.setMessages((prev) => mergeMessages(prev, [message])), onMessageAnalyzed: (message) => messages.setMessages((prev) => mergeMessages(prev, [message])),
onAttachmentUploaded: () => messages.fetchMessages(selectedTextChannel).catch(() => undefined), onAttachmentUploaded: () => messages.fetchMessages(selectedTextChannel).catch(() => undefined),
onMediaState: media.setMediaState, onMediaState: media.setMediaState,
onVoiceRecordingUploaded: (recording) => {
const event = new CustomEvent("voice_recording_uploaded", { detail: recording });
window.dispatchEvent(event);
},
onPcm: handleIncomingPcm, onPcm: handleIncomingPcm,
}); });
@@ -178,7 +183,7 @@ export default function App() {
await patchUIState({ isListening: true }); await patchUIState({ isListening: true });
}, [isListening, patchUIState]); }, [isListening, patchUIState]);
const tabs = useMemo(() => ["voice", "media", "messages", "review"] as DashboardTab[], []); const tabs = useMemo(() => ["voice", "media", "messages", "recordings", "review"] as DashboardTab[], []);
return ( return (
<DashboardLayout <DashboardLayout
@@ -189,7 +194,7 @@ export default function App() {
> >
<div className="md:hidden"> <div className="md:hidden">
<Tabs value={activeTab} onValueChange={(value) => patchUIState({ activeTab: value as DashboardTab })}> <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) => ( {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 })}> <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} {tab}
@@ -250,6 +255,13 @@ export default function App() {
onReanalyze={messages.reanalyze} onReanalyze={messages.reanalyze}
/> />
</TabsContent> </TabsContent>
<TabsContent value="recordings">
{!isAuthenticated ? (
<AuthOverlay onAuthenticated={() => setIsAuthenticated(true)} />
) : (
<RecordingsPanel />
)}
</TabsContent>
<TabsContent value="review"> <TabsContent value="review">
<ReviewPanel messages={messages.messages} onReanalyze={messages.reanalyze} /> <ReviewPanel messages={messages.messages} onReanalyze={messages.reanalyze} />
</TabsContent> </TabsContent>
+2 -1
View File
@@ -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 type { DashboardTab } from "../../types/ui";
import { cn } from "../../lib/utils"; import { cn } from "../../lib/utils";
import { Button } from "../ui/button"; 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: "voice", label: "Voice", icon: Volume2 },
{ id: "media", label: "Media", icon: Music2 }, { id: "media", label: "Media", icon: Music2 },
{ id: "messages", label: "Messages", icon: MessageSquare }, { id: "messages", label: "Messages", icon: MessageSquare },
{ id: "recordings", label: "Recordings", icon: Mic },
{ id: "review", label: "Review", icon: ShieldAlert }, { id: "review", label: "Review", icon: ShieldAlert },
]; ];
+4
View File
@@ -15,6 +15,7 @@ export interface DashboardSocketHandlers {
onMessageAnalyzed?: (message: MessageRecord) => void; onMessageAnalyzed?: (message: MessageRecord) => void;
onAttachmentUploaded?: () => void; onAttachmentUploaded?: () => void;
onMediaState?: (state: MediaState) => void; onMediaState?: (state: MediaState) => void;
onVoiceRecordingUploaded?: (recording: any) => void;
onPcm?: (data: ArrayBuffer) => void; onPcm?: (data: ArrayBuffer) => void;
} }
@@ -75,6 +76,9 @@ export function useDashboardSocket(handlers: DashboardSocketHandlers) {
case "media_state": case "media_state":
handlersRef.current.onMediaState?.(message.state); handlersRef.current.onMediaState?.(message.state);
break; break;
case "voice_recording_uploaded":
handlersRef.current.onVoiceRecordingUploaded?.(message.data);
break;
} }
} catch { } catch {
// ignore malformed socket messages // ignore malformed socket messages
+1 -1
View File
@@ -1,4 +1,4 @@
export type DashboardTab = "voice" | "media" | "messages" | "review"; export type DashboardTab = "voice" | "media" | "messages" | "review" | "recordings";
export interface UIState { export interface UIState {
selectedGuild?: string; selectedGuild?: string;
+80
View File
@@ -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 // 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 // Runtime table selection based on config
// ======================================== // ========================================
@@ -400,6 +472,11 @@ export const aiAnalysisRunsTable =
? pgAIAnalysisRunsTable ? pgAIAnalysisRunsTable
: sqliteAIAnalysisRunsTable; : sqliteAIAnalysisRunsTable;
export const voiceRecordingsTable =
config.DATABASE_TYPE === "postgres"
? pgVoiceRecordingsTable
: sqliteVoiceRecordingsTable;
// Export table types for use in queries // Export table types for use in queries
export type MuxerJob = typeof muxerJobsTable.$inferSelect; export type MuxerJob = typeof muxerJobsTable.$inferSelect;
export type MuxerJobInsert = typeof muxerJobsTable.$inferInsert; export type MuxerJobInsert = typeof muxerJobsTable.$inferInsert;
@@ -415,3 +492,6 @@ export type UIStateInsert = typeof uiStateTable.$inferInsert;
export type AIAnalysisRun = typeof aiAnalysisRunsTable.$inferSelect; export type AIAnalysisRun = typeof aiAnalysisRunsTable.$inferSelect;
export type AIAnalysisRunInsert = typeof aiAnalysisRunsTable.$inferInsert; export type AIAnalysisRunInsert = typeof aiAnalysisRunsTable.$inferInsert;
export type VoiceRecording = typeof voiceRecordingsTable.$inferSelect;
export type VoiceRecordingInsert = typeof voiceRecordingsTable.$inferInsert;
+115
View File
@@ -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 -1
View File
@@ -1,5 +1,5 @@
import { createChildLogger } from "../logger";
import { AppError } from "../errors"; import { AppError } from "../errors";
import { createChildLogger } from "../logger";
import { discordPlayer } from "../player"; import { discordPlayer } from "../player";
import { MediaQueue } from "./mediaQueue"; import { MediaQueue } from "./mediaQueue";
import { resolveMediaSource } from "./mediaResolver"; import { resolveMediaSource } from "./mediaResolver";
+2 -1
View File
@@ -128,7 +128,8 @@ export type ModerationWsEvent =
| { type: "message_analyzed"; data: MessageRecord } | { type: "message_analyzed"; data: MessageRecord }
| { type: "attachment_created"; data: AttachmentRecord } | { type: "attachment_created"; data: AttachmentRecord }
| { type: "analysis_queue_status"; data: AnalysisQueueStatus } | { 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 { export interface AnalysisQueueStatus {
queuedConversations: number; queuedConversations: number;
+19
View File
@@ -25,6 +25,7 @@ import {
finalizeRecordingSession, finalizeRecordingSession,
type RecordingSession, type RecordingSession,
} from "./recorder/sessionRecording"; } from "./recorder/sessionRecording";
import { uploadRecordingSegment } from "./recorder/uploader";
import { retryWithBackoff } from "./retry"; import { retryWithBackoff } from "./retry";
import type { PcmBroadcaster } from "./types"; import type { PcmBroadcaster } from "./types";
@@ -211,6 +212,24 @@ export async function startRecording(
"Metadata saved", "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) => { currentSegment.out.on("error", (err) => {
+145
View File
@@ -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 -1
View File
@@ -1,7 +1,6 @@
import type { Router } from "express"; import type { Router } from "express";
import express from "express"; import express from "express";
import { AppError } from "../errors"; import { AppError } from "../errors";
import type { MessageRecord } from "../moderation/types";
import { import {
getAnalysisQueueStatus, getAnalysisQueueStatus,
queueMessageAnalysis, queueMessageAnalysis,
@@ -11,6 +10,7 @@ import {
searchMessages, searchMessages,
updateMessageAIAnalysis, updateMessageAIAnalysis,
} from "../moderation/messageStore"; } from "../moderation/messageStore";
import type { MessageRecord } from "../moderation/types";
export function createAnalysisRoutes(): Router { export function createAnalysisRoutes(): Router {
const router = express.Router(); const router = express.Router();
+39
View File
@@ -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;
}
+1 -1
View File
@@ -9,7 +9,7 @@ export interface SharedUIState {
selectedVoiceChannel: string; selectedVoiceChannel: string;
selectedTextGuild: string; selectedTextGuild: string;
selectedTextChannel: string; selectedTextChannel: string;
activeTab: "voice" | "messages" | "media" | "review"; activeTab: "voice" | "messages" | "media" | "review" | "recordings";
isListening: boolean; isListening: boolean;
isStreaming: boolean; isStreaming: boolean;
} }
+10 -5
View File
@@ -28,6 +28,7 @@ import { createMessageRoutes } from "./routes/messageRoutes";
import { createSyncRoutes } from "./routes/syncRoutes"; import { createSyncRoutes } from "./routes/syncRoutes";
import { createUIStateRoutes } from "./routes/uiStateRoutes"; import { createUIStateRoutes } from "./routes/uiStateRoutes";
import { createVoiceRoutes } from "./routes/voiceRoutes"; import { createVoiceRoutes } from "./routes/voiceRoutes";
import { createRecordingsRoutes } from "./routes/recordingsRoutes";
import { Streamer } from "./streaming"; import { Streamer } from "./streaming";
import type { VoiceController } from "./voiceController"; import type { VoiceController } from "./voiceController";
@@ -56,7 +57,7 @@ interface SharedUIState {
selectedVoiceChannel: string; selectedVoiceChannel: string;
selectedTextGuild: string; selectedTextGuild: string;
selectedTextChannel: string; selectedTextChannel: string;
activeTab: "voice" | "messages" | "media" | "review"; activeTab: "voice" | "messages" | "media" | "review" | "recordings";
isListening: boolean; isListening: boolean;
isStreaming: boolean; isStreaming: boolean;
} }
@@ -94,11 +95,11 @@ export function normalizeSharedUIState(
selectedVoiceChannel: value.selectedVoiceChannel ?? "", selectedVoiceChannel: value.selectedVoiceChannel ?? "",
selectedTextGuild: value.selectedTextGuild ?? guild, selectedTextGuild: value.selectedTextGuild ?? guild,
selectedTextChannel: value.selectedTextChannel ?? "", selectedTextChannel: value.selectedTextChannel ?? "",
activeTab: (["voice", "messages", "media", "review"].includes( activeTab: (["voice", "messages", "media", "review", "recordings"].includes(
value.activeTab ?? "", value.activeTab ?? "",
) )
? value.activeTab ? value.activeTab
: "voice") as "voice" | "messages" | "media" | "review", : "voice") as "voice" | "messages" | "media" | "review" | "recordings",
isListening: value.isListening ?? false, isListening: value.isListening ?? false,
isStreaming: value.isStreaming ?? false, isStreaming: value.isStreaming ?? false,
}; };
@@ -143,13 +144,16 @@ function patchSharedUIState(patch: SharedUIStatePatch) {
sharedUIState.selectedTextChannel = patch.selectedTextChannel; sharedUIState.selectedTextChannel = patch.selectedTextChannel;
} }
if ( if (
["voice", "messages", "media", "review"].includes(patch.activeTab ?? "") ["voice", "messages", "media", "review", "recordings"].includes(
patch.activeTab ?? "",
)
) { ) {
sharedUIState.activeTab = patch.activeTab as sharedUIState.activeTab = patch.activeTab as
| "voice" | "voice"
| "messages" | "messages"
| "media" | "media"
| "review"; | "review"
| "recordings";
} }
if (typeof patch.isListening === "boolean") { if (typeof patch.isListening === "boolean") {
sharedUIState.isListening = patch.isListening; sharedUIState.isListening = patch.isListening;
@@ -325,6 +329,7 @@ export async function startWebserver(
app.use("/api", createMessageRoutes()); app.use("/api", createMessageRoutes());
app.use("/api", createAnalysisRoutes()); app.use("/api", createAnalysisRoutes());
app.use("/api", createSyncRoutes(_client)); app.use("/api", createSyncRoutes(_client));
app.use("/api", createRecordingsRoutes());
app.use( app.use(
"/api", "/api",
createMediaRoutes(mediaController, { createMediaRoutes(mediaController, {