linkgravity 1.2.2 → 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,222 +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
- intents = discord.Intents.default()
75
- intents.message_content = True
76
- intents.voice_states = True
77
- bot = commands.Bot(command_prefix="!", intents=intents)
78
-
79
-
80
- async def status_updater_task():
81
- await bot.wait_until_ready()
82
- logger.debug("status_updater_task started")
83
- try:
84
- last_status = ""
85
- while True:
86
- await asyncio.sleep(2)
87
-
88
- full_status = ""
89
- if not session_manager.has_active_queues():
90
- full_status = "🟢 Idle"
91
- else:
92
- first_t_id = session_manager.get_active_queue_keys()[0]
93
- sess = session_manager.get_session(first_t_id) or {}
94
- tool_name = sess.get("current_tool")
95
- full_status = _status_text_for_tool(tool_name) if tool_name else "🧠 Thinking..."
96
-
97
- if full_status != last_status:
98
- logger.debug(f"Status updating to: {full_status}")
99
- try:
100
- await bot.change_presence(
101
- status=discord.Status.online,
102
- activity=discord.Activity(type=discord.ActivityType.playing, name=full_status),
103
- )
104
- last_status = full_status
105
- except discord.HTTPException as e:
106
- logger.warning(f"Presence update rate-limited or failed: {e}")
107
- except Exception as e:
108
- logger.warning(f"Presence update error: {e}")
109
- except asyncio.CancelledError:
110
- logger.debug("Status updater task was cancelled.")
111
- except Exception as e:
112
- logger.exception(f"FATAL ERROR IN STATUS UPDATER: {e}")
113
-
114
-
115
- def _spawn_voice_process(voice_dir: str) -> subprocess.Popen:
116
- return subprocess.Popen(["node", "index.js"], cwd=voice_dir)
117
-
118
-
119
- async def _supervise_voice_process(voice_dir: str):
120
- """voice_process (voice-service/index.js) can die on its own - most
121
- notably from the known upstream @discordjs/voice DAVE decrypt bug
122
- (discordjs/discord.js#11419), which can throw synchronously out of
123
- an event handler with nothing upstream to catch it. Without this,
124
- that single crash would leave every voice feature (wake word, STT,
125
- TTS) dead until someone manually restarts the whole bot.
126
-
127
- Polls every 1s so a crash gets noticed and a restart kicked off
128
- almost immediately - the "5 crashes in 5 minutes" check below is
129
- NOT a claim that 5 minutes of intermittent breakage is acceptable;
130
- it only exists to stop a genuine crash-loop (missing node_modules,
131
- a port conflict, etc.) from burning CPU forever. Anyone mid-
132
- recording when Node dies gets told immediately via
133
- VoiceCog.handle_voice_service_down(), rather than finding out
134
- whenever a stale request to Node eventually times out.
135
- """
136
- global voice_process
137
- while True:
138
- await asyncio.sleep(1)
139
- if _voice_shutting_down:
140
- return
141
- if voice_process is None or voice_process.poll() is None:
142
- continue # still running (or never started) - nothing to do
143
-
144
- exit_code = voice_process.poll()
145
- logger.warning(f"🎤 Voice service exited unexpectedly (code {exit_code}). Attempting to restart it...")
8
+ from config import DISCORD_TOKEN, SESSION_SCOPES, bot_settings, logger, session_manager
146
9
 
147
- # Notify anyone mid-recording before attempting the restart, not after.
148
- voice_cog = bot.get_cog("VoiceCog")
149
- if voice_cog:
150
- try:
151
- await voice_cog.handle_voice_service_down()
152
- except Exception as e:
153
- logger.error(f"Error notifying users of voice service outage: {e}")
154
10
 
155
- now = asyncio.get_event_loop().time()
156
- _voice_restart_timestamps.append(now)
157
- while _voice_restart_timestamps and now - _voice_restart_timestamps[0] > 300:
158
- _voice_restart_timestamps.pop(0)
159
-
160
- if len(_voice_restart_timestamps) > 5:
161
- logger.error(
162
- "🎤 Voice service has crashed 5+ times in the last 5 minutes - giving up on auto-restart "
163
- "to avoid a crash loop. Check `lgy logs` for the underlying error, fix it, then run "
164
- "`lgy restart`."
165
- )
166
- return
167
-
168
- try:
169
- voice_process = _spawn_voice_process(voice_dir)
170
- asyncio.create_task(_wait_for_voice_service_ready())
171
- except Exception as e:
172
- logger.error(f"Failed to restart voice service: {e}")
173
-
174
-
175
- @bot.event
176
- async def on_ready():
177
- logger.info(f"✅ Bot is fully online and ready! Logged in as {bot.user}")
178
-
179
-
180
- @bot.event
181
- async def setup_hook():
182
- await bot.tree.sync()
183
- logger.info("Slash commands synced.")
184
-
185
- asyncio.create_task(server.setup_webhook_server(bot))
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.")
186
15
 
