Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 9 additions & 10 deletions actions/ChangeVoiceChannel.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,26 +21,25 @@ def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.has_configuration = True
self._current_channel: str = ""
self.icon_keys = [Icons.VOICE_CHANNEL_ACTIVE,
Icons.VOICE_CHANNEL_INACTIVE]
self.icon_keys = [Icons.VOICE_CHANNEL_ACTIVE, Icons.VOICE_CHANNEL_INACTIVE]
self.current_icon = self.get_icon(Icons.VOICE_CHANNEL_INACTIVE)
self.icon_name = Icons.VOICE_CHANNEL_INACTIVE

def on_ready(self):
super().on_ready()
self.plugin_base.add_callback(
VOICE_CHANNEL_SELECT, self._update_display)
self.backend.register_callback(
VOICE_CHANNEL_SELECT, self._update_display)
self.register_backend_callback(VOICE_CHANNEL_SELECT, self._update_display)

def _update_display(self, value: dict):
if not self.backend:
self.show_error()
return
self.hide_error()
self._current_channel = value.get(
"channel_id", None) if value else None
self.icon_name = Icons.VOICE_CHANNEL_INACTIVE if self._current_channel is None else Icons.VOICE_CHANNEL_ACTIVE
self._current_channel = value.get("channel_id", None) if value else None
self.icon_name = (
Icons.VOICE_CHANNEL_INACTIVE
if self._current_channel is None
else Icons.VOICE_CHANNEL_ACTIVE
)
self.current_icon = self.get_icon(self.icon_name)
self.display_icon()

Expand All @@ -63,7 +62,7 @@ def create_event_assigners(self):
id="change-channel",
ui_label="change-channel",
default_event=Input.Key.Events.DOWN,
callback=self._on_change_channel
callback=self._on_change_channel,
)
)

Expand Down
7 changes: 2 additions & 5 deletions actions/Deafen.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,18 +27,15 @@ def __init__(self, *args, **kwargs):

def on_ready(self):
super().on_ready()
self.plugin_base.add_callback(
VOICE_SETTINGS_UPDATE, self._update_display)
self.backend.register_callback(
VOICE_SETTINGS_UPDATE, self._update_display)
self.register_backend_callback(VOICE_SETTINGS_UPDATE, self._update_display)

def create_event_assigners(self):
self.event_manager.add_event_assigner(
EventAssigner(
id="toggle-deafen",
ui_label="toggle-deafen",
default_event=Input.Key.Events.DOWN,
callback=self._on_toggle
callback=self._on_toggle,
)
)

Expand Down
34 changes: 29 additions & 5 deletions actions/DiscordCore.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,10 @@ def __init__(self, *args, **kwargs):
self.current_color: Color = None
self.icon_name: str = ""
self.color_name: str = ""
self.backend: 'Backend' = self.plugin_base.backend
self.backend: "Backend" = self.plugin_base.backend

# Track registered callbacks for cleanup
self._registered_callbacks: list[tuple[str, callable]] = []

self.plugin_base.asset_manager.icons.add_listener(self._icon_changed)
self.plugin_base.asset_manager.colors.add_listener(self._color_changed)
Expand All @@ -40,14 +43,33 @@ def create_generative_ui(self):
def create_event_assigners(self):
pass

def register_backend_callback(self, key: str, callback: callable):
"""Register a callback and track it for cleanup."""
self.backend.register_callback(key, callback)
self._registered_callbacks.append((key, callback))

def cleanup_callbacks(self):
"""Unregister all tracked callbacks to prevent memory leaks."""
for key, callback in self._registered_callbacks:
self.backend.unregister_callback(key, callback)
self._registered_callbacks.clear()

def __del__(self):
"""Clean up callbacks when action is destroyed."""
try:
self.cleanup_callbacks()
except (AttributeError, RuntimeError):
# Object may be partially initialized or backend already destroyed
pass

def display_icon(self):
if not self.current_icon:
return
_, rendered = self.current_icon.get_values()
if rendered:
self.set_media(image=rendered)

async def _icon_changed(self, event: str, key: str, asset: Icon):
def _icon_changed(self, event: str, key: str, asset: Icon):
if not key in self.icon_keys:
return
if key != self.icon_name:
Expand All @@ -62,12 +84,14 @@ def display_color(self):
color = self.current_color.get_values()
try:
self.set_background_color(color)
except:
except (RuntimeError, AttributeError) as ex:
# Sometimes we try to call this too early, and it leads to
# console errors, but no real impact. Ignoring this for now
pass
log.debug(
f"Failed to set background color (action may not be ready yet): {ex}"
)

