Compare commits

..
6 Commits
Author SHA1 Message Date
asepharyana d1e6f3b47a chore: update ports to 4000-range (4000/4001) 2026-08-02 14:30:54 +07:00
asepharyana dbcf9d68f2 fix(media): publish status when a track ends naturally
The Redis media:status key was only rewritten after a command received via
Redis. When the last track ended naturally (AudioPlayer Idle -> advanceQueue
with an empty queue), currentTrackItem was cleared but the status key was not
persisted — so the backend's cached status and the frontend's 10s polling
stayed stuck showing the finished track as 'playing' forever.

Wire a media-status sink (commandHandler provides the real redisPub to
MediaHandler) and re-publish status after auto-advance, so natural track end
updates the UI.
2026-08-02 10:43:34 +07:00
asepharyana ef4281cd1f fix(voice): activity tab rendered a permanently empty chart
VoiceActivityTimeline was never given a data prop — the Activity tab always
showed an empty Recharts bar chart while the connection tab already had live
speaker state. Replace the dead chart with a live speaker/activity list fed
from the same WebSocket data, so the tab reflects real state instead of
misleading empty bars.
2026-08-02 10:38:02 +07:00
asepharyana 25f6609a9f fix(recordings): stop faking duration from file size + render edited content in search
The voice_recordings table has no duration column, but the backend aliased
duration_bytes = size_bytes (file size in bytes) and RecordingCard divided it
by 60 as if it were seconds — a 3MB MP3 rendered as a nonsensical '55924:3'
fake timestamp. Drop the fabricated field and show real file size instead.

Also render edited_content fallback in the analysis search results for
consistency with message cards/detail.
2026-08-02 10:32:58 +07:00
asepharyana 3f199aa70d fix(messages): merge partial WS updates + display edited content
message_updated broadcasts only {id, edited_content, edited_at} (+ reset
ai_* fields), but the frontend replaced the whole cached record, wiping
username/content/channel_id/created_at -> blank cards and the
'the channel_id of undefined' crash on /messages. Merge partials over the
existing record (list + detail), make list-patching channel-filter aware,
show edited content/badge, and fix the message_updated WS type.

