This commit is contained in:
GW_MC
2026-01-23 16:18:35 +08:00
parent 3120b93f60
commit 4e246637fb
5 changed files with 185 additions and 135 deletions

24
src/config.ts Normal file
View File

@@ -0,0 +1,24 @@
import { config as dotenvConfig } from 'dotenv';
dotenvConfig();
export const CONFIG: {
CLIENT_ID: string;
CLIENT_SECRET: string;
REDIRECT_URI: string;
ESP_IP: string;
ESP_PORT: number;
LISTEN_PORT: number;
} = {
CLIENT_ID: process.env.CLIENT_ID ?? '',
CLIENT_SECRET: process.env.CLIENT_SECRET ?? '',
REDIRECT_URI: process.env.REDIRECT_URI || 'http://localhost',
ESP_IP: process.env.ESP_IP || '192.168.50.201',
ESP_PORT: Number(process.env.ESP_PORT) || 4210,
LISTEN_PORT: Number(process.env.LISTEN_PORT) || 4211,
};
if (!CONFIG.CLIENT_ID || !CONFIG.CLIENT_SECRET) {
console.error('Error: CLIENT_ID and CLIENT_SECRET must be set in the environment variables.');
process.exit(1);
}

92
src/discordService.ts Normal file
View File

@@ -0,0 +1,92 @@
import * as RPC from 'discord-rpc';
import axios from 'axios';
import { CONFIG } from './config';
import { VoiceSettingsPayload } from './types';
class DiscordService {
private client: RPC.Client;
private loggedIn = false;
private isLoginInProgress = false;
private voiceUpdateCallbacks: Array<(d: VoiceSettingsPayload) => void> = [];
constructor() {
this.client = new RPC.Client({ transport: 'ipc' });
this.setupEvents();
}
private setupEvents() {
this.client.on('ready', () => {
console.log(`Logged in as ${this.client.user?.username || 'Unknown User'}`);
this.client.subscribe('VOICE_SETTINGS_UPDATE', undefined);
this.client
.getVoiceSettings()
.then((settings) => {
if (settings) {
this.callVoiceCallbacks({ mute: settings.mute, deaf: settings.deaf });
} else {
console.warn('Could not fetch initial voice settings.');
}
})
.catch(console.error);
});
this.client.on('VOICE_SETTINGS_UPDATE', (data: VoiceSettingsPayload) => {
this.callVoiceCallbacks(data);
});
this.client.on('disconnected', () => {
console.warn('Disconnected from Discord RPC.');
this.loggedIn = false;
if (this.isLoginInProgress) return;
this.isLoginInProgress = true;
this.login().finally(() => (this.isLoginInProgress = false));
});
this.client.on('error', (err) => console.error('Discord RPC Error:', err));
}
private callVoiceCallbacks(data: VoiceSettingsPayload) {
for (const cb of this.voiceUpdateCallbacks) cb(data);
}
public onVoiceSettingsUpdate(cb: (d: VoiceSettingsPayload) => void) {
this.voiceUpdateCallbacks.push(cb);
}
public async login() {
if (this.loggedIn) return;
try {
console.log('Waiting for authorization (Check Discord popup)...');
this.isLoginInProgress = true;
await this.client.login({
clientId: CONFIG.CLIENT_ID,
clientSecret: CONFIG.CLIENT_SECRET,
scopes: ['rpc', 'rpc.voice.read', 'rpc.voice.write'],
redirectUri: CONFIG.REDIRECT_URI,
});
this.loggedIn = true;
console.log('Authenticated successfully with Discord!');
} catch (error: any) {
if (axios.isAxiosError(error)) {
console.error('Token Exchange Failed:', error.response?.data || error.message);
} else {
console.error('Authentication Failed:', error);
}
} finally {
this.isLoginInProgress = false;
}
}
public isLoggedIn() {
return this.loggedIn;
}
public getClient() {
return this.client;
}
}
export const discordService = new DiscordService();

View File

