/* * Vencord, a Discord client mod * Copyright (c) 2025 Vendicated and contributors * SPDX-License-Identifier: GPL-3.0-or-later */ import { definePluginSettings } from "@api/Settings"; import { UserAreaButton, UserAreaRenderProps } from "@api/UserArea"; import { debounce } from "@shared/debounce"; import { Devs, EquicordDevs } from "@utils/constants"; import { t } from "@utils/translation"; import definePlugin, { makeRange, OptionType } from "@utils/types"; import { findByCodeLazy, findComponentByCodeLazy } from "@webpack"; import { ChannelActions, ChannelRouter, ChannelStore, ContextMenuApi, GuildStore, MediaEngineStore, Menu, PermissionsBits, PermissionStore, React, SelectedChannelStore, Toasts, UserStore, VoiceActions, VoiceStateStore } from "@webpack/common"; const ChatVoiceIcon = findComponentByCodeLazy("0l1.8-1.8c.17"); const startStream = findByCodeLazy('type:"STREAM_START"'); const getDesktopSources = findByCodeLazy("desktop sources"); const valueOperation = [ { label: "More than", value: "<", default: false }, { label: "Less than", value: ">", default: false }, { label: "Equal to", value: "==", default: true }, ]; const settings = definePluginSettings({ UserAmountOperation: { description: "Select an operation for the amounts of users", type: OptionType.SELECT, options: [...valueOperation], }, UserAmount: { description: "Select amount of users", type: OptionType.SLIDER, markers: makeRange(0, 15, 1), default: 3, stickToMarkers: true, }, spacesLeftOperation: { description: "Select an operation for the maximum amounts of users", type: OptionType.SELECT, options: [...valueOperation], }, spacesLeft: { description: "Select amount of max users", type: OptionType.SLIDER, markers: makeRange(0, 15, 1), default: 3, stickToMarkers: true, }, vcLimitOperation: { description: "Select an operation for the voice-channel.", type: OptionType.SELECT, options: [...valueOperation], }, vcLimit: { description: "Select a voice-channel limit", type: OptionType.SLIDER, markers: makeRange(1, 15, 1), default: 5, stickToMarkers: true, }, Servers: { description: "Servers that are included", type: OptionType.STRING, default: "", }, autoNavigate: { type: OptionType.BOOLEAN, description: "Automatically navigates to the voice-channel.", default: false, }, autoCamera: { type: OptionType.BOOLEAN, description: "Automatically turns on camera", default: false, }, autoStream: { type: OptionType.BOOLEAN, description: "Automatically turns on stream", default: false, }, selfMute: { type: OptionType.BOOLEAN, description: "Automatically mutes your mic when joining voice-channel.", default: false, }, selfDeafen: { type: OptionType.BOOLEAN, description: "Automatically deafems your mic when joining voice-channel.", default: false, }, leaveEmpty: { type: OptionType.BOOLEAN, description: "Finds a random-call, when the voice chat is empty.", default: false, }, avoidStages: { type: OptionType.BOOLEAN, description: "Avoids joining stage voice-channels.", default: false, }, avoidAfk: { type: OptionType.BOOLEAN, description: "Avoids joining AFK voice-channels.", default: false, }, video: { type: OptionType.BOOLEAN, description: "Searches for users with their video on", default: false, }, stream: { type: OptionType.BOOLEAN, description: "Searches for users who are streaming", default: false, }, mute: { type: OptionType.BOOLEAN, description: "Searches for users who are muted", default: false, }, deafen: { type: OptionType.BOOLEAN, description: "Searches for users who are deafened", default: false, }, includeStates: { type: OptionType.BOOLEAN, description: "Option to include states", default: false, }, avoidStates: { type: OptionType.BOOLEAN, description: "Option to avoid states", default: false, }, }); interface VoiceState { userId: string; channelId?: string; oldChannelId?: string; deaf: boolean; mute: boolean; selfDeaf: boolean; selfMute: boolean; selfStream: boolean; selfVideo: boolean; sessionId: string; suppress: boolean; requestToSpeakTimestamp: string | null; } function RandomVoiceIcon({ className }: { className?: string; }) { return ( ); } export default definePlugin({ name: "RandomVoice", description: t("randomVoice.description"), authors: [EquicordDevs.xijexo, EquicordDevs.omaw, Devs.thororen], userAreaButton: { icon: RandomVoiceIcon, render: RandomVoiceButton }, flux: { VOICE_STATE_UPDATES() { const currentUserId = UserStore.getCurrentUser().id; const myChannelId = VoiceStateStore.getVoiceStateForUser(currentUserId)?.channelId; if (!myChannelId || !settings.store.leaveEmpty) return; const voiceStatesMap = VoiceStateStore.getVoiceStates() as Record; const othersInChannel = Object.values(voiceStatesMap).filter(vs => vs.channelId === myChannelId && vs.userId !== currentUserId ); if (othersInChannel.length === 0) { getChannels(); } }, }, settings, }); function RandomVoiceButton({ iconForeground, hideTooltips, nameplate }: UserAreaRenderProps) { return ( ContextMenuApi.openContextMenu(e, () => )} onClick={() => getChannels()} role="switch" tooltipText={hideTooltips ? void 0 : "Random Voice"} icon={} plated={nameplate != null} /> ); } function ContextMenu() { let ServerList: any[] = []; Object.values(UserStore.getUsers()).forEach(user => { const { channelId } = VoiceStateStore.getVoiceStateForUser(user.id) ?? {}; if (!channelId) return; const channel = ChannelStore.getChannel(channelId); if (channel) ServerList.push(channel.getGuildId()); }); ServerList = Array.from(new Set(ServerList)); const Servers = ServerList.map(server => GuildStore.getGuild(server)).filter(guild => guild && guild.id); const [servers, setServers] = React.useState(settings.store.Servers); const [SpacesLeftOperation, setSpacesLeftOperation] = React.useState(settings.store.spacesLeftOperation); const [userAmount, setuserAmount] = React.useState(settings.store.UserAmountOperation); const [vcOperation, setVcOperation] = React.useState(settings.store.vcLimitOperation); const [navigate, setnavigate] = React.useState(settings.store.autoNavigate); const [stage, setStage] = React.useState(settings.store.avoidStages); const [afk, setAfk] = React.useState(settings.store.avoidAfk); const [camera, setCamera] = React.useState(settings.store.autoCamera); const [stream, setStream] = React.useState(settings.store.autoStream); const [empty, setEmpty] = React.useState(settings.store.leaveEmpty); const [muteself, setSelfMute] = React.useState(settings.store.selfMute); const [deafenself, setSelfDeafen] = React.useState(settings.store.selfDeafen); const [mute, setMute] = React.useState(settings.store.mute); const [deafen, setDeafen] = React.useState(settings.store.deafen); const [video, setVideo] = React.useState(settings.store.video); const [state, setState] = React.useState(settings.store.includeStates); const [notstate, avoidState] = React.useState(settings.store.avoidStates); return ( { }} aria-label="Voice state modifier" > { }} > <> {Servers.map(server => ( <> { if (settings.store.Servers.includes(server?.id ?? "invalid server")) settings.store.Servers = settings.store.Servers.replace(`/${server.id}`, ""); else settings.store.Servers += `/${server?.id ?? "invalid server"}`; setServers(settings.store.Servers); }} /> ))} { const allServerIds = Servers.filter(server => server?.id).map(server => server.id); settings.store.Servers = `/${allServerIds.join("/")}`; setServers(settings.store.Servers); }} disabled={servers.length === Servers.filter(server => server?.id).length} icon={() => { return ( ); }} /> { settings.store.Servers = ""; setServers(""); }} icon={() => { return ( ); }} /> { }} > <> { setMute(!mute); settings.store.mute = !mute; }} checked={mute} /> { setDeafen(!deafen); settings.store.deafen = !deafen; }} checked={deafen} /> { setVideo(!video); settings.store.video = !video; }} checked={video} /> { setStream(!stream); settings.store.stream = !stream; }} checked={stream} /> { setState(!state); settings.store.includeStates = !state; }} checked={state} /> { avoidState(!notstate); settings.store.avoidStates = !notstate; }} checked={notstate} /> ( { settings.store.UserAmount = Number(value.toFixed(0)); }, 50)} renderValue={(value: number) => `${value.toFixed(0)} user${Number(value.toFixed(0)) === 1 ? "" : "s"}`} /> )} /> { }} > <> { setuserAmount("<"); settings.store.UserAmountOperation = "<"; }} /> "} action={() => { setuserAmount(">"); settings.store.UserAmountOperation = ">"; }} /> { setuserAmount("=="); settings.store.UserAmountOperation = "=="; }} /> ( { settings.store.spacesLeft = Number(value.toFixed(0)); }, 50)} renderValue={(value: number) => `${value.toFixed(0)} user${Number(value.toFixed(0)) === 1 ? "" : "s"}`} /> )} /> { }} > <> { setSpacesLeftOperation("<"); settings.store.spacesLeftOperation = "<"; }} /> "} action={() => { setSpacesLeftOperation(">"); settings.store.spacesLeftOperation = ">"; }} /> { setSpacesLeftOperation("=="); settings.store.spacesLeftOperation = "=="; }} /> ( { settings.store.vcLimit = Number(value.toFixed(0)); }, 50)} renderValue={(value: number) => `${value.toFixed(0)} user${Number(value.toFixed(0)) === 1 ? "" : "s"}`} /> )} /> { }} > <> { setVcOperation("<"); settings.store.vcLimitOperation = "<"; }} /> "} action={() => { setVcOperation(">"); settings.store.vcLimitOperation = ">"; }} /> { setVcOperation("=="); settings.store.vcLimitOperation = "=="; }} /> { }} > <> { setSelfMute(!muteself); settings.store.selfMute = !muteself; }} checked={muteself} /> { setSelfDeafen(!deafenself); settings.store.selfDeafen = !deafenself; }} checked={deafenself} /> { setCamera(!camera); settings.store.autoCamera = !camera; }} checked={camera} /> { setStream(!stream); settings.store.autoStream = !stream; }} checked={stream} /> { setEmpty(!empty); settings.store.leaveEmpty = !empty; }} checked={empty} /> { setnavigate(!navigate); settings.store.autoNavigate = !navigate; }} checked={navigate} /> { setStage(!stage); settings.store.avoidStages = !stage; }} checked={stage} /> { setAfk(!afk); settings.store.avoidAfk = !afk; }} checked={afk} /> ); } function getChannels() { const criteriaChannel: any[] = []; Object.values(UserStore.getUsers()).forEach(user => { const { channelId, selfDeaf, selfMute, selfStream, selfVideo } = VoiceStateStore.getVoiceStateForUser(user.id) ?? {}; if (!channelId) return; if (criteriaChannel.includes(channelId)) return; const channel = ChannelStore.getChannel(channelId); if (!channel) return; const channelVoiceStates = VoiceStateStore.getVoiceStatesForChannel(channelId); if (!settings.store.Servers.split("/").includes(channel.getGuildId())) return; if (settings.store.avoidStages && channel.isGuildStageVoice()) return; const operations = { ">": (a, b) => a < b, "<": (a, b) => a > b, "==": (a, b) => a === b, }; const users = Object.keys(channelVoiceStates).length; const VcLimit = channel.userLimit === 0 ? 99 : channel.userLimit; const spacesLeft = VcLimit - users; if (!operations[settings.store.spacesLeftOperation](spacesLeft, settings.store.spacesLeft)) return; if (!operations[settings.store.UserAmountOperation](users, settings.store.UserAmount)) return; if (!operations[settings.store.vcLimitOperation](VcLimit, settings.store.vcLimit)) return; if (Object.keys(channelVoiceStates).length === channel?.userLimit) return; if (Object.keys(channelVoiceStates).includes(UserStore.getCurrentUser().id)) return; if (!PermissionStore.can(PermissionsBits.CONNECT, channel)) return; if (settings.store.avoidAfk && !PermissionStore.can(PermissionsBits.SPEAK, channel)) return; if (settings.store.avoidStates) { let lowestMismatchCount = Infinity; const channelVoiceStates = VoiceStateStore.getVoiceStatesForChannel(channel.id); let mismatchedStates = 0; let bestChannelId: string | null = null; for (const state of Object.values(channelVoiceStates) as { selfMute?: boolean; selfDeaf?: boolean; selfVideo?: boolean; selfStream?: boolean; }[]) { if ((settings.store.deafen && state.selfDeaf) || (!settings.store.deafen && !state.selfDeaf)) mismatchedStates++; if ((settings.store.video && !state.selfVideo) || (!settings.store.video && state.selfVideo)) mismatchedStates++; if ((settings.store.stream && !state.selfStream) || (!settings.store.stream && state.selfStream)) mismatchedStates++; if (!settings.store.deafen) { if ((settings.store.mute && state.selfMute) || (!settings.store.mute && !state.selfMute)) mismatchedStates++; } } if (mismatchedStates < lowestMismatchCount) { lowestMismatchCount = mismatchedStates; bestChannelId = channel.id; } if (bestChannelId) { criteriaChannel.push(channelId); } } if (settings.store.includeStates && !settings.store.avoidStates) { if ((settings.store.deafen && !selfDeaf) || (!settings.store.deafen && selfDeaf)) return; if ((settings.store.video && !selfVideo) || (!settings.store.video && selfVideo)) return; if ((settings.store.stream && !selfStream) || (!settings.store.stream && selfStream)) return; if (!settings.store.deafen) if ((settings.store.mute && !selfMute) || (!settings.store.mute && selfMute)) return; } criteriaChannel.push(channelId); }); if (criteriaChannel.length === 0) { Toasts.show({ message: "Failed to find a Voice channel!", id: "Vc-not-found", type: Toasts.Type.MESSAGE, options: { position: Toasts.Position.BOTTOM, } }); return; } const randomIndex = Math.floor(Math.random() * criteriaChannel.length); JoinVc(criteriaChannel[randomIndex]); } function JoinVc(channelID) { const channel = ChannelStore.getChannel(channelID); ChannelActions.selectVoiceChannel(channelID); if (settings.store.autoNavigate) ChannelRouter.transitionToChannel(channel.id); if (settings.store.autoCamera && PermissionStore.can(PermissionsBits.STREAM, channel)) autoCamera(); if (settings.store.autoStream && PermissionStore.can(PermissionsBits.STREAM, channel)) autoStream(); if (settings.store.selfMute && !MediaEngineStore.isSelfMute() && SelectedChannelStore.getVoiceChannelId()) VoiceActions.toggleSelfMute(); if (settings.store.selfDeafen && !MediaEngineStore.isSelfDeaf() && SelectedChannelStore.getVoiceChannelId()) VoiceActions.toggleSelfDeaf(); } async function autoStream() { const mediaEngine = MediaEngineStore.getMediaEngine(); const selected = SelectedChannelStore.getVoiceChannelId(); if (!selected) return; const channel = ChannelStore.getChannel(selected); const sources = await getDesktopSources(mediaEngine, ["screen"], null); if (!sources || sources.length === 0) return; const source = sources[0]; if (channel.type === 13 || !PermissionStore.can(PermissionsBits.STREAM, channel)) return; startStream(channel.guild_id, selected, { "pid": null, "sourceId": source.id, "sourceName": source.name, "audioSourceId": null, "sound": true, "previewDisabled": false }); } function autoCamera() { const checkExist = setInterval(() => { const cameraOFF = document.querySelector('[aria-label="Turn off Camera" i]') as HTMLButtonElement; if (cameraOFF) clearInterval(checkExist); const camera = document.querySelector('[aria-label="Turn on Camera" i]') as HTMLButtonElement; if (camera) { clearInterval(checkExist); camera.click(); } }, 50); }