async def _color_changed(self, event: str, key: str, asset: Color):
def _color_changed(self, event: str, key: str, asset: Color):
if not key in self.color_keys:
return
if key != self.color_name:
Expand Down
11 changes: 4 additions & 7 deletions actions/Mute.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,18 +27,15 @@ def __init__(self, *args, **kwargs):

def on_ready(self):
super().on_ready()
self.plugin_base.add_callback(
VOICE_SETTINGS_UPDATE, self._update_display)
self.backend.register_callback(
VOICE_SETTINGS_UPDATE, self._update_display)
self.register_backend_callback(VOICE_SETTINGS_UPDATE, self._update_display)

def create_event_assigners(self):
self.event_manager.add_event_assigner(
EventAssigner(
id="toggle-mute",
ui_label="toggle-mute",
default_event=Input.Key.Events.DOWN,
callback=self._on_toggle
callback=self._on_toggle,
)
)

Expand All @@ -47,7 +44,7 @@ def create_event_assigners(self):
id="enable-mute",
ui_label="enable-mute",
default_event=None,
callback=self._on_mute
callback=self._on_mute,
)
)

Expand All @@ -56,7 +53,7 @@ def create_event_assigners(self):
id="disable-mute",
ui_label="disable-mute",
default_event=None,
callback=self._off_mute
callback=self._off_mute,
)
)

Expand Down
11 changes: 5 additions & 6 deletions actions/TogglePTT.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,23 +30,22 @@ def __init__(self, *args, **kwargs):

def on_ready(self):
super().on_ready()
self.plugin_base.add_callback(
VOICE_SETTINGS_UPDATE, self._update_display)
self.backend.register_callback(
VOICE_SETTINGS_UPDATE, self._update_display)
self.register_backend_callback(VOICE_SETTINGS_UPDATE, self._update_display)

def create_event_assigners(self):
self.event_manager.add_event_assigner(
EventAssigner(
id="toggle-ptt",
ui_label="toggle-ptt",
default_event=Input.Key.Events.DOWN,
callback=self._on_toggle
callback=self._on_toggle,
)
)

def _on_toggle(self, _):
new = ActivityMethod.PTT if self._mode == ActivityMethod.VA else ActivityMethod.VA
new = (
ActivityMethod.PTT if self._mode == ActivityMethod.VA else ActivityMethod.VA
)
try:
self.backend.set_push_to_talk(str(new))
except Exception as ex:
Expand Down
113 changes: 80 additions & 33 deletions backend.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ def __init__(self):
self.callbacks: dict = {}
self._is_authed: bool = False
self._current_voice_channel: str = None
self._is_reconnecting: bool = False

def discord_callback(self, code, event):
if code == 0:
Expand All @@ -27,8 +28,9 @@ def discord_callback(self, code, event):
except Exception as ex:
log.error(f"failed to parse discord event: {ex}")
return
resp_code = event.get('data').get(
'code', 0) if event.get('data') is not None else 0
resp_code = (
event.get("data").get("code", 0) if event.get("data") is not None else 0
)
if resp_code in [4006, 4009]:
if not self.refresh_token:
self.setup_client()
Expand All @@ -45,11 +47,10 @@ def discord_callback(self, code, event):
self._update_tokens(access_token, refresh_token)
self.discord_client.authenticate(self.access_token)
return
match event.get('cmd'):
match event.get("cmd"):
case commands.AUTHORIZE:
auth_code = event.get('data').get('code')
token_resp = self.discord_client.get_access_token(
auth_code)
auth_code = event.get("data").get("code")
token_resp = self.discord_client.get_access_token(auth_code)
self.access_token = token_resp.get("access_token")
self.refresh_token = token_resp.get("refresh_token")
self.discord_client.authenticate(self.access_token)
Expand All @@ -62,13 +63,15 @@ def discord_callback(self, code, event):
self.discord_client.subscribe(k)
self._get_current_voice_channel()
case commands.DISPATCH:
evt = event.get('evt')
self.frontend.handle_callback(evt, event.get('data'))
evt = event.get("evt")
self.frontend.handle_callback(evt, event.get("data"))
case commands.GET_SELECTED_VOICE_CHANNEL:
self._current_voice_channel = event.get('data').get(
'channel_id') if event.get('data') else None
self._current_voice_channel = (
event.get("data").get("channel_id") if event.get("data") else None
)
self.frontend.handle_callback(
commands.VOICE_CHANNEL_SELECT, event.get('data'))
commands.VOICE_CHANNEL_SELECT, event.get("data")
)