Also broadcast type:'edited' + ai reset in message_updated so the live UI
matches the DB update.
2026-08-02 10:27:41 +07:00
asepharyana a82265f4a9 chore: remove outdated README.md file 2026-08-02 10:18:56 +07:00
19 changed files with 245 additions and 217 deletions
+1 -1
View File
@@ -39,7 +39,7 @@ AUDIO_CHANNELS=2 # Number of audio channels (default: 2)
AVATAR_SIZE=64 # User avatar size in pixels (default: 64) AVATAR_SIZE=64 # User avatar size in pixels (default: 64)
# === Webserver === # === Webserver ===
WEBSERVER_PORT=3001 # Backend HTTP/WS server port (default: 3001) WEBSERVER_PORT=4001 # Backend HTTP/WS server port (default: 4001)
# === Connection === # === Connection ===
VOICE_CONNECTION_TIMEOUT_MS=15000 # Voice connection timeout in ms (default: 15000) VOICE_CONNECTION_TIMEOUT_MS=15000 # Voice connection timeout in ms (default: 15000)
-118
View File
@@ -1,118 +0,0 @@
# Bete — Discord Moderation Dashboard
Bot monitoring Discord yang merekam voice channel, menangkap pesan teks, menyimpan attachment, menjalankan analisis AI opsional, dan menyediakan dashboard web real-time.
**Stack utama:** Node.js (Express 5), pnpm, TypeScript, React 19 (Next.js 16), Tailwind v4, shadcn/ui, Drizzle ORM, PostgreSQL, WebSocket, Redis pub/sub.
## Prasyarat
- Node.js 22+
- pnpm 11.x
- FFmpeg di `PATH` (untuk audio muxing dan playback media)
- `yt-dlp` di `PATH` (untuk resolve audio YouTube/Spotify)
- Bun (untuk frontend dev — opsional, bisa pake pnpm)
- PostgreSQL 15+
## Setup
```bash
pnpm install
cp .env.example .env
# Edit .env sesuai konfigurasi server
```
## Menjalankan
```bash
# Backend (port 3001)
pnpm run dev:backend
# Discord Gateway (capture messages, voice, dll)
pnpm run dev:discord-gateway
# Frontend (port 3000)
pnpm run dev:web
```
## Build
```bash
pnpm run build:backend
pnpm run build:discord-gateway
pnpm run build:web # next build — static export ke out/
pnpm run build # build semua service
```
## Deploy
```bash
./deploy.sh # Build + deploy semua service ke VPS
./deploy.sh --frontend # Frontend only
./deploy.sh --backend # Backend only
./deploy.sh --no-build # Skip build, copy files aja
```
## Service Architecture
```
Discord
|
v
discord-gateway ←→ Redis ←→ backend (Express 5) ←→ frontend (Next.js)
| pub/sub | |
| +— REST API (/api/*) |
| +— WebSocket (/ws) |
+— message capture +— AI moderation |
+— voice recording +— dashboard data +— dashboard UI
+— attachment upload +— real-time updates
```
## Fitur
- **Message capture**: Capture pesan baru, edit, dan delete dari Discord
- **Voice recording**: Rekam voice channel ke segmen OGG per user, streaming PCM real-time ke WebSocket
- **Attachment upload**: Download + upload attachment ke external storage
- **AI moderation**: Analisis pesan opsional via LLM, auto-delete, queue management
- **Dashboard**: Messages feed, AI analysis review, voice connection, music player, recordings, user/channel stats
- **Media playback**: Playback dari URL, file lokal, YouTube, Spotify
- **WebSocket**: Real-time event streaming untuk semua aktivitas
- **Public API**: Semua endpoint REST dan WebSocket dapat diakses tanpa autentikasi
## Struktur Proyek
```
services/
├── backend/ # Express 5 REST API + WebSocket server
│ ├── src/modules/ # Feature modules (messages, voice, media, dll)
│ └── src/http/ # Express app setup, middleware
├── discord-gateway/ # Discord client, voice recording, AI analysis
│ ├── src/modules/ # message-capture, voice-recording, ai-moderation
│ └── src/shared/ # Config, database, Discord client
└── frontend/ # Next.js 16 dashboard (static export)
├── src/app/ # Pages (login, dashboard tabs)
├── src/features/ # Feature components (dashboard, live, messages)
└── src/lib/ # API client, WebSocket, types
packages/
└── shared/ # Shared types, errors, logger, utilities
```
## Database
PostgreSQL via Drizzle ORM. Migrasi:
```bash
pnpm run db:generate # Generate migration
pnpm run db:migrate # Apply migration
pnpm run db:studio # Drizzle Studio
```
## WebSocket Events
Backend broadcast event berikut ke frontend via WebSocket:
- `message_created`, `message_updated`, `message_deleted`, `message_analyzed`
- `attachment_created`, `attachment_uploaded`
- `voice_recording_started`, `voice_recording_stopped`, `voice_recording_uploaded`
- `voice_active_user`, `voice_pcm_data`
- `media_state`
- `reaction_*`, `thread_*`, `presence_updated`, `guild_member_*`
+2 -2
View File
@@ -33,9 +33,9 @@ services:
- .env - .env
environment: environment:
NODE_ENV: production NODE_ENV: production
WEBSERVER_PORT: 3000 WEBSERVER_PORT: 4000
healthcheck: healthcheck:
test: ["CMD", "wget", "-qO-", "http://localhost:3000/api/health"] test: ["CMD", "wget", "-qO-", "http://localhost:4000/api/health"]
interval: 30s interval: 30s
timeout: 10s timeout: 10s
start_period: 15s start_period: 15s
+2 -2
View File
@@ -1,10 +1,10 @@
/** /**
* E2E API tests — runs against a running backend instance. * E2E API tests — runs against a running backend instance.
* Usage: API_BASE=http://localhost:3001 vitest run * Usage: API_BASE=http://localhost:4001 vitest run
*/ */
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
const BASE = process.env.API_BASE ?? "http://localhost:3001/api"; const BASE = process.env.API_BASE ?? "http://localhost:4001/api";
async function api(path: string, init?: RequestInit) { async function api(path: string, init?: RequestInit) {
const res = await fetch(`${BASE}${path}`, { const res = await fetch(`${BASE}${path}`, {
@@ -20,7 +20,6 @@ export interface RecordingRow {
upload_error: string | null; upload_error: string | null;
created_at: number; created_at: number;
uploaded_at: number | null; uploaded_at: number | null;
duration_bytes: number;
} }
export interface PaginatedRecordings { export interface PaginatedRecordings {
@@ -69,7 +68,6 @@ export class RecordingsService {
upload_error: pgVoiceRecordingsTable.upload_error, upload_error: pgVoiceRecordingsTable.upload_error,
created_at: pgVoiceRecordingsTable.created_at, created_at: pgVoiceRecordingsTable.created_at,
uploaded_at: pgVoiceRecordingsTable.uploaded_at, uploaded_at: pgVoiceRecordingsTable.uploaded_at,
duration_bytes: pgVoiceRecordingsTable.size_bytes,
}) })
.from(pgVoiceRecordingsTable) .from(pgVoiceRecordingsTable)
.where(where) .where(where)
@@ -16,6 +16,7 @@ import {
createHandlerRegistry, createHandlerRegistry,
} from "./handler-registry.js"; } from "./handler-registry.js";
import { MediaHandler } from "./media.handler.js"; import { MediaHandler } from "./media.handler.js";
import { wireMediaStatusWriter } from "./mediaStatusSink.js";
import { ModerationHandler } from "./moderation.handler.js"; import { ModerationHandler } from "./moderation.handler.js";
import { VoiceHandler } from "./voice.handler.js"; import { VoiceHandler } from "./voice.handler.js";
@@ -88,6 +89,10 @@ export class CommandHandler {
this.guildHandler = new GuildHandler(client); this.guildHandler = new GuildHandler(client);
this.moderationHandler = new ModerationHandler(client); this.moderationHandler = new ModerationHandler(client);
// Wire the media status sink so MediaHandler can persist status on
// queue advances that happen outside a command (natural track end).
wireMediaStatusWriter(this.redisPub);
// Build the command registry // Build the command registry
this.registry = createHandlerRegistry( this.registry = createHandlerRegistry(
this.voiceHandler, this.voiceHandler,
@@ -16,6 +16,7 @@ import {
ScreenShareController, ScreenShareController,
type ScreenShareVoiceStatus, type ScreenShareVoiceStatus,
} from "../voice-recording/screenShareController.js"; } from "../voice-recording/screenShareController.js";
import { setMediaStatusKey } from "./mediaStatusSink.js";
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Types // Types
@@ -88,14 +89,36 @@ export class MediaHandler {
activeChannelId: null, activeChannelId: null,
}), }),
) { ) {
// Register auto-advance on natural track end // Register auto-advance on natural track end. advanceQueue mutates the
// module-level currentTrackItem/queue, so we must re-publish the status
// key afterward: otherwise the backend's Redis `media:status` cache (and
// the frontend's 10s polling) stays stuck on the finished track.
discordPlayer.onIdle(() => { discordPlayer.onIdle(() => {
this.advanceQueue().catch((err) => { this.advanceQueue()
this.logger.error({ err }, "Auto-advance failed"); .then(() => this.publishStatus())
}); .catch((err) => {
this.logger.error({ err }, "Auto-advance failed");
});
}); });
} }
/**
* Persist the latest media state to Redis so the backend/frontend see queue
* advances that happen outside a command (natural track end, screen-share
* done). CommandHandler owns the Redis status-key writes for command-triggered
* changes; this covers the side-effect-only path.
*/
private publishStatus(): void {
try {
setMediaStatusKey(this.getCurrentMediaStatus());
} catch (err: unknown) {
this.logger.warn(
{ error: err instanceof Error ? err.message : String(err) },
"Failed to publish media status on track end",
);
}
}
getCurrentMediaStatus(): MediaStatusPayload { getCurrentMediaStatus(): MediaStatusPayload {
return buildStatusPayload(); return buildStatusPayload();
} }
@@ -0,0 +1,41 @@
import type Redis from "ioredis";
import { createChildLogger } from "@/shared/logger/index";
import { MEDIA_STATUS_KEY } from "../../shared/redis-channels.js";
/**
* Shared sink for writing the media status Redis key.
*
* CommandHandler owns the publisher + status writes for command-triggered
* changes (`publishMediaStatus`). MediaHandler needs to also persist status
* when the queue advances *outside* a command (natural track end / screen-share
* done), so we expose the real publisher here and let CommandHandler wire it
* once at startup.
*/
const logger = createChildLogger("media-status-sink");
let _setMediaStatusKey: ((payload: unknown) => void) | null = null;
export function setMediaStatusWriter(writer: (payload: unknown) => void): void {
_setMediaStatusKey = writer;
}
export function setMediaStatusKey(payload: unknown): void {
if (!_setMediaStatusKey) {
logger.warn("Media status writer not wired — skipping status publish");
return;
}
_setMediaStatusKey(payload);
}
export { MEDIA_STATUS_KEY };
export function wireMediaStatusWriter(redisPub: Redis): void {
setMediaStatusWriter((payload) => {
redisPub
.set(MEDIA_STATUS_KEY, JSON.stringify(payload))
.catch((err: unknown) => {
const msg = err instanceof Error ? err.message : String(err);
logger.warn({ error: msg }, "Failed to update media status key");
});
});
}
@@ -343,6 +343,20 @@ export function registerMessageCapture(client: Client): void {
id: newMessage.id, id: newMessage.id,
edited_content: getDisplayContent(newMessage as Message), edited_content: getDisplayContent(newMessage as Message),
edited_at: editedAt, edited_at: editedAt,
type: "edited",
// Match the DB update (updateMessageAsEdited resets analysis to
// pending) so the live UI reflects the same state instead of
// lingering on the stale pre-edit verdict.
ai_status: "pending",
ai_moderation_flags: null,
ai_moderation_score: null,
ai_analysis: null,
ai_categories: null,
ai_severity: null,
ai_confidence: null,
ai_recommended_action: null,
ai_analyzed_at: null,
ai_error: null,
}); });
} }
} else if (newMessage.author) { } else if (newMessage.author) {
@@ -148,7 +148,7 @@ export default function VoicePage() {
</div> </div>
)} )}
{tab === "activity" && <VoiceActivityTimeline />} {tab === "activity" && <VoiceActivityTimeline data={speakers} />}
</div> </div>
); );
} }
@@ -96,7 +96,10 @@ export function SearchPanel() {
)} )}
</div> </div>
<p className="text-sm leading-relaxed"> <p className="text-sm leading-relaxed">
{renderMessageContent(msg.content, msg.metadata)} {renderMessageContent(
msg.edited_content ?? msg.content,
msg.metadata,
)}
</p> </p>
{msg.ai_moderation_flags && {msg.ai_moderation_flags &&
msg.ai_moderation_flags !== "[]" && ( msg.ai_moderation_flags !== "[]" && (
@@ -86,7 +86,7 @@ export function MessageCard({
deleted deleted
</Badge> </Badge>
)} )}
{msg.type === "edited" && ( {(msg.type === "edited" || msg.edited_content) && (
<Badge <Badge
variant="outline" variant="outline"
className="text-[10px] px-1.5 py-0 h-4" className="text-[10px] px-1.5 py-0 h-4"
@@ -102,7 +102,10 @@ export function MessageCard({
"italic text-muted-foreground line-through", "italic text-muted-foreground line-through",
)} )}
> >
{renderMessageContent(msg.content, msg.metadata)} {renderMessageContent(
msg.edited_content ?? msg.content,
msg.metadata,
)}
</p> </p>
{(() => { {(() => {
const u = extractFirstImage(msg.metadata); const u = extractFirstImage(msg.metadata);
@@ -44,8 +44,10 @@ export function MessageDetailView({
{/* Content */} {/* Content */}
<div className="text-sm text-text-primary/90 leading-relaxed mb-4 whitespace-pre-wrap"> <div className="text-sm text-text-primary/90 leading-relaxed mb-4 whitespace-pre-wrap">
{renderMessageContent(message.content, message.metadata) || {renderMessageContent(
"(no text content)"} message.edited_content ?? message.content,
message.metadata,
) || "(no text content)"}
</div> </div>
{/* Attachments */} {/* Attachments */}
@@ -44,8 +44,10 @@ export function MessageDetail({
{/* Content */} {/* Content */}
<div className="text-sm text-text-primary/90 leading-relaxed mb-4 whitespace-pre-wrap"> <div className="text-sm text-text-primary/90 leading-relaxed mb-4 whitespace-pre-wrap">
{renderMessageContent(message.content, message.metadata) || {renderMessageContent(
"(no text content)"} message.edited_content ?? message.content,
message.metadata,
) || "(no text content)"}
</div> </div>
{/* Attachments */} {/* Attachments */}
@@ -3,6 +3,7 @@
import { Download, Loader2, Pause, Play } from "lucide-react"; import { Download, Loader2, Pause, Play } from "lucide-react";
import { useState } from "react"; import { useState } from "react";
import { GlassCard } from "@/components/glass/card"; import { GlassCard } from "@/components/glass/card";
import { formatBytes } from "@/lib/format";
import type { VoiceRecording } from "@/lib/types"; import type { VoiceRecording } from "@/lib/types";
interface RecordingCardProps { interface RecordingCardProps {
@@ -24,9 +25,9 @@ export function RecordingCard({
onTogglePlay, onTogglePlay,
}: RecordingCardProps) { }: RecordingCardProps) {
const [downloading, setDownloading] = useState(false); const [downloading, setDownloading] = useState(false);
const durationStr = recording.duration_bytes const sizeStr = recording.size_bytes
? `${Math.floor(recording.duration_bytes / 60)}:${String(recording.duration_bytes % 60).padStart(2, "0")}` ? formatBytes(recording.size_bytes)
: "--:--"; : "--";
// Fetch the file (CORS is open on the uploader) → blob → force download with // Fetch the file (CORS is open on the uploader) → blob → force download with
// the real filename. Falls back to opening the URL in a new tab. // the real filename. Falls back to opening the URL in a new tab.
@@ -119,7 +120,7 @@ export function RecordingCard({
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<span className="text-[10px] font-mono text-text-secondary/60"> <span className="text-[10px] font-mono text-text-secondary/60">
{durationStr} {sizeStr}
</span> </span>
<span className="text-[10px] text-text-secondary/40"> <span className="text-[10px] text-text-secondary/40">
{new Date(recording.created_at).toLocaleString()} {new Date(recording.created_at).toLocaleString()}
@@ -1,22 +1,30 @@
"use client"; "use client";
import { import { Mic, MicOff } from "lucide-react";
Bar,
BarChart,
ResponsiveContainer,
Tooltip,
XAxis,
YAxis,
} from "recharts";
import { GlassCard } from "@/components/glass/card"; import { GlassCard } from "@/components/glass/card";
import { useMounted } from "@/lib/hooks/use-mounted"; import type { ActiveSpeaker } from "@/lib/types";
interface ActivityTimelineProps { interface ActivityTimelineProps {
data?: { user: string; duration: number }[]; data?: ActiveSpeaker[];
} }
/**
* Voice Activity — live view of everyone currently in the monitored voice
* channel and whether they are speaking right now.
*
* Previously this rendered a Recharts bar chart fed from a `{user, duration}`
* prop that NO caller ever supplied, so the Activity tab always showed an
* empty, misleading chart. It now renders real live speaker state from the
* WebSocket (same source as the Connection tab's waveform).
*/
export function VoiceActivityTimeline({ data = [] }: ActivityTimelineProps) { export function VoiceActivityTimeline({ data = [] }: ActivityTimelineProps) {
const mounted = useMounted(); const sorted = [...data].sort((a, b) =>
a.speaking === b.speaking
? String(a.username).localeCompare(b.username)
: a.speaking
? -1
: 1,
);
return ( return (
<GlassCard variant="base"> <GlassCard variant="base">
@@ -24,54 +32,53 @@ export function VoiceActivityTimeline({ data = [] }: ActivityTimelineProps) {
<span className="text-xs font-semibold tracking-wide uppercase text-text-secondary"> <span className="text-xs font-semibold tracking-wide uppercase text-text-secondary">
Voice Activity Voice Activity
</span> </span>
<span className="text-[10px] text-text-secondary/50 ml-auto">
{sorted.length} speaker{sorted.length !== 1 ? "s" : ""} · live
</span>
</div> </div>
<div className="h-40">
{mounted ? ( {sorted.length === 0 ? (
<ResponsiveContainer <div className="flex flex-col items-center justify-center py-12 text-center">
width="100%" <MicOff className="size-8 text-text-secondary/30 mb-2" />
height={160} <p className="text-xs text-text-secondary/60">
minWidth={0} No speakers in the monitored voice channel.
minHeight={0} </p>
> <p className="mt-1 text-[10px] text-text-secondary/40">
<BarChart data={data} layout="vertical"> Connect to a voice channel to see live activity here.
<XAxis </p>
type="number" </div>
axisLine={false} ) : (
tickLine={false} <div className="space-y-1.5">
tick={{ fill: "oklch(0.55 0.02 245)", fontSize: 10 }} {sorted.map((s) => (
/> <div
<YAxis key={s.userId}
type="category" className="flex items-center gap-2 rounded-lg border border-border/40 bg-card/40 px-3 py-2"
dataKey="user" >
axisLine={false} {s.speaking ? (
tickLine={false} <Mic className="size-3.5 text-primary shrink-0" />
tick={{ fill: "oklch(0.55 0.02 245)", fontSize: 10 }} ) : (
width={80} <MicOff className="size-3.5 text-text-secondary/40 shrink-0" />
/> )}
<Tooltip <span
contentStyle={{ className={`truncate text-sm ${
background: "oklch(0.11 0.02 245 / 0.9)", s.speaking
border: "1px solid oklch(1 0 0 / 0.08)", ? "text-text-primary font-medium"
borderRadius: 8, : "text-text-secondary/70"
fontSize: 12, }`}
color: "oklch(0.93 0.01 245)", >
}} {s.username}
formatter={(value) => [ </span>
`${(Number(value) / 60).toFixed(1)}m`, <span
"Duration", className={`ml-auto shrink-0 text-[9px] font-semibold uppercase tracking-widest ${
]} s.speaking ? "text-primary" : "text-text-secondary/40"
/> }`}
<Bar >
dataKey="duration" {s.speaking ? "Speaking" : "Listening"}
fill="var(--color-primary)" </span>
radius={[0, 4, 4, 0]} </div>
/> ))}
</BarChart> </div>
</ResponsiveContainer> )}
) : (
<div className="h-full w-full animate-pulse rounded-md bg-card/40" />
)}
</div>
</GlassCard> </GlassCard>
); );
} }
+59 -15
View File
@@ -183,43 +183,87 @@ export function useMessagesWsSync(ws: WsHook, guildId: string) {
const { mutate } = useSWRConfig(); const { mutate } = useSWRConfig();
useEffect(() => { useEffect(() => {
if (!guildId) return; if (!guildId) return;
// Patch every message-list key for this guild (all channels + "__all__") // Patch every message-list key for this guild (all channels + "**filtered**").
// The updater receives the SWR key so we can honor its channel filter:
// a live `message_created`/updated for channel B must NOT be prepended to
// a list that is filtered down to channel A.
const patchLists = ( const patchLists = (
matcher: (key: unknown, msg: { channel_id?: string }) => boolean,
updater: (old: MessagePage | undefined) => MessagePage | undefined, updater: (old: MessagePage | undefined) => MessagePage | undefined,
msg: { channel_id?: string },
) => { ) => {
void mutate( void mutate(
(key) => (key) =>
Array.isArray(key) && key[0] === "messages" && key[1] === guildId, Array.isArray(key) &&
key[0] === "messages" &&
key[1] === guildId &&
matcher(key, msg),
updater, updater,
{ revalidate: false }, { revalidate: false },
); );
}; };
// A list key [messages, guildId, channelId] is "channel N" when channelId
// is a non-empty string and matches the incoming message; "__all__" (or
// any non-channel) lists accept every message of the guild.
const matchesFilter = (key: unknown[], msg: { channel_id?: string }) => {
const channelId = key[2] as string | undefined;
if (!channelId || channelId === "__all__") return true;
return msg.channel_id === channelId;
};
const unsub1 = ws.on("message_created", (data) => { const unsub1 = ws.on("message_created", (data) => {
const msg = data as MessageRecord; const msg = data as MessageRecord;
patchLists((old) => (old ? { ...old, data: [msg, ...old.data] } : old)); patchLists(
(_k, m) => matchesFilter(_k as unknown[], m),
(old) => (old ? { ...old, data: [msg, ...old.data] } : old),
msg,
);
}); });
const unsub2 = ws.on("message_updated", (data) => { const unsub2 = ws.on("message_updated", (data) => {
const msg = data as MessageRecord; const msg = data as Partial<MessageRecord> & { id: string };
patchLists((old) => // The gateway broadcasts a PARTIAL update ({ id, edited_content,
old // edited_at, ... }) — merge it over the existing record instead of
? { ...old, data: old.data.map((m) => (m.id === msg.id ? msg : m)) } // replacing it, or the card would lose username/content/channel/etc.
: old, patchLists(
(_k, m) =>
(m as Partial<MessageRecord>).channel_id === undefined ||
matchesFilter(_k as unknown[], m),
(old) =>
old
? {
...old,
data: old.data.map((m) =>
m.id === msg.id ? { ...m, ...msg } : m,
),
}
: old,
msg,
);
void mutate(
msgKeys.detail(msg.id),
(old: MessageRecord | undefined) => (old ? { ...old, ...msg } : old),
{ revalidate: false },
); );
void mutate(msgKeys.detail(msg.id), msg, { revalidate: false });
}); });
const unsub3 = ws.on("message_deleted", (data) => { const unsub3 = ws.on("message_deleted", (data) => {
const { id } = data as { id: string }; const { id } = data as { id: string };
patchLists((old) => patchLists(
old ? { ...old, data: old.data.filter((m) => m.id !== id) } : old, () => true,
(old) =>
old ? { ...old, data: old.data.filter((m) => m.id !== id) } : old,
{ channel_id: undefined },
); );
}); });
const unsub4 = ws.on("message_analyzed", (data) => { const unsub4 = ws.on("message_analyzed", (data) => {
const msg = data as MessageRecord; const msg = data as MessageRecord;
patchLists((old) => // message_analyzed carries the FULL record — replace is fine.
old patchLists(
? { ...old, data: old.data.map((m) => (m.id === msg.id ? msg : m)) } (_k, m) => matchesFilter(_k as unknown[], m),
: old, (old) =>
old
? { ...old, data: old.data.map((m) => (m.id === msg.id ? msg : m)) }
: old,
msg,
); );
void mutate(msgKeys.detail(msg.id), msg, { revalidate: false }); void mutate(msgKeys.detail(msg.id), msg, { revalidate: false });
}); });
@@ -8,8 +8,6 @@ export interface VoiceRecording {
channel_name?: string | null; channel_name?: string | null;
filename: string; filename: string;
size_bytes: number; size_bytes: number;
/** Present on REST rows; absent on WS voice_recording_uploaded events */
duration_bytes?: number | null;
download_url?: string | null; download_url?: string | null;
upload_status: string; upload_status: string;
upload_error?: string | null; upload_error?: string | null;
+6 -1
View File
@@ -27,7 +27,12 @@ export interface WsBinaryEvent {
export interface WsEventMap { export interface WsEventMap {
message_created: MessageRecord; message_created: MessageRecord;
message_updated: MessageRecord; /**
* The gateway broadcasts a PARTIAL update: { id } plus the changed fields
* (edited_content, edited_at, type, and the reset ai_* fields). It is NOT a
* full MessageRecord — merge it, never rely on it carrying the full row.
*/
message_updated: Partial<MessageRecord> & { id: string };
/** Gateway emits { id, deleted_at } — NOT a bare string */ /** Gateway emits { id, deleted_at } — NOT a bare string */
message_deleted: { id: string; deleted_at?: number }; message_deleted: { id: string; deleted_at?: number };
message_analyzed: MessageRecord; message_analyzed: MessageRecord;