@@ -1,105 +1,59 @@
import * as RPC from 'discord-rpc';
import * as dgram from 'dgram';
import axios from 'axios';
import { config } from 'dotenv';
import { EspCommand, VoiceState } from './types';
import { startUdpServer, sendToEsp } from './udpBridge';
import { discordService } from './discordService';
config();
// --- CONFIGURATION ---
const CONFIG = {
CLIENT_ID: process.env.CLIENT_ID || 'YOUR_CLIENT_ID', // From Discord Dev Portal
CLIENT_SECRET: process.env.CLIENT_SECRET || 'YOUR_CLIENT_SECRET', // From Discord Dev Portal
REDIRECT_URI: process.env.REDIRECT_URI || 'http://localhost',
ESP_IP: process.env.ESP_IP || '192.168.50.201', // IP of your ESP32
ESP_PORT: 4210, // Port ESP32 listens on
LISTEN_PORT: 4211, // Port this script listens on
};
// --- TYPES ---
enum VoiceState {
MUTED = 'MUTED',
UNMUTED = 'UNMUTED',
}
enum EspCommand {
TOGGLE = 'TOGGLE',
STATUS = 'STATUS',
MUTE = 'MUTE',
UNMUTE = 'UNMUTE',
}
// --- UDP SERVER SETUP ---
const udpServer = dgram.createSocket('udp4');
udpServer.on('error', (err) => {
console.error(`UDP server error:\n${err.stack}`);
udpServer.close();
});
// Helper to send messages to ESP32
const sendToEsp = (msg: string): void => {
const message = Buffer.from(msg);
udpServer.send(message, CONFIG.ESP_PORT, CONFIG.ESP_IP, (err) => {
if (err) {
console.error(`UDP Send Error to ${CONFIG.ESP_IP}:`, err);
} else {
console.log(`Sent to ESP32: ${msg}`);
}
});
};
// --- DISCORD RPC SETUP ---
const client = new RPC.Client({ transport: 'ipc' });
let loggedIn = false;
// Handle incoming UDP messages from ESP32
udpServer.on('message', async (msg, rinfo) => {
startUdpServer(async (msg) => {
const command = msg.toString().trim();
console.log(`Received from ESP32: ${command}`);
if (!discordService) return;
if (command === EspCommand.TOGGLE) {
if (!loggedIn) {
if (!discordService.isLoggedIn()) {
console.warn('Cannot toggle: Not authenticated yet.');
return;
}
try {
// Fetch current state to toggle accurately
const settings = await client.getVoiceSettings();
const client = discordService.getClient();
const settings: any = await client.getVoiceSettings();
const currentMute = settings.mute;
const { output, input, mode, ...restSettings } = settings;
await client.setVoiceSettings({
...restSettings, // Preserve other settings
mute: settings.deaf || !currentMute, // if deaf is true, mute should stay true, otherwise nothing changes
...restSettings,
mute: settings.deaf || !currentMute,
});
console.log(`Toggled mute to: ${!currentMute}`);
} catch (err) {
console.error('Error toggling mute:', err);
}
} else if (command === EspCommand.MUTE || command === EspCommand.UNMUTE) {
if (!loggedIn) {
if (!discordService.isLoggedIn()) {
console.warn('Cannot set mute: Not authenticated yet.');
return;
}
try {
const muteState = command === EspCommand.MUTE;
const settings = await client.getVoiceSettings();
const client = discordService.getClient();
const settings: any = await client.getVoiceSettings();
const { output, input, mode, ...restSettings } = settings;
await client.setVoiceSettings({
...restSettings, // Preserve other settings
mute: muteState || settings.deaf, // if deaf is true, mute should stay true
...restSettings,
mute: muteState || settings.deaf,
});
console.log(`Set mute to: ${muteState}`);
} catch (err) {
console.error('Error setting mute state:', err);
}
} else if (command === EspCommand.STATUS) {
if (!loggedIn) {
if (!discordService.isLoggedIn()) {
console.warn('Cannot fetch status: Not authenticated yet.');
return;
}
try {
const settings = await client.getVoiceSettings();
const client = discordService.getClient();
const settings: any = await client.getVoiceSettings();
sendToEsp(settings.mute ? VoiceState.MUTED : VoiceState.UNMUTED);
} catch (err) {
console.error('Error fetching voice settings:', err);
@@ -109,76 +63,10 @@ udpServer.on('message', async (msg, rinfo) => {
}
});
udpServer.bind(CONFIG.LISTEN_PORT, () => {
console.log(`UDP Bridge listening on port ${CONFIG.LISTEN_PORT}`);
});
// --- DISCORD EVENTS ---
client.on('ready', () => {
console.log(`Logged in as ${client.user?.username || 'Unknown User'}`);
// Subscribe to voice setting changes
client.subscribe('VOICE_SETTINGS_UPDATE', {});
// Initial fetch to sync ESP32 state on startup
client
.getVoiceSettings()
.then((settings) => {
if (settings) {
sendToEsp(settings.mute ? VoiceState.MUTED : VoiceState.UNMUTED);
} else {
console.warn('Could not fetch initial voice settings.');
}
})
.catch(console.error);
});
client.on('VOICE_SETTINGS_UPDATE', (data: { mute: boolean; deaf: boolean }) => {
discordService.onVoiceSettingsUpdate((data) => {
console.log(`Voice settings updated. Mute: ${data.mute}, Deaf: ${data.deaf}`);
sendToEsp(data.mute ? VoiceState.MUTED : VoiceState.UNMUTED);
});
let isLoginInProgress = false;
client.on('disconnected', () => {
console.warn('Disconnected from Discord RPC.');
loggedIn = false;
if (isLoginInProgress) return; // Prevent multiple login attempts
isLoginInProgress = true;
login().finally(() => {
isLoginInProgress = false;
}); // Attempt to re-login
});
client.on('error', (error) => {
console.error('Discord RPC Error:', error);
});
// --- AUTHENTICATION FLOW ---
async function login() {
try {
console.log('Waiting for authorization (Check Discord popup)...');
// 1. Authorize (Popup)
// RPC.Client.authorize returns a Promise that resolves to { code }
await client.login({
clientId: CONFIG.CLIENT_ID,
clientSecret: CONFIG.CLIENT_SECRET,
scopes: ['rpc', 'rpc.voice.read', 'rpc.voice.write'],
redirectUri: CONFIG.REDIRECT_URI,
});
loggedIn = true;
console.log('Authenticated successfully with Discord!');
} catch (error: any) {
if (axios.isAxiosError(error)) {
console.error('Token Exchange Failed:', error.response?.data || error.message);
} else {
console.error('Authentication Failed:', error);
}
}
}
// Start the flow
login();
// Start auth flow
discordService.login();

16
src/types.ts Normal file
View File

@@ -0,0 +1,16 @@
export enum VoiceState {
MUTED = 'MUTED',
UNMUTED = 'UNMUTED',
}
export enum EspCommand {
TOGGLE = 'TOGGLE',
STATUS = 'STATUS',
MUTE = 'MUTE',
UNMUTE = 'UNMUTE',
}
export type VoiceSettingsPayload = {
mute: boolean;
deaf?: boolean;
};

30
src/udpBridge.ts Normal file
View File

@@ -0,0 +1,30 @@
import * as dgram from 'dgram';
import { CONFIG } from './config';
import { VoiceState } from './types';
const udpServer = dgram.createSocket('udp4');
udpServer.on('error', (err) => {
console.error(`UDP server error:\n${err.stack}`);
udpServer.close();
});
export const startUdpServer = (onMessage: (msg: Buffer, rinfo: dgram.RemoteInfo) => void) => {
udpServer.on('message', onMessage);
udpServer.bind(CONFIG.LISTEN_PORT, () => {
console.log(`UDP Bridge listening on port ${CONFIG.LISTEN_PORT}`);
});
};
export const sendToEsp = (msg: string): void => {
const message = Buffer.from(msg);
udpServer.send(message, CONFIG.ESP_PORT, CONFIG.ESP_IP, (err) => {
if (err) {
console.error(`UDP Send Error to ${CONFIG.ESP_IP}:`, err);
} else {
console.log(`Sent to ESP32: ${msg}`);
}
});
};
export { dgram };