312 lines
13 KiB
Python
Executable File
312 lines
13 KiB
Python
Executable File
# discord_tools/scripts/voice_call_keeper.py
|
|
import os
|
|
import sys
|
|
import asyncio
|
|
import websockets
|
|
import json
|
|
from datetime import datetime
|
|
|
|
# Add the parent directory to the Python path
|
|
script_dir = os.path.dirname(os.path.abspath(__file__))
|
|
project_root = os.path.dirname(os.path.dirname(script_dir))
|
|
sys.path.insert(0, project_root)
|
|
|
|
from discord_tools.config.settings import DISCORD_TOKEN
|
|
from discord_tools.utils.api_utils import make_discord_request
|
|
|
|
class VoiceCallKeeper:
|
|
def __init__(self):
|
|
self.active = True
|
|
self.current_channel_id = None
|
|
self.current_guild_id = None
|
|
self.session_id = None
|
|
self.token = None
|
|
self.endpoint = None
|
|
self.my_user_id = None
|
|
self.start_time = None
|
|
self.total_reconnects = 0
|
|
|
|
async def find_current_voice_channel(self, ws):
|
|
# Wait for READY and get current voice state
|
|
ready = False
|
|
current_voice = None
|
|
|
|
while not ready:
|
|
msg = json.loads(await ws.recv())
|
|
|
|
if msg.get('t') == 'READY':
|
|
ready_data = msg['d']
|
|
self.my_user_id = ready_data['user']['id']
|
|
|
|
# Check private calls
|
|
private_calls = ready_data.get('private_calls', [])
|
|
for call in private_calls:
|
|
# Check if we're in this call
|
|
voice_states = call.get('voice_states', [])
|
|
for vs in voice_states:
|
|
if vs.get('user_id') == self.my_user_id:
|
|
current_voice = {
|
|
'channel_id': call['channel_id'],
|
|
'guild_id': None,
|
|
'type': 'DM Call'
|
|
}
|
|
break
|
|
if current_voice:
|
|
break
|
|
|
|
# Check guild voice states
|
|
if not current_voice:
|
|
guilds = ready_data.get('guilds', [])
|
|
for guild in guilds:
|
|
voice_states = guild.get('voice_states', [])
|
|
for vs in voice_states:
|
|
if vs.get('user_id') == self.my_user_id and vs.get('channel_id'):
|
|
current_voice = {
|
|
'channel_id': vs['channel_id'],
|
|
'guild_id': guild['id'],
|
|
'type': 'Server VC'
|
|
}
|
|
break
|
|
if current_voice:
|
|
break
|
|
|
|
ready = True
|
|
|
|
return current_voice
|
|
|
|
async def maintain_voice_connection(self, channel_id=None, guild_id=None, auto_join=False):
|
|
# Connect to gateway and maintain voice connection with auto-reconnect
|
|
gateway_url = "wss://gateway.discord.gg/?v=9&encoding=json"
|
|
|
|
# Set start time on first connection
|
|
if self.start_time is None:
|
|
self.start_time = datetime.now()
|
|
|
|
while self.active:
|
|
try:
|
|
await self._connect_and_run(gateway_url, channel_id, guild_id, auto_join)
|
|
except (websockets.exceptions.ConnectionClosed,
|
|
websockets.exceptions.ConnectionClosedOK,
|
|
websockets.exceptions.ConnectionClosedError) as e:
|
|
if self.active:
|
|
self.total_reconnects += 1
|
|
elapsed = (datetime.now() - self.start_time).total_seconds()
|
|
print(f"\n[!] Connection lost after {elapsed:.0f}s: {e.reason if hasattr(e, 'reason') else str(e)}")
|
|
print(f"[!] Reconnecting... (attempt #{self.total_reconnects})")
|
|
await asyncio.sleep(3)
|
|
# After reconnect, don't auto-join again, use stored IDs
|
|
auto_join = False
|
|
else:
|
|
break
|
|
except KeyboardInterrupt:
|
|
print("\n\nDisconnecting...")
|
|
self.active = False
|
|
break
|
|
except Exception as e:
|
|
if self.active:
|
|
self.total_reconnects += 1
|
|
print(f"\n[!] Unexpected error: {e}")
|
|
print(f"[!] Reconnecting... (attempt #{self.total_reconnects})")
|
|
await asyncio.sleep(3)
|
|
auto_join = False
|
|
else:
|
|
break
|
|
|
|
async def _connect_and_run(self, gateway_url, channel_id, guild_id, auto_join):
|
|
async with websockets.connect(gateway_url, max_size=16 * 1024 * 1024) as ws:
|
|
# Receive Hello
|
|
hello = json.loads(await ws.recv())
|
|
heartbeat_interval = hello['d']['heartbeat_interval']
|
|
|
|
# Send Identify
|
|
identify = {
|
|
"op": 2,
|
|
"d": {
|
|
"token": DISCORD_TOKEN,
|
|
"properties": {
|
|
"$os": "windows",
|
|
"$browser": "chrome",
|
|
"$device": "pc"
|
|
},
|
|
"compress": False
|
|
}
|
|
}
|
|
await ws.send(json.dumps(identify))
|
|
|
|
# Heartbeat task
|
|
async def heartbeat():
|
|
while self.active:
|
|
try:
|
|
await asyncio.sleep(heartbeat_interval / 1000)
|
|
if self.active:
|
|
await ws.send(json.dumps({"op": 1, "d": None}))
|
|
except Exception:
|
|
break
|
|
|
|
heartbeat_task = asyncio.create_task(heartbeat())
|
|
|
|
try:
|
|
# If auto-join, find current voice channel
|
|
if auto_join:
|
|
print("Detecting current voice channel...")
|
|
current_voice = await self.find_current_voice_channel(ws)
|
|
|
|
if not current_voice:
|
|
print("Not currently in any voice channel/call")
|
|
return
|
|
|
|
channel_id = current_voice['channel_id']
|
|
guild_id = current_voice['guild_id']
|
|
print(f"Found: {current_voice['type']}")
|
|
|
|
# Store for reconnections
|
|
self.current_channel_id = channel_id
|
|
self.current_guild_id = guild_id
|
|
else:
|
|
# Wait for READY
|
|
ready = False
|
|
while not ready:
|
|
msg = json.loads(await ws.recv())
|
|
if msg.get('t') == 'READY':
|
|
ready = True
|
|
self.my_user_id = msg['d']['user']['id']
|
|
|
|
if self.total_reconnects == 0:
|
|
print("Connected to Gateway")
|
|
else:
|
|
elapsed = (datetime.now() - self.start_time).total_seconds()
|
|
print(f"[✓] Reconnected successfully (total uptime: {elapsed:.0f}s)")
|
|
|
|
# Use stored or provided IDs
|
|
if self.current_channel_id is None:
|
|
self.current_channel_id = channel_id
|
|
self.current_guild_id = guild_id
|
|
|
|
# Join/maintain voice channel/call
|
|
if self.total_reconnects == 0:
|
|
print(f"Maintaining voice connection...")
|
|
print("Call keeper active - Press Ctrl+C to disconnect")
|
|
print("=" * 60)
|
|
|
|
voice_state_update = {
|
|
"op": 4,
|
|
"d": {
|
|
"guild_id": self.current_guild_id,
|
|
"channel_id": self.current_channel_id,
|
|
"self_mute": False,
|
|
"self_deaf": False
|
|
}
|
|
}
|
|
await ws.send(json.dumps(voice_state_update))
|
|
|
|
# Monitor voice state and keep connection alive
|
|
while self.active:
|
|
msg = await ws.recv()
|
|
data = json.loads(msg)
|
|
|
|
event_type = data.get('t')
|
|
|
|
# Track voice state changes
|
|
if event_type == 'VOICE_STATE_UPDATE':
|
|
voice_data = data['d']
|
|
# Check if it's related to our channel
|
|
if voice_data.get('channel_id') == self.current_channel_id or voice_data.get('user_id') == self.my_user_id:
|
|
user_id = voice_data.get('user_id')
|
|
member = voice_data.get('member', {})
|
|
user = member.get('user', {})
|
|
username = user.get('username', 'Unknown')
|
|
|
|
if voice_data.get('channel_id') is None and user_id != self.my_user_id:
|
|
print(f"[-] {username} left")
|
|
elif voice_data.get('channel_id') == self.current_channel_id and user_id != self.my_user_id:
|
|
elapsed = (datetime.now() - self.start_time).total_seconds()
|
|
print(f"[+] {username} joined (running {elapsed:.0f}s)")
|
|
|
|
# Voice server update
|
|
elif event_type == 'VOICE_SERVER_UPDATE':
|
|
self.token = data['d'].get('token')
|
|
self.endpoint = data['d'].get('endpoint')
|
|
if self.total_reconnects == 0:
|
|
print(f"[✓] Connection established")
|
|
else:
|
|
print(f"[✓] Voice connection re-established")
|
|
|
|
elif event_type == 'CALL_CREATE':
|
|
print("[📞] Call created")
|
|
|
|
elif event_type == 'CALL_UPDATE':
|
|
ringing = data['d'].get('ringing', [])
|
|
if ringing:
|
|
print(f"[📞] Ringing: {len(ringing)} user(s)")
|
|
|
|
finally:
|
|
heartbeat_task.cancel()
|
|
|
|
# Only leave voice if we're truly shutting down
|
|
if not self.active:
|
|
try:
|
|
leave_voice = {
|
|
"op": 4,
|
|
"d": {
|
|
"guild_id": self.current_guild_id,
|
|
"channel_id": None,
|
|
"self_mute": False,
|
|
"self_deaf": False
|
|
}
|
|
}
|
|
await ws.send(json.dumps(leave_voice))
|
|
except Exception:
|
|
pass
|
|
|
|
# Stats
|
|
total_time = (datetime.now() - self.start_time).total_seconds()
|
|
hours = total_time / 3600
|
|
minutes = (total_time % 3600) / 60
|
|
|
|
print("\n" + "=" * 60)
|
|
print(f"Duration: {int(hours)}h {int(minutes)}m")
|
|
print(f"Total reconnections: {self.total_reconnects}")
|
|
print("=" * 60)
|
|
|
|
def main():
|
|
print("Voice Call Keeper")
|
|
print("=" * 60)
|
|
|
|
print("1. Auto-join current call/VC")
|
|
print("2. Join specific channel")
|
|
print("=" * 60)
|
|
|
|
choice = input("Choice: ").strip()
|
|
|
|
if choice == '1':
|
|
# Auto-join current voice
|
|
keeper = VoiceCallKeeper()
|
|
asyncio.run(keeper.maintain_voice_connection(auto_join=True))
|
|
|
|
elif choice == '2':
|
|
# Manual join
|
|
channel_id = input("\nChannel ID: ").strip()
|
|
|
|
if not channel_id.isdigit():
|
|
print("Invalid channel ID")
|
|
return
|
|
|
|
# Ask if it's a server VC
|
|
is_server = input("Server VC? (y/n): ").strip().lower()
|
|
|
|
guild_id = None
|
|
if is_server == 'y':
|
|
guild_id = input("Guild ID: ").strip()
|
|
if not guild_id.isdigit():
|
|
print("Invalid guild ID")
|
|
return
|
|
|
|
keeper = VoiceCallKeeper()
|
|
asyncio.run(keeper.maintain_voice_connection(channel_id, guild_id=guild_id))
|
|
|
|
else:
|
|
print("Invalid choice")
|
|
|
|
if __name__ == "__main__":
|
|
main()
|