def _update_tokens(self, access_token: str = "", refresh_token: str = ""):
self.access_token = access_token
Expand All @@ -77,10 +80,13 @@ def _update_tokens(self, access_token: str = "", refresh_token: str = ""):
self.frontend.save_refresh_token(refresh_token)

def setup_client(self):
if self._is_reconnecting:
log.debug("Already reconnecting, skipping duplicate attempt")
return
try:
self._is_reconnecting = True
log.debug("new client")
self.discord_client = AsyncDiscord(
self.client_id, self.client_secret)
self.discord_client = AsyncDiscord(self.client_id, self.client_secret)
log.debug("connect")
self.discord_client.connect(self.discord_callback)
if not self.access_token:
Expand All @@ -95,11 +101,20 @@ def setup_client(self):
if self.discord_client:
self.discord_client.disconnect()
self.discord_client = None

def update_client_credentials(self, client_id: str, client_secret: str, access_token: str = "", refresh_token: str = ""):
finally:
self._is_reconnecting = False

def update_client_credentials(
self,
client_id: str,
client_secret: str,
access_token: str = "",
refresh_token: str = "",
):
if None in (client_id, client_secret) or "" in (client_id, client_secret):
self.frontend.on_auth_callback(
False, "actions.base.credentials.missing_client_info")
False, "actions.base.credentials.missing_client_info"
)
return
self.client_id = client_id
self.client_secret = client_secret
Expand All @@ -112,43 +127,75 @@ def is_authed(self) -> bool:

def register_callback(self, key: str, callback: callable):
callbacks = self.callbacks.get(key, [])
callbacks.append(callback)
self.callbacks[key] = callbacks
# Deduplicate callbacks to prevent multiple executions
if callback not in callbacks:
callbacks.append(callback)
self.callbacks[key] = callbacks
if self._is_authed:
self.discord_client.subscribe(key)

def set_mute(self, muted: bool):
def unregister_callback(self, key: str, callback: callable):
"""Remove a callback from the callback list."""
callbacks = self.callbacks.get(key, [])
if callback in callbacks:
callbacks.remove(callback)
if callbacks:
self.callbacks[key] = callbacks
else:
# Remove key entirely if no callbacks remain
del self.callbacks[key]

def _ensure_connected(self) -> bool:
"""Ensure client is connected, trigger reconnection if needed."""
if self.discord_client is None or not self.discord_client.is_connected():
self.setup_client()
self.discord_client.set_voice_settings({'mute': muted})
if not self._is_reconnecting:
self.setup_client()
return False
return True

def set_mute(self, muted: bool):
if not self._ensure_connected():
log.warning("Discord client not connected, cannot set mute")
return
self.discord_client.set_voice_settings({"mute": muted})

def set_deafen(self, muted: bool):
if self.discord_client is None or not self.discord_client.is_connected():
self.setup_client()
self.discord_client.set_voice_settings({'deaf': muted})
if not self._ensure_connected():
log.warning("Discord client not connected, cannot set deafen")
return
self.discord_client.set_voice_settings({"deaf": muted})

def change_voice_channel(self, channel_id: str = None) -> bool:
if self.discord_client is None or not self.discord_client.is_connected():
self.setup_client()
if not self._ensure_connected():
log.warning("Discord client not connected, cannot change voice channel")
return False
self.discord_client.select_voice_channel(channel_id, True)
return True

def change_text_channel(self, channel_id: str) -> bool:
if self.discord_client is None or not self.discord_client.is_connected():
self.setup_client()
if not self._ensure_connected():
log.warning("Discord client not connected, cannot change text channel")
return False
self.discord_client.select_text_channel(channel_id)
return True

def set_push_to_talk(self, ptt: str) -> bool:
if self.discord_client is None or not self.discord_client.is_connected():
self.setup_client()
self.discord_client.set_voice_settings({'mode': {"type": ptt}})
if not self._ensure_connected():
log.warning("Discord client not connected, cannot set push to talk")
return False
self.discord_client.set_voice_settings({"mode": {"type": ptt}})
return True

@property
def current_voice_channel(self):
return self._current_voice_channel

def _get_current_voice_channel(self):
if self.discord_client is None or not self.discord_client.is_connected():
self.setup_client()
if not self._ensure_connected():
log.warning(
"Discord client not connected, cannot get current voice channel"
)
return
self.discord_client.get_selected_voice_channel()

def close(self):
Expand Down
Loading