linkgravity 1.3.0 → 1.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/main.py CHANGED
@@ -1,242 +1,30 @@
1
- import asyncio
2
- import atexit
3
- import os
4
- import subprocess
5
- import sys
6
- from functools import partial
1
+ """Top-level entrypoint. Starts whichever platforms are enabled
2
+ (main_discord.run_discord / main_telegram.run_telegram) concurrently in
3
+ one process, sharing a single webhook server and exception handler."""
7
4
 
8
- import discord
9
- from discord.ext import commands
5
+ import asyncio
10
6
 
11
7
  from api import server
12
- from config import (
13
- DISCORD_TOKEN,
14
- SESSION_SCOPES,
15
- logger,
16
- session_manager,
17
- )
18
- from handlers.message_router import handle_message
19
- from services.response import send_agy_response
20
- from services.streaming import stream_thinking_latest
21
- from utils.utils import (
22
- agy_new_conversation,
23
- agy_send_message,
24
- stt,
25
- tts,
26
- )
27
-
28
- voice_process = None
29
- _voice_shutting_down = False
30
- _voice_restart_timestamps = [] # epoch seconds of recent auto-restarts, for the backoff/giveup check below
31
-
32
- VOICE_SERVICE_HEALTH_URL = "http://localhost:18081/health"
33
- VOICE_SERVICE_READY_TIMEOUT_SEC = 20
34
-
35
-
36
- async def _wait_for_voice_service_ready():
37
- import aiohttp
38
-
39
- deadline = asyncio.get_event_loop().time() + VOICE_SERVICE_READY_TIMEOUT_SEC
40
- async with aiohttp.ClientSession() as session:
41
- while asyncio.get_event_loop().time() < deadline:
42
- try:
43
- async with session.get(VOICE_SERVICE_HEALTH_URL, timeout=aiohttp.ClientTimeout(total=2)) as resp:
44
- if resp.status == 200:
45
- data = await resp.json()
46
- if data.get("ready"):
47
- logger.info("🎤 Voice service is online and ready.")
48
- return
49
- except Exception:
50
- pass
51
- await asyncio.sleep(0.5)
52
- logger.warning(
53
- f"Voice service did not report ready within {VOICE_SERVICE_READY_TIMEOUT_SEC}s - "
54
- "/join may fail until it finishes starting."
55
- )
56
-
57
-
58
- STATUS_TEXT_BY_KEYWORDS = [
59
- (("run_command",), "🖥️ Running command..."),
60
- (("file", "list"), "🔍 Analyzing files..."),
61
- (("search", "web"), "🌐 Searching web..."),
62
- (("replace", "write"), "✍️ Writing code..."),
63
- (("ask_question",), "❓ Waiting for input..."),
64
- ]
65
-
66
-
67
- def _status_text_for_tool(tool_name: str) -> str:
68
- for keywords, text in STATUS_TEXT_BY_KEYWORDS:
69
- if any(keyword in tool_name for keyword in keywords):
70
- return text
71
- return f"⚙️ Running {tool_name}..."
72
-
73
-
74
- def _voice_status_text() -> str | None:
75
- """None if no guild is connected to voice right now. Otherwise reflects
76
- whether any connected guild is in its post-wake-word "awake" window -
77
- filling the gap between Idle and an active text session, since being
78
- connected to voice and waiting for a wake word isn't really "Idle"."""
79
- voice_cog = bot.get_cog("VoiceCog")
80
- if not voice_cog or not voice_cog._voice_state:
81
- return None
82
- if any(voice_cog.stt_session.is_active(guild_id) for guild_id in voice_cog._voice_state):
83
- return "👂 Awake"
84
- return "💤 Asleep"
85
-
86
-
87
- intents = discord.Intents.default()
88
- intents.message_content = True
89
- intents.voice_states = True
90
- bot = commands.Bot(command_prefix="!", intents=intents)
91
-
92
-
93
- async def status_updater_task():
94
- await bot.wait_until_ready()
95
- logger.debug("status_updater_task started")
96
- try:
97
- last_status = ""
98
- while True:
99
- await asyncio.sleep(2)
100
-
101
- full_status = ""
102
- if not session_manager.has_active_queues():
103
- full_status = _voice_status_text() or "🟢 Idle"
104
- else:
105
- first_t_id = session_manager.get_active_queue_keys()[0]
106
- sess = session_manager.get_session(first_t_id) or {}
107
- pending_tool = sess.get("pending_approval_tool")
108
- tool_name = sess.get("current_tool")
109
- if pending_tool:
110
- full_status = "⏳ Waiting for approval..."
111
- elif tool_name:
112
- full_status = _status_text_for_tool(tool_name)
113
- else:
114
- full_status = "🧠 Thinking..."
115
-
116
- if full_status != last_status:
117
- logger.debug(f"Status updating to: {full_status}")
118
- try:
119
- await bot.change_presence(
120
- status=discord.Status.online,
121
- activity=discord.Activity(type=discord.ActivityType.playing, name=full_status),
122
- )
123
- last_status = full_status
124
- except discord.HTTPException as e:
125
- logger.warning(f"Presence update rate-limited or failed: {e}")
126
- except Exception as e:
127
- logger.warning(f"Presence update error: {e}")
128
- except asyncio.CancelledError:
129
- logger.debug("Status updater task was cancelled.")
130
- except Exception as e:
131
- logger.exception(f"FATAL ERROR IN STATUS UPDATER: {e}")
132
-
133
-
134
- def _spawn_voice_process(voice_dir: str) -> subprocess.Popen:
135
- return subprocess.Popen(["node", "index.js"], cwd=voice_dir)
136
-
137
-
138
- async def _supervise_voice_process(voice_dir: str):
139
- """voice_process (voice-service/index.js) can die on its own - most
140
- notably from the known upstream @discordjs/voice DAVE decrypt bug
141
- (discordjs/discord.js#11419), which can throw synchronously out of
142
- an event handler with nothing upstream to catch it. Without this,
143
- that single crash would leave every voice feature (wake word, STT,
144
- TTS) dead until someone manually restarts the whole bot.
145
-
146
- Polls every 1s so a crash gets noticed and a restart kicked off
147
- almost immediately - the "5 crashes in 5 minutes" check below is
148
- NOT a claim that 5 minutes of intermittent breakage is acceptable;
149
- it only exists to stop a genuine crash-loop (missing node_modules,
150
- a port conflict, etc.) from burning CPU forever. Anyone mid-
151
- recording when Node dies gets told immediately via
152
- VoiceCog.handle_voice_service_down(), rather than finding out
153
- whenever a stale request to Node eventually times out.
154
- """
155
- global voice_process
156
- while True:
157
- await asyncio.sleep(1)
158
- if _voice_shutting_down:
159
- return
160
- if voice_process is None or voice_process.poll() is None:
161
- continue # still running (or never started) - nothing to do
162
-
163
- exit_code = voice_process.poll()
164
- logger.warning(f"🎤 Voice service exited unexpectedly (code {exit_code}). Attempting to restart it...")
165
-
166
- # Notify anyone mid-recording before attempting the restart, not after.
167
- voice_cog = bot.get_cog("VoiceCog")
168
- if voice_cog:
169
- try:
170
- await voice_cog.handle_voice_service_down()
171
- except Exception as e:
172
- logger.error(f"Error notifying users of voice service outage: {e}")
173
-
174
- now = asyncio.get_event_loop().time()
175
- _voice_restart_timestamps.append(now)
176
- while _voice_restart_timestamps and now - _voice_restart_timestamps[0] > 300:
177
- _voice_restart_timestamps.pop(0)
8
+ from config import DISCORD_TOKEN, SESSION_SCOPES, bot_settings, logger, session_manager
178
9
 
