fix(voice): media queue field mismatch, voice joinable filter, connect error toast

Audit voice (kirim/terima/music/screenshare) menemukan 3 masalah:
1. media:queue SILENT no-op — backend publish {source,mode} tapi gateway
   handler baca payload.url → selalu 'received without a URL'. Backend
   sekarang kirim {url,mode}, gateway terima url ATAU source (robust).
2. Voice connect gagal diam-diam saat user pilih channel tanpa permission
   (joinable=false, contoh Music 32/64/128/256k). Backend+gateway sekarang
   expose joinable; FE disable channel 'no akses' + empty state.
3. FE tidak kasih feedback saat connect gagal — tambah toast.error dengan
   pesan dari backend.

Verified live: @discordjs/voice connect ke Lofi Radio joinable sukses
(VOICE READY, DAVE session OK) — pipeline voice sebenarnya sehat, masalah
utama UX. media:queue fix akan di-verify setelah deploy.
This commit is contained in:
asepharyana
2026-08-03 07:48:50 +07:00
parent 9abb09dd33
commit a57eeb2e22
7 changed files with 40 additions and 10 deletions
@@ -99,7 +99,9 @@ export async function queue(
() => () =>
publishCommand<MediaState>( publishCommand<MediaState>(
COMMAND_MEDIA_QUEUE, COMMAND_MEDIA_QUEUE,
{ source, mode }, // NOTE: gateway MediaHandler reads `payload.url` (not `source`) —
// keep the field name aligned or playback silently no-ops.
{ url: source, mode },
DEFAULT_COMMAND_TIMEOUT_MS, DEFAULT_COMMAND_TIMEOUT_MS,
), ),
() => readStatusFallback(), () => readStatusFallback(),
@@ -28,6 +28,8 @@ export interface Channel {
id: string; id: string;
name: string; name: string;
type: "voice" | "text"; type: "voice" | "text";
/** Whether the selfbot account can actually join this voice channel. */
joinable?: boolean;
} }
export interface GuildVoiceEntry { export interface GuildVoiceEntry {
@@ -124,7 +124,8 @@ export class MediaHandler {
} }
async handleMediaQueue(cmd: CommandMessage): Promise<CommandReply<unknown>> { async handleMediaQueue(cmd: CommandMessage): Promise<CommandReply<unknown>> {
const url = String(cmd.payload.url ?? "").trim(); // Accept both `url` (canonical) and `source` (legacy FE) for resilience.
const url = String(cmd.payload.url ?? cmd.payload.source ?? "").trim();
const mode: MediaMode = cmd.payload.mode === "screen" ? "screen" : "music"; const mode: MediaMode = cmd.payload.mode === "screen" ? "screen" : "music";
const requestedBy = String(cmd.payload.requestedBy ?? "unknown"); const requestedBy = String(cmd.payload.requestedBy ?? "unknown");
@@ -128,6 +128,9 @@ export class VoiceHandler {
id: c.id, id: c.id,
name: c.name, name: c.name,
type: "voice" as const, type: "voice" as const,
// selfbot exposes joinable (permission check) — let FE filter
// channels the account actually may join.
joinable: (c as { joinable?: boolean }).joinable ?? true,
})); }));
return { id: cmd.id, success: true, data: voiceChannels }; return { id: cmd.id, success: true, data: voiceChannels };
@@ -18,6 +18,7 @@ import {
useVoiceStatus, useVoiceStatus,
} from "@/hooks"; } from "@/hooks";
import { useWebSocket } from "@/lib/ws/context"; import { useWebSocket } from "@/lib/ws/context";
import { toast } from "sonner";
type VoiceTab = "connection" | "activity"; type VoiceTab = "connection" | "activity";
@@ -104,12 +105,22 @@ export default function VoicePage() {
selectedChannel={selectedChannel} selectedChannel={selectedChannel}
onGuildChange={handleGuildChange} onGuildChange={handleGuildChange}
onChannelChange={(v) => setSelectedChannel(v ?? "")} onChannelChange={(v) => setSelectedChannel(v ?? "")}
onConnect={() => onConnect={() => {
connectMut.mutate({ void connectMut
.mutateAsync({
guildId: selectedGuild, guildId: selectedGuild,
channelId: selectedChannel, channelId: selectedChannel,
}) })
} .catch((err: unknown) => {
const msg =
err instanceof Error
? err.message
: "Gagal connect ke voice channel";
toast.error("Voice connect gagal", {
description: msg,
});
});
}}
onDisconnect={() => { onDisconnect={() => {
if (micActive) { if (micActive) {
setMicActive(false); setMicActive(false);
@@ -119,10 +119,19 @@ export function VoiceConnectionCard({
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
{voiceChannels.map((c) => ( {voiceChannels.map((c) => (
<SelectItem key={c.id} value={c.id}> <SelectItem
{c.name} key={c.id}
value={c.id}
disabled={c.joinable === false}
>
{c.joinable === false ? `${c.name} (no akses)` : c.name}
</SelectItem> </SelectItem>
))} ))}
{voiceChannels.length === 0 && (
<div className="px-3 py-2 text-xs text-text-secondary/60">
Tidak ada voice channel
</div>
)}
</SelectContent> </SelectContent>
</Select> </Select>
</div> </div>
+2
View File
@@ -8,6 +8,8 @@ export interface Channel {
id: string; id: string;
name: string; name: string;
type: "voice" | "text"; type: "voice" | "text";
/** Whether the selfbot account can actually join this voice channel. */
joinable?: boolean;
} }
/** Shape of the /api/config response (camelCase keys from backend). */ /** Shape of the /api/config response (camelCase keys from backend). */