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/bin/cli.js +160 -80
- package/bin/platforms.js +63 -0
- package/bin/setup.js +172 -76
- package/hooks/hook.py +3 -4
- package/package.json +1 -1
- package/requirements.txt +1 -0
- package/src/api/server.py +2 -4
- package/src/api/ui_routes.py +59 -18
- package/src/api/voice_routes.py +5 -8
- package/src/cogs/general_cog.py +4 -1
- package/src/cogs/voice/enrollment.py +1 -1
- package/src/cogs/voice_cog.py +28 -23
- package/src/config.py +9 -4
- package/src/core/agy_runner.py +36 -3
- package/src/core/session_manager.py +25 -0
- package/src/handlers/message_router.py +6 -9
- package/src/handlers/thread_reply.py +63 -33
- package/src/main.py +40 -278
- package/src/main_discord.py +311 -0
- package/src/main_telegram.py +202 -0
- package/src/messengers/base.py +46 -7
- package/src/messengers/discord_adapter.py +72 -5
- package/src/messengers/registry.py +28 -12
- package/src/messengers/telegram_adapter.py +372 -0
- package/src/services/audio_service.py +2 -2
- package/src/services/discord_helpers.py +3 -4
- package/src/services/response.py +2 -2
- package/src/services/streaming.py +24 -16
- package/src/utils/utils.py +2 -0
- package/voice-service/index.js +25 -22
- package/src/services/discord_mcp.py +0 -50
package/src/main.py
CHANGED
|
@@ -1,222 +1,30 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
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
|
|
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
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
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
|
-
|
|
188
|
-
|
|
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
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
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
|
-
|
|
216
|
-
|
|
217
|
-
|
|
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
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
38
|
+
discord_bot = None
|
|
39
|
+
if discord_enabled:
|
|
40
|
+
from main_discord import bot as discord_bot
|
|
234
41
|
|
|
235
|
-
|
|
236
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
284
|
-
|
|
285
|
-
from
|
|
58
|
+
tasks = []
|
|
59
|
+
if discord_enabled:
|
|
60
|
+
from main_discord import run_discord
|
|
286
61
|
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
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
|
-
|
|
66
|
+
tasks.append(asyncio.create_task(run_telegram(stop_event)))
|
|
304
67
|
|
|
305
|
-
|
|
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
|