179
- if len(_voice_restart_timestamps) > 5:
180
- logger.error(
181
- "🎤 Voice service has crashed 5+ times in the last 5 minutes - giving up on auto-restart "
182
- "to avoid a crash loop. Check `lgy logs` for the underlying error, fix it, then run "
183
- "`lgy restart`."
184
- )
185
- return
186
10
 
187
- try:
188
- voice_process = _spawn_voice_process(voice_dir)
189
- asyncio.create_task(_wait_for_voice_service_ready())
190
- except Exception as e:
191
- logger.error(f"Failed to restart voice service: {e}")
192
-
193
-
194
- @bot.event
195
- async def on_ready():
196
- logger.info(f"✅ Bot is fully online and ready! Logged in as {bot.user}")
197
-
198
-
199
- def _terminate_voice_process():
200
- global _voice_shutting_down
201
- _voice_shutting_down = True
202
- if voice_process and voice_process.poll() is None:
203
- logger.debug("Terminating child Node.js voice process...")
204
- voice_process.terminate() # Node now catches this and disconnects any active voice channel cleanly
205
- try:
206
- voice_process.wait(timeout=3)
207
- except subprocess.TimeoutExpired:
208
- voice_process.kill()
209
-
210
-
211
- @bot.event
212
- async def setup_hook():
213
- await bot.tree.sync()
214
- logger.info("Slash commands synced.")
11
+ async def main():
12
+ removed = session_manager.cleanup_stale_sessions()
13
+ if removed:
14
+ logger.info(f"Cleaned up {removed} stale session(s) from sessions.json.")
215
15
 