187
- global voice_process
188
- try:
189
- voice_dir = os.path.join(os.path.dirname(os.path.dirname(__file__)), "voice-service")
190
- logger.debug(f"Spawning Node.js process at: {voice_dir}")
191
- if os.path.exists(os.path.join(voice_dir, "index.js")):
192
- if not os.path.isdir(os.path.join(voice_dir, "node_modules")):
193
- logger.error(
194
- "voice-service/node_modules is missing - its dependencies were never installed "
195
- "(or got wiped, e.g. by replacing project files without re-running `npm install`). "
196
- "Voice features will not work until you run `npm install` in the project root "
197
- "and restart the bot."
198
- )
199
- voice_process = _spawn_voice_process(voice_dir)
200
- logger.info("🎤 Voice service starting, waiting for it to come online...")
201
- asyncio.create_task(_wait_for_voice_service_ready())
202
- asyncio.create_task(_supervise_voice_process(voice_dir))
16
+ discord_enabled = bot_settings.get("discord_enabled", bool(DISCORD_TOKEN))
17
+ telegram_enabled = bot_settings.get("telegram_enabled", False)
203
18
 
204
- def cleanup_voice():
205
- global _voice_shutting_down
206
- _voice_shutting_down = True
207
- if voice_process and voice_process.poll() is None:
208
- logger.debug("Zombie prevention: Terminating child Node.js process as Python exits...")
209
- voice_process.terminate()
210
- try:
211
- voice_process.wait(timeout=3)
212
- except subprocess.TimeoutExpired:
213
- voice_process.kill()
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
214
24
 
215
- atexit.register(cleanup_voice)
216
- else:
217
- logger.warning("Voice service (index.js) not found. Skipping auto-start.")
218
- except Exception as e:
219
- 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
220
28
 
221
29
  def _handle_task_exception(loop, context):
222
30
  exc = context.get("exception")
@@ -227,83 +35,37 @@ async def setup_hook():
227
35
 
228
36
  asyncio.get_event_loop().set_exception_handler(_handle_task_exception)
229
37
 
230
- logger.debug("Launching status_updater_task...")
231
- asyncio.create_task(status_updater_task())
232
- logger.debug("status_updater_task dispatched")
233
-
38
+ discord_bot = None
39
+ if discord_enabled:
40
+ from main_discord import bot as discord_bot
234
41
 
235
- @bot.event
236
- async def on_error(event_method: str, *args, **kwargs):
237
- logger.exception(f"Unhandled exception in Discord event: {event_method}")
238
- exc_type, exc_value, _ = sys.exc_info()
239
- if exc_value is None:
240
- return
241
-
242
- error_msg = "⚠️ **An internal bot error has occurred.** Please contact the developer or check the server logs."
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))
243
44
 
244
- if event_method == "on_message":
245
- if args and isinstance(args[0], discord.Message):
246
- message = args[0]
247
- try:
248
- await message.reply(error_msg)
249
- except Exception:
250
- pass
45
+ stop_event = asyncio.Event()
251
46
 
47
+ def _handle_sigterm():
48
+ logger.info("Received SIGTERM (lgy stop/restart) - shutting down...")
49
+ stop_event.set()
252
50
 
253
- @bot.event
254
- async def on_application_command_error(interaction, error):
255
- logger.exception(f"Slash command error: {error}")
256
- error_msg = "⚠️ **An error occurred while processing the command.** Please try again later or check the logs."
257
51
  try:
258
- if interaction.response.is_done():
259
- await interaction.followup.send(error_msg, ephemeral=True)
260
- else:
261
- await interaction.response.send_message(error_msg, ephemeral=True)
262
- except Exception:
263
- pass
264
-
265
-
266
- @bot.event
267
- async def on_message(message: discord.Message):
268
- await handle_message(bot, message)
269
-
270
-
271
- async def main():
272
- if not DISCORD_TOKEN or not SESSION_SCOPES:
273
- logger.critical("Missing DISCORD_TOKEN, or no server/channel configured (session_scopes) - run `lgy setup`")
274
- return
275
-
276
- discord.utils.setup_logging()
277
-
278
- from messengers.discord_adapter import DiscordAdapter
279
- from messengers.registry import set_adapter
52
+ import signal
280
53
 
281
- set_adapter(DiscordAdapter(bot))
54
+ asyncio.get_running_loop().add_signal_handler(signal.SIGTERM, _handle_sigterm)
55
+ except NotImplementedError:
56
+ pass # add_signal_handler isn't supported on this platform (e.g. Windows)
282
57
 
283
- async with bot:
284
- from cogs.voice_cog import VoiceCog
285
- from config import bot_settings, save_bot_settings
58
+ tasks = []
59
+ if discord_enabled:
60
+ from main_discord import run_discord
286
61
 
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
- )
62
+ tasks.append(asyncio.create_task(run_discord(stop_event)))
63
+ if telegram_enabled:
64
+ from main_telegram import run_telegram
302
65
 
303
- from cogs.general_cog import GeneralCog
66
+ tasks.append(asyncio.create_task(run_telegram(stop_event)))
304
67
 
305
- await bot.add_cog(GeneralCog(bot=bot))
306
- await bot.start(DISCORD_TOKEN)
68
+ await asyncio.gather(*tasks)
307
69
 
308
70
 
309
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