Add voice audio, capture and transcript, browser monitor, and DM UI
This commit is contained in:
@@ -42,6 +42,13 @@ const KNOWN_ACTIONS = new Set<string>([
|
||||
PuppetAction.StateGet,
|
||||
PuppetAction.VoiceGet,
|
||||
PuppetAction.VoiceOverview,
|
||||
PuppetAction.VoiceJoin,
|
||||
PuppetAction.VoiceLeave,
|
||||
PuppetAction.VoiceMove,
|
||||
PuppetAction.VoiceUsers,
|
||||
PuppetAction.UserStatus,
|
||||
PuppetAction.VoiceDevices,
|
||||
PuppetAction.VoiceSetDevices,
|
||||
PuppetAction.UserSearch,
|
||||
PuppetAction.DmList,
|
||||
PuppetAction.MessageSend,
|
||||
|
||||
+262
-18
@@ -20,6 +20,8 @@ import {
|
||||
GuildMemberStore,
|
||||
GuildStore,
|
||||
MessageStore,
|
||||
PresenceStore,
|
||||
ReadStateStore,
|
||||
UserStore,
|
||||
VoiceStateStore
|
||||
} from "@vencord/types/webpack/common";
|
||||
@@ -28,12 +30,21 @@ import { isSnowflake, PUPPET_LIMITS, PuppetAction, PuppetErrorCode, type PuppetR
|
||||
/** Channel types that can receive text messages. */
|
||||
const SENDABLE_CHANNEL_TYPES = new Set([0, 1, 3, 5, 10, 11, 12]);
|
||||
|
||||
/** Channel types that are voice channels. */
|
||||
const VOICE_CHANNEL_TYPES = new Set([2, 13]);
|
||||
|
||||
/** Discord's message fetch action, located lazily at runtime. */
|
||||
const MessageActions = findByPropsLazy("fetchMessages");
|
||||
|
||||
/** Discord's reaction actions, located lazily at runtime. */
|
||||
const ReactionActions = findByPropsLazy("addReaction", "removeReaction");
|
||||
|
||||
/** Discord's channel actions, located lazily at runtime. */
|
||||
const ChannelActions = findByPropsLazy("selectVoiceChannel");
|
||||
|
||||
/** Discord's media engine actions, located lazily at runtime. */
|
||||
const MediaEngineActions = findByPropsLazy("setSelfMute");
|
||||
|
||||
function compareIds(a: string, b: string): number {
|
||||
try {
|
||||
const left = BigInt(a);
|
||||
@@ -157,20 +168,53 @@ function buildState() {
|
||||
};
|
||||
}
|
||||
|
||||
function voiceMember(state: any) {
|
||||
function voiceMember(state: any, guildId?: string) {
|
||||
const user = UserStore.getUser(state.userId);
|
||||
return {
|
||||
id: state.userId,
|
||||
username: user?.username ?? state.userId,
|
||||
globalName: user?.globalName ?? null,
|
||||
nick: guildId ? GuildMemberStore.getNick(guildId, state.userId) : null,
|
||||
avatar: user?.avatar ?? null,
|
||||
mute: !!state.mute,
|
||||
deaf: !!state.deaf,
|
||||
selfMute: !!state.selfMute,
|
||||
selfDeaf: !!state.selfDeaf
|
||||
selfDeaf: !!state.selfDeaf,
|
||||
video: !!state.selfVideo,
|
||||
stream: !!state.selfStream || !!state.stream,
|
||||
suppress: !!state.suppress
|
||||
};
|
||||
}
|
||||
|
||||
/** Discord snowflakes carry a millisecond timestamp. */
|
||||
function snowflakeToIso(id: string | null | undefined): string | null {
|
||||
if (!id) return null;
|
||||
try {
|
||||
return new Date(Number(BigInt(id) >> 22n) + 1420070400000).toISOString();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function unreadCounts(channelId: string): { unread: number; mentions: number } {
|
||||
try {
|
||||
return {
|
||||
unread: ReadStateStore.getUnreadCount(channelId) ?? 0,
|
||||
mentions: ReadStateStore.getMentionCount(channelId) ?? 0
|
||||
};
|
||||
} catch {
|
||||
return { unread: 0, mentions: 0 };
|
||||
}
|
||||
}
|
||||
|
||||
function presenceOf(userId: string): string {
|
||||
try {
|
||||
return PresenceStore.getStatus(userId) ?? "offline";
|
||||
} catch {
|
||||
return "offline";
|
||||
}
|
||||
}
|
||||
|
||||
function toUserResult(user: any, guildId?: string) {
|
||||
const nick = guildId ? GuildMemberStore.getNick(guildId, user.id) : null;
|
||||
return {
|
||||
@@ -318,7 +362,66 @@ export async function handlePuppetAction(action: string, params: Record<string,
|
||||
guildId: channel?.guild_id ?? guildId ?? null,
|
||||
channelId: voiceState.channelId,
|
||||
channelName: channel?.name ?? null,
|
||||
members: Object.values(states).map(voiceMember)
|
||||
members: Object.values(states).map(state =>
|
||||
voiceMember(state, (channel?.guild_id as string | undefined) ?? (guildId as string | undefined))
|
||||
)
|
||||
});
|
||||
}
|
||||
|
||||
case PuppetAction.VoiceUsers: {
|
||||
const { userIds } = params;
|
||||
assert(
|
||||
Array.isArray(userIds) && userIds.length >= 1 && userIds.length <= 50,
|
||||
PuppetErrorCode.InvalidParam,
|
||||
"userIds must be an array of 1 to 50 user IDs"
|
||||
);
|
||||
assert(
|
||||
userIds.every(id => isSnowflake(id)),
|
||||
PuppetErrorCode.InvalidSnowflake,
|
||||
"every userId must be a snowflake"
|
||||
);
|
||||
|
||||
const users = (userIds as string[]).map(id => {
|
||||
const state = VoiceStateStore.getVoiceStateForUser(id) ?? VoiceStateStore.getVoiceState(null, id);
|
||||
const user = UserStore.getUser(id);
|
||||
const profile = {
|
||||
id,
|
||||
username: user?.username ?? id,
|
||||
globalName: user?.globalName ?? null,
|
||||
avatar: user?.avatar ?? null
|
||||
};
|
||||
|
||||
if (!state?.channelId) {
|
||||
return { ...profile, channelId: null, channelName: null, guildId: null, members: [] };
|
||||
}
|
||||
|
||||
const channel = ChannelStore.getChannel(state.channelId);
|
||||
const states = VoiceStateStore.getVoiceStatesForChannel(state.channelId);
|
||||
|
||||
return {
|
||||
...profile,
|
||||
channelId: state.channelId,
|
||||
channelName: channel?.name ?? null,
|
||||
guildId: (channel?.guild_id as string | undefined) ?? null,
|
||||
members: Object.values(states).map(member =>
|
||||
voiceMember(member, channel?.guild_id as string | undefined)
|
||||
)
|
||||
};
|
||||
});
|
||||
|
||||
return ok({ users });
|
||||
}
|
||||
|
||||
case PuppetAction.UserStatus: {
|
||||
const { userIds } = params;
|
||||
assert(
|
||||
Array.isArray(userIds) && userIds.length >= 1 && userIds.length <= 50,
|
||||
PuppetErrorCode.InvalidParam,
|
||||
"userIds must be an array of 1 to 50 user IDs"
|
||||
);
|
||||
|
||||
return ok({
|
||||
users: (userIds as string[]).map(id => ({ id, status: presenceOf(id) }))
|
||||
});
|
||||
}
|
||||
|
||||
@@ -339,11 +442,127 @@ export async function handlePuppetAction(action: string, params: Record<string,
|
||||
channels: [...byChannel.entries()].map(([channelId, states]) => ({
|
||||
channelId,
|
||||
channelName: ChannelStore.getChannel(channelId)?.name ?? null,
|
||||
members: states.map(voiceMember)
|
||||
members: states.map(state => voiceMember(state, guildId as string))
|
||||
}))
|
||||
});
|
||||
}
|
||||
|
||||
case PuppetAction.VoiceJoin:
|
||||
case PuppetAction.VoiceMove: {
|
||||
const { channelId, mute = true } = params;
|
||||
assert(isSnowflake(channelId), PuppetErrorCode.InvalidSnowflake, "channelId must be a snowflake");
|
||||
|
||||
const channel = ChannelStore.getChannel(channelId);
|
||||
assert(channel, PuppetErrorCode.ChannelNotFound, `channel ${channelId} not found`);
|
||||
assert(
|
||||
VOICE_CHANNEL_TYPES.has(channel.type),
|
||||
PuppetErrorCode.InvalidParam,
|
||||
`channel type ${channel.type} is not a voice channel`
|
||||
);
|
||||
|
||||
const selectVoiceChannel = (ChannelActions as any)?.selectVoiceChannel;
|
||||
assert(
|
||||
typeof selectVoiceChannel === "function",
|
||||
PuppetErrorCode.NotSupported,
|
||||
"voice actions are not available in this client"
|
||||
);
|
||||
|
||||
if (action === PuppetAction.VoiceJoin && mute !== false) {
|
||||
const setSelfMute = (MediaEngineActions as any)?.setSelfMute;
|
||||
if (typeof setSelfMute === "function") {
|
||||
try {
|
||||
setSelfMute(true);
|
||||
} catch {
|
||||
// Best effort: the join still works unmuted.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
await selectVoiceChannel(channelId);
|
||||
} catch (error) {
|
||||
throw new ActionError(PuppetErrorCode.VoiceFailed, `voice join failed: ${String(error)}`);
|
||||
}
|
||||
|
||||
return ok({
|
||||
channelId,
|
||||
joined: true,
|
||||
muted: action === PuppetAction.VoiceJoin ? mute !== false : undefined
|
||||
});
|
||||
}
|
||||
|
||||
case PuppetAction.VoiceLeave: {
|
||||
const selectVoiceChannel = (ChannelActions as any)?.selectVoiceChannel;
|
||||
assert(
|
||||
typeof selectVoiceChannel === "function",
|
||||
PuppetErrorCode.NotSupported,
|
||||
"voice actions are not available in this client"
|
||||
);
|
||||
|
||||
try {
|
||||
await selectVoiceChannel(null);
|
||||
} catch (error) {
|
||||
throw new ActionError(PuppetErrorCode.VoiceFailed, `voice leave failed: ${String(error)}`);
|
||||
}
|
||||
|
||||
return ok({ left: true });
|
||||
}
|
||||
|
||||
case PuppetAction.VoiceDevices: {
|
||||
const devices = await navigator.mediaDevices.enumerateDevices();
|
||||
return ok({
|
||||
input: devices
|
||||
.filter(device => device.kind === "audioinput")
|
||||
.map(device => ({ id: device.deviceId, label: device.label || device.deviceId })),
|
||||
output: devices
|
||||
.filter(device => device.kind === "audiooutput")
|
||||
.map(device => ({ id: device.deviceId, label: device.label || device.deviceId }))
|
||||
});
|
||||
}
|
||||
|
||||
case PuppetAction.VoiceSetDevices: {
|
||||
const { inputDeviceId, outputDeviceId } = params;
|
||||
if (inputDeviceId !== undefined) {
|
||||
assert(
|
||||
typeof inputDeviceId === "string" && inputDeviceId.length <= 200,
|
||||
PuppetErrorCode.InvalidParam,
|
||||
"inputDeviceId must be a short string"
|
||||
);
|
||||
}
|
||||
if (outputDeviceId !== undefined) {
|
||||
assert(
|
||||
typeof outputDeviceId === "string" && outputDeviceId.length <= 200,
|
||||
PuppetErrorCode.InvalidParam,
|
||||
"outputDeviceId must be a short string"
|
||||
);
|
||||
}
|
||||
|
||||
const setInputDevice = (MediaEngineActions as any)?.setInputDevice;
|
||||
const setOutputDevice = (MediaEngineActions as any)?.setOutputDevice;
|
||||
|
||||
if (inputDeviceId !== undefined) {
|
||||
assert(
|
||||
typeof setInputDevice === "function",
|
||||
PuppetErrorCode.NotSupported,
|
||||
"device selection is not available in this client"
|
||||
);
|
||||
await setInputDevice(inputDeviceId);
|
||||
}
|
||||
if (outputDeviceId !== undefined) {
|
||||
assert(
|
||||
typeof setOutputDevice === "function",
|
||||
PuppetErrorCode.NotSupported,
|
||||
"device selection is not available in this client"
|
||||
);
|
||||
await setOutputDevice(outputDeviceId);
|
||||
}
|
||||
|
||||
return ok({
|
||||
inputDeviceId: inputDeviceId ?? null,
|
||||
outputDeviceId: outputDeviceId ?? null
|
||||
});
|
||||
}
|
||||
|
||||
case PuppetAction.UserSearch: {
|
||||
const { query, guildId, limit = 10 } = params;
|
||||
assert(
|
||||
@@ -379,20 +598,45 @@ export async function handlePuppetAction(action: string, params: Record<string,
|
||||
|
||||
const channels = ChannelStore.getSortedPrivateChannels()
|
||||
.slice(0, Number(limit))
|
||||
.map(channel => ({
|
||||
id: channel.id,
|
||||
type: channel.type,
|
||||
name: (channel as any).name ?? null,
|
||||
icon: (channel as any).icon ?? null,
|
||||
lastMessageId: (channel as any).lastMessageId ?? null,
|
||||
recipients: Object.values((channel as any).recipients ?? {}).map((user: any) => ({
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
globalName: user.globalName ?? null,
|
||||
avatar: user.avatar ?? null,
|
||||
bot: !!user.bot
|
||||
}))
|
||||
}));
|
||||
.map(channel => {
|
||||
const raw = (channel as any).recipients;
|
||||
const recipientIds: string[] = Array.isArray(raw)
|
||||
? raw
|
||||
: Object.values(raw ?? {})
|
||||
.map((user: any) => user?.id ?? user)
|
||||
.filter(Boolean);
|
||||
|
||||
// 1:1 DMs may store no recipient list; the channel knows the other user.
|
||||
if (!recipientIds.length && typeof (channel as any).getRecipientId === "function") {
|
||||
const id = (channel as any).getRecipientId();
|
||||
if (id) recipientIds.push(id);
|
||||
}
|
||||
|
||||
const lastMessageId = (channel as any).lastMessageId ?? null;
|
||||
const unread = unreadCounts(channel.id);
|
||||
|
||||
return {
|
||||
id: channel.id,
|
||||
type: channel.type,
|
||||
name: (channel as any).name ?? null,
|
||||
icon: (channel as any).icon ?? null,
|
||||
lastMessageId,
|
||||
lastMessageAt: snowflakeToIso(lastMessageId),
|
||||
unread: unread.unread,
|
||||
mentions: unread.mentions,
|
||||
recipients: recipientIds.map(id => {
|
||||
const user = UserStore.getUser(id);
|
||||
return {
|
||||
id,
|
||||
username: user?.username ?? id,
|
||||
globalName: user?.globalName ?? null,
|
||||
avatar: user?.avatar ?? null,
|
||||
status: presenceOf(id),
|
||||
bot: !!user?.bot
|
||||
};
|
||||
})
|
||||
};
|
||||
});
|
||||
|
||||
return ok({ channels });
|
||||
}
|
||||
|
||||
@@ -129,6 +129,20 @@ export function initPuppetEvents() {
|
||||
}
|
||||
});
|
||||
|
||||
dispatcher.subscribe("SPEAKING", (event: any) => {
|
||||
try {
|
||||
if (!event?.userId) return;
|
||||
VesktopNative.puppet.emit({
|
||||
type: "VOICE_SPEAKING",
|
||||
userId: event.userId,
|
||||
speaking: event.speaking !== false,
|
||||
channelId: event.channelId ?? event.channel_id ?? null
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("[puppet] speaking normalization failed:", error);
|
||||
}
|
||||
});
|
||||
|
||||
console.log("[veskslop] puppet events active");
|
||||
});
|
||||
}
|
||||
|
||||
@@ -18,6 +18,13 @@ export const enum PuppetAction {
|
||||
StateGet = "state.get",
|
||||
VoiceGet = "voice.get",
|
||||
VoiceOverview = "voice.overview",
|
||||
VoiceJoin = "voice.join",
|
||||
VoiceLeave = "voice.leave",
|
||||
VoiceMove = "voice.move",
|
||||
VoiceUsers = "voice.users",
|
||||
UserStatus = "user.status",
|
||||
VoiceDevices = "voice.devices",
|
||||
VoiceSetDevices = "voice.setDevices",
|
||||
UserSearch = "user.search",
|
||||
DmList = "dm.list",
|
||||
MessageSend = "message.send",
|
||||
@@ -33,6 +40,13 @@ export const PuppetActions: readonly PuppetAction[] = [
|
||||
PuppetAction.StateGet,
|
||||
PuppetAction.VoiceGet,
|
||||
PuppetAction.VoiceOverview,
|
||||
PuppetAction.VoiceJoin,
|
||||
PuppetAction.VoiceLeave,
|
||||
PuppetAction.VoiceMove,
|
||||
PuppetAction.VoiceUsers,
|
||||
PuppetAction.UserStatus,
|
||||
PuppetAction.VoiceDevices,
|
||||
PuppetAction.VoiceSetDevices,
|
||||
PuppetAction.UserSearch,
|
||||
PuppetAction.DmList,
|
||||
PuppetAction.MessageSend,
|
||||
@@ -56,6 +70,7 @@ export const enum PuppetErrorCode {
|
||||
NotSendable = "NOT_SENDABLE",
|
||||
SendFailed = "SEND_FAILED",
|
||||
ReactionFailed = "REACTION_FAILED",
|
||||
VoiceFailed = "VOICE_FAILED",
|
||||
DmUnavailable = "DM_UNAVAILABLE",
|
||||
NotSupported = "NOT_SUPPORTED",
|
||||
RateLimited = "RATE_LIMITED",
|
||||
|
||||
Reference in New Issue
Block a user