216
- asyncio.create_task(server.setup_webhook_server(bot))
16
+ discord_enabled = bot_settings.get("discord_enabled", bool(DISCORD_TOKEN))
17
+ telegram_enabled = bot_settings.get("telegram_enabled", False)
217
18
 
218
- global voice_process
219
- try:
220
- voice_dir = os.path.join(os.path.dirname(os.path.dirname(__file__)), "voice-service")
221
- logger.debug(f"Spawning Node.js process at: {voice_dir}")
222
- if os.path.exists(os.path.join(voice_dir, "index.js")):
223
- if not os.path.isdir(os.path.join(voice_dir, "node_modules")):
224
- logger.error(
225
- "voice-service/node_modules is missing - its dependencies were never installed "
226
- "(or got wiped, e.g. by replacing project files without re-running `npm install`). "
227
- "Voice features will not work until you run `npm install` in the project root "
228
- "and restart the bot."
229
- )
230
- voice_process = _spawn_voice_process(voice_dir)
231
- logger.info("🎤 Voice service starting, waiting for it to come online...")
232
- asyncio.create_task(_wait_for_voice_service_ready())
233
- asyncio.create_task(_supervise_voice_process(voice_dir))
19
+ if discord_enabled and not SESSION_SCOPES:
20
+ logger.warning("Discord enabled but no server/channel configured (session_scopes) - run `lgy setup`")
21
+ if discord_enabled and not DISCORD_TOKEN:
22
+ logger.critical("Discord enabled but missing DISCORD_TOKEN - run `lgy setup`")
23
+ discord_enabled = False
234
24
 
235
- atexit.register(_terminate_voice_process)
236
- else:
237
- logger.warning("Voice service (index.js) not found. Skipping auto-start.")
238
- except Exception as e:
239
- logger.error(f"Failed to auto-start voice service: {e}")
25
+ if not discord_enabled and not telegram_enabled:
26
+ logger.critical("No messenger platform is enabled - run `lgy setup`")
27
+ return
240
28
 
241
29
  def _handle_task_exception(loop, context):
242
30
  exc = context.get("exception")
@@ -247,58 +35,18 @@ async def setup_hook():
247
35
 
248
36
  asyncio.get_event_loop().set_exception_handler(_handle_task_exception)
249
37
 
250
- logger.debug("Launching status_updater_task...")
251
- asyncio.create_task(status_updater_task())
252
- logger.debug("status_updater_task dispatched")
253
-
254
-
255
- @bot.event
256
- async def on_error(event_method: str, *args, **kwargs):
257
- logger.exception(f"Unhandled exception in Discord event: {event_method}")
258
- exc_type, exc_value, _ = sys.exc_info()
259
- if exc_value is None:
260
- return
261
-
262
- error_msg = "⚠️ **An internal bot error has occurred.** Please contact the developer or check the server logs."
38
+ discord_bot = None
39
+ if discord_enabled:
40
+ from main_discord import bot as discord_bot
263
41
 
264
- if event_method == "on_message":
265
- if args and isinstance(args[0], discord.Message):
266
- message = args[0]
267
- try:
268
- await message.reply(error_msg)
269
- except Exception:
270
- pass
42
+ # One shared webhook server for every enabled platform - agy always calls this same fixed port.
43
+ asyncio.create_task(server.setup_webhook_server(discord_bot))
271
44
 
272
-
273
- @bot.event
274
- async def on_application_command_error(interaction, error):
275
- logger.exception(f"Slash command error: {error}")
276
- error_msg = "⚠️ **An error occurred while processing the command.** Please try again later or check the logs."
277
- try:
278
- if interaction.response.is_done():
279
- await interaction.followup.send(error_msg, ephemeral=True)
280
- else:
281
- await interaction.response.send_message(error_msg, ephemeral=True)
282
- except Exception:
283
- pass
284
-
285
-
286
- @bot.event
287
- async def on_message(message: discord.Message):
288
- await handle_message(bot, message)
289
-
290
-
291
- async def main():
292
- if not DISCORD_TOKEN or not SESSION_SCOPES:
293
- logger.critical("Missing DISCORD_TOKEN, or no server/channel configured (session_scopes) - run `lgy setup`")
294
- return
295
-
296
- discord.utils.setup_logging()
45
+ stop_event = asyncio.Event()
297
46
 
298
47
  def _handle_sigterm():
299
- logger.info("Received SIGTERM (lgy stop/restart) - disconnecting voice before exit...")
300
- _terminate_voice_process()
301
- asyncio.create_task(bot.close())
48
+ logger.info("Received SIGTERM (lgy stop/restart) - shutting down...")
49
+ stop_event.set()
302
50
 
303
51
  try:
304
52
  import signal
@@ -307,35 +55,17 @@ async def main():
307
55
  except NotImplementedError:
308
56
  pass # add_signal_handler isn't supported on this platform (e.g. Windows)
309
57
 
310
- from messengers.discord_adapter import DiscordAdapter
311
- from messengers.registry import set_adapter
312
-
313
- set_adapter(DiscordAdapter(bot))
314
-
315
- async with bot:
316
- from cogs.voice_cog import VoiceCog
317
- from config import bot_settings, save_bot_settings
58
+ tasks = []
59
+ if discord_enabled:
60
+ from main_discord import run_discord
318
61
 
319
- await bot.add_cog(
320
- VoiceCog(
321
- bot=bot,
322
- stt=stt,
323
- tts=tts,
324
- send_agy_response=send_agy_response,
325
- agy_send=agy_send_message,
326
- stream_thinking_latest=partial(stream_thinking_latest, bot),
327
- agy_start_session=agy_new_conversation,
328
- session_manager=session_manager,
329
- bot_settings=bot_settings,
330
- save_bot_settings=save_bot_settings,
331
- logger=logger,
332
- )
333
- )
62
+ tasks.append(asyncio.create_task(run_discord(stop_event)))
63
+ if telegram_enabled:
64
+ from main_telegram import run_telegram
334
65
 
335
- from cogs.general_cog import GeneralCog
66
+ tasks.append(asyncio.create_task(run_telegram(stop_event)))
336
67
 
337
- await bot.add_cog(GeneralCog(bot=bot))
338
- await bot.start(DISCORD_TOKEN)
68
+ await asyncio.gather(*tasks)
339
69
 
340
70
 
341
71
  if __name__ == "__main__":
@@ -0,0 +1,311 @@
1
+ """Discord bot setup. Runs in the same process as Telegram (see main.py),
2
+ which starts both concurrently when both platforms are enabled."""
3
+
4
+ import asyncio
5
+ import atexit
6
+ import os
7
+ import subprocess
8
+ import sys
9
+ from functools import partial
10
+
11
+ import discord
12
+ from discord.ext import commands
13
+
14
+ from config import DISCORD_TOKEN, logger, session_manager
15
+ from handlers.message_router import handle_message
16
+ from messengers.discord_adapter import DiscordAdapter
17
+ from messengers.registry import register_adapter
18
+ from services.response import send_agy_response
19
+ from services.streaming import stream_thinking_latest
20
+ from utils.utils import agy_new_conversation, agy_send_message, stt, tts
21
+
22
+ voice_process = None
23
+ _voice_shutting_down = False
24
+ _voice_restart_timestamps = [] # epoch seconds of recent auto-restarts, for the backoff/giveup check below
25
+
26
+ VOICE_SERVICE_HEALTH_URL = "http://localhost:18081/health"
27
+ VOICE_SERVICE_READY_TIMEOUT_SEC = 20
28
+
29
+
30
+ async def _wait_for_voice_service_ready():
31
+ import aiohttp
32
+
33
+ deadline = asyncio.get_event_loop().time() + VOICE_SERVICE_READY_TIMEOUT_SEC
34
+ async with aiohttp.ClientSession() as session:
35
+ while asyncio.get_event_loop().time() < deadline:
36
+ try:
37
+ async with session.get(VOICE_SERVICE_HEALTH_URL, timeout=aiohttp.ClientTimeout(total=2)) as resp:
38
+ if resp.status == 200:
39
+ data = await resp.json()
40
+ if data.get("ready"):
41
+ logger.info("🎤 Voice service is online and ready.")
42
+ return
43
+ except Exception:
44
+ pass
45
+ await asyncio.sleep(0.5)
46
+ logger.warning(
47
+ f"Voice service did not report ready within {VOICE_SERVICE_READY_TIMEOUT_SEC}s - "
48
+ "/join may fail until it finishes starting."
49
+ )
50
+
51
+
52
+ STATUS_TEXT_BY_KEYWORDS = [
53
+ (("run_command",), "🖥️ Running command..."),
54
+ (("file", "list"), "🔍 Analyzing files..."),
55
+ (("search", "web"), "🌐 Searching web..."),
56
+ (("replace", "write"), "✍️ Writing code..."),
57
+ (("ask_question",), "❓ Waiting for input..."),
58
+ ]
59
+
60
+
61
+ def _status_text_for_tool(tool_name: str) -> str:
62
+ for keywords, text in STATUS_TEXT_BY_KEYWORDS:
63
+ if any(keyword in tool_name for keyword in keywords):
64
+ return text
65
+ return f"⚙️ Running {tool_name}..."
66
+
67
+
68
+ def _voice_status_text() -> str | None:
69
+ """None if no guild is connected to voice right now. Otherwise reflects
70
+ whether any connected guild is in its post-wake-word "awake" window -
71
+ filling the gap between Idle and an active text session, since being
72
+ connected to voice and waiting for a wake word isn't really "Idle"."""
73
+ voice_cog = bot.get_cog("VoiceCog")
74
+ if not voice_cog or not voice_cog._voice_state:
75
+ return None
76
+ if any(voice_cog.stt_session.is_active(guild_id) for guild_id in voice_cog._voice_state):
77
+ return "👂 Awake"
78
+ return "💤 Asleep"
79
+
80
+
81
+ intents = discord.Intents.default()
82
+ intents.message_content = True
83
+ intents.voice_states = True
84
+ bot = commands.Bot(command_prefix="!", intents=intents)
85
+ discord_adapter = DiscordAdapter(bot)
86
+ register_adapter("discord", discord_adapter)
87
+
88
+
89
+ async def status_updater_task():
90
+ await bot.wait_until_ready()
91
+ logger.debug("status_updater_task started")
92
+ try:
93
+ last_status = ""
94
+ while True:
95
+ await asyncio.sleep(2)
96
+
97
+ full_status = ""
98
+ if not session_manager.has_active_queues():
99
+ full_status = _voice_status_text() or "🟢 Idle"
100
+ else:
101
+ first_t_id = session_manager.get_active_queue_keys()[0]
102
+ sess = session_manager.get_session(first_t_id) or {}
103
+ pending_tool = sess.get("pending_approval_tool")
104
+ tool_name = sess.get("current_tool")
105
+ if pending_tool:
106
+ full_status = "⏳ Waiting for approval..."
107
+ elif tool_name:
108
+ full_status = _status_text_for_tool(tool_name)
109
+ else:
110
+ full_status = "🧠 Thinking..."
111
+
112
+ if full_status != last_status:
113
+ logger.debug(f"Status updating to: {full_status}")
114
+ try:
115
+ await bot.change_presence(
116
+ status=discord.Status.online,
117
+ activity=discord.Activity(type=discord.ActivityType.playing, name=full_status),
118
+ )
119
+ last_status = full_status
120
+ except discord.HTTPException as e:
121
+ logger.warning(f"Presence update rate-limited or failed: {e}")
122
+ except Exception as e:
123
+ logger.warning(f"Presence update error: {e}")
124
+ except asyncio.CancelledError:
125
+ logger.debug("Status updater task was cancelled.")
126
+ except Exception as e:
127
+ logger.exception(f"FATAL ERROR IN STATUS UPDATER: {e}")
128
+
129
+
130
+ def _spawn_voice_process(voice_dir: str) -> subprocess.Popen:
131
+ return subprocess.Popen(["node", "index.js"], cwd=voice_dir)
132
+
133
+
134
+ async def _supervise_voice_process(voice_dir: str):
135
+ """voice_process (voice-service/index.js) can die on its own - most
136
+ notably from the known upstream @discordjs/voice DAVE decrypt bug
137
+ (discordjs/discord.js#11419), which can throw synchronously out of
138
+ an event handler with nothing upstream to catch it. Without this,
139
+ that single crash would leave every voice feature (wake word, STT,
140
+ TTS) dead until someone manually restarts the whole bot.
141
+
142
+ Polls every 1s so a crash gets noticed and a restart kicked off
143
+ almost immediately - the "5 crashes in 5 minutes" check below is
144
+ NOT a claim that 5 minutes of intermittent breakage is acceptable;
145
+ it only exists to stop a genuine crash-loop (missing node_modules,
146
+ a port conflict, etc.) from burning CPU forever. Anyone mid-
147
+ recording when Node dies gets told immediately via
148
+ VoiceCog.handle_voice_service_down(), rather than finding out
149
+ whenever a stale request to Node eventually times out.
150
+ """
151
+ global voice_process
152
+ while True:
153
+ await asyncio.sleep(1)
154
+ if _voice_shutting_down:
155
+ return
156
+ if voice_process is None or voice_process.poll() is None:
157
+ continue # still running (or never started) - nothing to do
158
+
159
+ exit_code = voice_process.poll()
160
+ logger.warning(f"🎤 Voice service exited unexpectedly (code {exit_code}). Attempting to restart it...")
161
+
162
+ # Notify anyone mid-recording before attempting the restart, not after.
163
+ voice_cog = bot.get_cog("VoiceCog")
164
+ if voice_cog:
165
+ try:
166
+ await voice_cog.handle_voice_service_down()
167
+ except Exception as e:
168
+ logger.error(f"Error notifying users of voice service outage: {e}")
169
+
170
+ now = asyncio.get_event_loop().time()
171
+ _voice_restart_timestamps.append(now)
172
+ while _voice_restart_timestamps and now - _voice_restart_timestamps[0] > 300:
173
+ _voice_restart_timestamps.pop(0)
174
+
175
+ if len(_voice_restart_timestamps) > 5:
176
+ logger.error(
177
+ "🎤 Voice service has crashed 5+ times in the last 5 minutes - giving up on auto-restart "
178
+ "to avoid a crash loop. Check `lgy logs` for the underlying error, fix it, then run "
179
+ "`lgy restart`."
180
+ )
181
+ return
182
+
183
+ try:
184
+ voice_process = _spawn_voice_process(voice_dir)
185
+ asyncio.create_task(_wait_for_voice_service_ready())
186
+ except Exception as e:
187
+ logger.error(f"Failed to restart voice service: {e}")
188
+
189
+
190
+ @bot.event
191
+ async def on_ready():
192
+ logger.info(f"✅ Bot is fully online and ready! Logged in as {bot.user}")
193
+
194
+
195
+ def _terminate_voice_process():
196
+ global _voice_shutting_down
197
+ _voice_shutting_down = True
198
+ if voice_process and voice_process.poll() is None:
199
+ logger.debug("Terminating child Node.js voice process...")
200
+ voice_process.terminate() # Node now catches this and disconnects any active voice channel cleanly
201
+ try:
202
+ voice_process.wait(timeout=3)
203
+ except subprocess.TimeoutExpired:
204
+ voice_process.kill()
205
+
206
+
207
+ @bot.event
208
+ async def setup_hook():
209
+ await bot.tree.sync()
210
+ logger.info("Slash commands synced.")
211
+
212
+ global voice_process
213
+ try:
214
+ voice_dir = os.path.join(os.path.dirname(os.path.dirname(__file__)), "voice-service")
215
+ logger.debug(f"Spawning Node.js process at: {voice_dir}")
216
+ if os.path.exists(os.path.join(voice_dir, "index.js")):
217
+ if not os.path.isdir(os.path.join(voice_dir, "node_modules")):
218
+ logger.error(
219
+ "voice-service/node_modules is missing - its dependencies were never installed "
220
+ "(or got wiped, e.g. by replacing project files without re-running `npm install`). "
221
+ "Voice features will not work until you run `npm install` in the project root "
222
+ "and restart the bot."
223
+ )
224
+ voice_process = _spawn_voice_process(voice_dir)
225
+ logger.info("🎤 Voice service starting, waiting for it to come online...")
226
+ asyncio.create_task(_wait_for_voice_service_ready())
227
+ asyncio.create_task(_supervise_voice_process(voice_dir))
228
+
229
+ atexit.register(_terminate_voice_process)
230
+ else:
231
+ logger.warning("Voice service (index.js) not found. Skipping auto-start.")
232
+ except Exception as e:
233
+ logger.error(f"Failed to auto-start voice service: {e}")
234
+
235
+ logger.debug("Launching status_updater_task...")
236
+ asyncio.create_task(status_updater_task())
237
+ logger.debug("status_updater_task dispatched")
238
+
239
+
240
+ @bot.event
241
+ async def on_error(event_method: str, *args, **kwargs):
242
+ logger.exception(f"Unhandled exception in Discord event: {event_method}")
243
+ exc_type, exc_value, _ = sys.exc_info()
244
+ if exc_value is None:
245
+ return
246
+
247
+ error_msg = "⚠️ **An internal bot error has occurred.** Please contact the developer or check the server logs."
248
+
249
+ if event_method == "on_message":
250
+ if args and isinstance(args[0], discord.Message):
251
+ message = args[0]
252
+ try:
253
+ await message.reply(error_msg)
254
+ except Exception:
255
+ pass
256
+
257
+
258
+ @bot.event
259
+ async def on_application_command_error(interaction, error):
260
+ logger.exception(f"Slash command error: {error}")
261
+ error_msg = "⚠️ **An error occurred while processing the command.** Please try again later or check the logs."
262
+ try:
263
+ if interaction.response.is_done():
264
+ await interaction.followup.send(error_msg, ephemeral=True)
265
+ else:
266
+ await interaction.response.send_message(error_msg, ephemeral=True)
267
+ except Exception:
268
+ pass
269
+
270
+
271
+ @bot.event
272
+ async def on_message(message: discord.Message):
273
+ await handle_message(bot, message, discord_adapter)
274
+
275
+
276
+ async def run_discord(stop_event: asyncio.Event) -> None:
277
+ if not DISCORD_TOKEN:
278
+ logger.critical("Missing DISCORD_TOKEN - run `lgy setup`")
279
+ return
280
+
281
+ discord.utils.setup_logging()
282
+
283
+ async with bot:
284
+ from cogs.voice_cog import VoiceCog
285
+ from config import bot_settings, save_bot_settings
286
+
287
+ await bot.add_cog(
288
+ VoiceCog(
289
+ bot=bot,
290
+ stt=stt,
291
+ tts=tts,
292
+ send_agy_response=send_agy_response,
293
+ agy_send=agy_send_message,
294
+ stream_thinking_latest=partial(stream_thinking_latest, bot),
295
+ agy_start_session=agy_new_conversation,
296
+ session_manager=session_manager,
297
+ bot_settings=bot_settings,
298
+ save_bot_settings=save_bot_settings,
299
+ logger=logger,
300
+ )
301
+ )
302
+
303
+ from cogs.general_cog import GeneralCog
304
+
305
+ await bot.add_cog(GeneralCog(bot=bot))
306
+
307
+ start_task = asyncio.create_task(bot.start(DISCORD_TOKEN))
308
+ await stop_event.wait()
309
+ _terminate_voice_process()
310
+ await bot.close()
311
+ await start_task