linkgravity 1.0.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.
Files changed (43) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +114 -0
  3. package/bin/cli.js +278 -0
  4. package/bin/setup.js +260 -0
  5. package/hooks/hook.py +60 -0
  6. package/hooks/stop_hook.py +64 -0
  7. package/npm-scripts/postinstall.js +62 -0
  8. package/npm-scripts/prepare.js +45 -0
  9. package/npm-scripts/register-hook.js +182 -0
  10. package/npm-scripts/run-dev.js +9 -0
  11. package/npm-scripts/venv-paths.js +45 -0
  12. package/package.json +59 -0
  13. package/requirements.txt +13 -0
  14. package/src/api/server.py +48 -0
  15. package/src/api/ui_routes.py +340 -0
  16. package/src/api/voice_routes.py +94 -0
  17. package/src/approval/command_parser.py +62 -0
  18. package/src/approval/tool_formatter.py +68 -0
  19. package/src/cogs/general_cog.py +287 -0
  20. package/src/cogs/voice/__init__.py +0 -0
  21. package/src/cogs/voice/enrollment.py +436 -0
  22. package/src/cogs/voice/stt_session.py +121 -0
  23. package/src/cogs/voice_cog.py +573 -0
  24. package/src/config.py +123 -0
  25. package/src/core/agy_runner.py +380 -0
  26. package/src/core/atomic_io.py +31 -0
  27. package/src/core/logger.py +28 -0
  28. package/src/core/session_manager.py +126 -0
  29. package/src/handlers/message_router.py +16 -0
  30. package/src/handlers/thread_reply.py +165 -0
  31. package/src/main.py +313 -0
  32. package/src/messengers/base.py +105 -0
  33. package/src/messengers/discord_adapter.py +240 -0
  34. package/src/messengers/registry.py +19 -0
  35. package/src/services/audio_service.py +67 -0
  36. package/src/services/discord_helpers.py +95 -0
  37. package/src/services/discord_mcp.py +50 -0
  38. package/src/services/response.py +51 -0
  39. package/src/services/streaming.py +199 -0
  40. package/src/utils/utils.py +40 -0
  41. package/voice-service/index.js +1048 -0
  42. package/voice-service/package-lock.json +1880 -0
  43. package/voice-service/package.json +24 -0
@@ -0,0 +1,573 @@
1
+ import asyncio
2
+ import time
3
+
4
+ import aiohttp
5
+ import discord
6
+ from discord import app_commands
7
+ from discord.ext import commands, tasks
8
+
9
+ from config import logger
10
+
11
+ from .voice.enrollment import EnrollmentManager
12
+ from .voice.stt_session import SttSessionTracker
13
+
14
+ NODE_VOICE_API = "http://localhost:18081"
15
+ # Default aiohttp timeout is 5 minutes - too long for a dead voice service.
16
+ NODE_REQUEST_TIMEOUT = aiohttp.ClientTimeout(total=5)
17
+
18
+
19
+ class VoiceCog(commands.Cog):
20
+ def __init__(
21
+ self,
22
+ bot,
23
+ stt,
24
+ tts,
25
+ send_agy_response,
26
+ agy_send,
27
+ stream_thinking_latest,
28
+ agy_start_session,
29
+ session_manager,
30
+ bot_settings,
31
+ save_bot_settings,
32
+ logger,
33
+ ):
34
+ self.bot = bot
35
+ # Unused by the voice pipeline now (STT moved to Node); kept for other callers.
36
+ self.stt = stt
37
+ self.tts = tts
38
+ self.send_agy_response = send_agy_response
39
+ self.agy_send = agy_send
40
+ self.stream_thinking_latest = stream_thinking_latest
41
+ self.agy_start_session = agy_start_session
42
+ self.session_manager = session_manager
43
+ self.bot_settings = bot_settings
44
+ self.save_bot_settings = save_bot_settings
45
+ self.logger = logger
46
+ self._voice_state = {}
47
+ # guild_id -> in-flight handle_stt_input task, if any. A new
48
+ # utterance cancels the previous one so replies can't race out of order.
49
+ self._active_turns = {}
50
+ self.enrollment = EnrollmentManager(
51
+ bot, self._voice_state, self._play_audio, self.bot_settings, self.save_bot_settings, self.logger
52
+ )
53
+ self.stt_session = SttSessionTracker(bot, self._voice_state, self.bot_settings, self.logger)
54
+ self.cleanup_old_voice_files.start()
55
+
56
+ def cog_unload(self):
57
+ self.cleanup_old_voice_files.cancel()
58
+ self.enrollment.stop()
59
+
60
+ async def handle_voice_service_down(self):
61
+ await self.enrollment.handle_voice_service_down()
62
+
63
+ @tasks.loop(minutes=5)
64
+ async def cleanup_old_voice_files(self):
65
+ try:
66
+ from config import TMP_VOICE_DIR
67
+
68
+ now = time.time()
69
+ count = 0
70
+ for f in TMP_VOICE_DIR.glob("*.mp3"):
71
+ if now - f.stat().st_mtime > 600:
72
+ f.unlink(missing_ok=True)
73
+ count += 1
74
+ if count > 0:
75
+ self.logger.debug(f"Garbage Collector: Deleted {count} old TTS audio files.")
76
+ except Exception as e:
77
+ self.logger.error(f"Garbage Collector error: {e}")
78
+
79
+ async def active_times_autocomplete(
80
+ self, interaction: discord.Interaction, current: str
81
+ ) -> list[app_commands.Choice[int]]:
82
+ current_val = int(self.bot_settings.get("active_timer", 60))
83
+ opts = []
84
+ if str(current_val) in current or not current:
85
+ opts.append(app_commands.Choice(name=f"{current_val} (current)", value=current_val))
86
+
87
+ for v in [30, 60, 120, 300]:
88
+ if v != current_val and len(opts) < 25:
89
+ opts.append(app_commands.Choice(name=str(v), value=v))
90
+ return opts
91
+
92
+ async def threshold_autocomplete(
93
+ self, interaction: discord.Interaction, current: str
94
+ ) -> list[app_commands.Choice[int]]:
95
+ current_val = int(self.bot_settings.get("voice_threshold", 3000))
96
+ opts = []
97
+ if str(current_val) in current or not current:
98
+ opts.append(app_commands.Choice(name=f"{current_val} (current)", value=current_val))
99
+
100
+ for v in [1000, 2000, 3000, 5000]:
101
+ if v != current_val and len(opts) < 25:
102
+ opts.append(app_commands.Choice(name=str(v), value=v))
103
+ return opts
104
+
105
+ async def tts_voice_autocomplete(
106
+ self, interaction: discord.Interaction, current: str
107
+ ) -> list[app_commands.Choice[str]]:
108
+ current_val = self.bot_settings.get("tts_voice", "en-US-AriaNeural")
109
+ options = [
110
+ "en-US-AriaNeural",
111
+ "en-US-GuyNeural",
112
+ "en-US-AnaNeural",
113
+ "en-US-ChristopherNeural",
114
+ "en-US-EricNeural",
115
+ "en-US-MichelleNeural",
116
+ "en-US-RogerNeural",
117
+ "en-GB-SoniaNeural",
118
+ "en-GB-RyanNeural",
119
+ "en-AU-NatashaNeural",
120
+ "en-AU-WilliamNeural",
121
+ "ko-KR-SunHiNeural",
122
+ "ko-KR-InJoonNeural",
123
+ "ja-JP-NanamiNeural",
124
+ "ja-JP-KeitaNeural",
125
+ "fr-FR-DeniseNeural",
126
+ "de-DE-KatjaNeural",
127
+ "es-ES-ElviraNeural",
128
+ ]
129
+
130
+ choices = []
131
+ if current.lower() in current_val.lower() or not current:
132
+ choices.append(app_commands.Choice(name=f"{current_val} (current)", value=current_val))
133
+
134
+ for opt in options:
135
+ if current.lower() in opt.lower() and opt != current_val and len(choices) < 25:
136
+ choices.append(app_commands.Choice(name=opt, value=opt))
137
+ return choices
138
+
139
+ async def tts_enabled_autocomplete(
140
+ self, interaction: discord.Interaction, current: str
141
+ ) -> list[app_commands.Choice[str]]:
142
+ is_on = self.bot_settings.get("tts_enabled", True)
143
+ current_val = "ON" if is_on else "OFF"
144
+ other_val = "OFF" if is_on else "ON"
145
+
146
+ opts = []
147
+ if current.lower() in current_val.lower() or not current:
148
+ opts.append(app_commands.Choice(name=f"{current_val} (current)", value=current_val.lower()))
149
+ if current.lower() in other_val.lower():
150
+ opts.append(app_commands.Choice(name=other_val, value=other_val.lower()))
151
+ return opts
152
+
153
+ @app_commands.command(name="join", description="Summon the bot to your current voice channel")
154
+ async def cmd_join(self, interaction: discord.Interaction):
155
+ if not isinstance(interaction.channel, discord.Thread):
156
+ await interaction.response.send_message(
157
+ "❌ This command can only be used inside a thread created with `/new`.", ephemeral=True
158
+ )
159
+ return
160
+
161
+ if not self.session_manager.get_session(str(interaction.channel_id)):
162
+ await interaction.response.send_message(
163
+ "❌ This thread is not an active agy session. Use `/new` to start a new session.", ephemeral=True
164
+ )
165
+ return
166
+
167
+ if not interaction.user.voice or not interaction.user.voice.channel:
168
+ await interaction.response.send_message("❌ Please join a voice channel first.", ephemeral=True)
169
+ return
170
+
171
+ vc_chan = interaction.user.voice.channel
172
+ guild_id = interaction.guild_id
173
+
174
+ has_wake_word = bool(self.bot_settings.get("wake_words"))
175
+ active_timer = self.bot_settings.get("active_timer", 60)
176
+
177
+ if has_wake_word:
178
+ wake_words_raw = self.bot_settings["wake_words"]
179
+ ww_list = [f"`{w.strip()}`" for w in wake_words_raw.split(",") if w.strip()]
180
+ ww_str = ", ".join(ww_list[:-1]) + f", or {ww_list[-1]}" if len(ww_list) > 1 else ww_list[0]
181
+ msg = (
182
+ f"🎤 Connected to `{vc_chan.name}`.\n"
183
+ f"💡 Say {ww_str} to activate me. Once awake, I'll keep listening for {active_timer} seconds after each interaction.\n"
184
+ f"⚙️ You can customize settings using `/sound`."
185
+ )
186
+ else:
187
+ msg = (
188
+ f"🎤 Connected to `{vc_chan.name}`.\n"
189
+ f"🎙️ No wake word is set up yet, so I can't hear you yet - run `/sound wake_word:<word>` "
190
+ f"and say your chosen word a few times to register it in your voice."
191
+ )
192
+ await interaction.response.send_message(msg)
193
+
194
+ self.stt_session.clear_active_window(str(guild_id)) # don't inherit a window left open by a prior /join
195
+
196
+ try:
197
+ async with aiohttp.ClientSession(timeout=NODE_REQUEST_TIMEOUT) as session:
198
+ try:
199
+ await session.post(f"{NODE_VOICE_API}/leave", json={"guild_id": str(guild_id)})
200
+ except Exception as e:
201
+ self.logger.warning(f"Failed to call /leave before /join: {e}")
202
+
203
+ resp = await session.post(
204
+ f"{NODE_VOICE_API}/join", json={"guild_id": str(guild_id), "channel_id": str(vc_chan.id)}
205
+ )
206
+ data = await resp.json()
207
+ if data.get("success"):
208
+ self._voice_state[str(guild_id)] = interaction.channel_id
209
+
210
+ if has_wake_word:
211
+ if self.bot_settings.get("tts_enabled", True):
212
+ welcome_audio = await self.tts("Yes, I am listening.")
213
+ if welcome_audio:
214
+ await self._play_audio(str(guild_id), welcome_audio)
215
+ elif self.bot_settings.get("tts_enabled", True):
216
+ prompt_audio = await self.tts(
217
+ "No wake word is set up yet. Please use the sound command to set one."
218
+ )
219
+ if prompt_audio:
220
+ await self._play_audio(str(guild_id), prompt_audio)
221
+ else:
222
+ await interaction.channel.send(f"⚠️ Node.js integration failed: {data.get('error')}")
223
+ except aiohttp.ClientConnectorError:
224
+ self.logger.error("Voice service is unreachable (not started yet, or crashed)")
225
+ await interaction.channel.send(
226
+ "⚠️ The voice service isn't reachable right now - it may still be starting up "
227
+ "(wait a few seconds and try `/join` again), or it may have crashed (check `lgy logs`)."
228
+ )
229
+ except aiohttp.ClientError as e:
230
+ self.logger.error(f"Node.js connection error in /join: {e}")
231
+ await interaction.channel.send(f"⚠️ Node.js connection error: {e}")
232
+ except asyncio.TimeoutError:
233
+ self.logger.error("Timeout connecting to Node.js backend")
234
+ await interaction.channel.send("⚠️ Timeout connecting to voice backend.")
235
+
236
+ @app_commands.command(
237
+ name="sound", description="Configure voice settings (Wake word, active time, threshold, TTS voice, TTS on/off)"
238
+ )
239
+ @app_commands.describe(
240
+ wake_word="The single word/phrase that wakes the bot (recorded in your voice)",
241
+ active_times="Duration in seconds the bot stays awake",
242
+ threshold="Voice volume sensitivity (1000~10000)",
243
+ tts_voice="Select the AI TTS voice",
244
+ tts_enabled="Turn Text-to-Speech ON or OFF",
245
+ )
246
+ @app_commands.autocomplete(
247
+ active_times=active_times_autocomplete,
248
+ threshold=threshold_autocomplete,
249
+ tts_voice=tts_voice_autocomplete,
250
+ tts_enabled=tts_enabled_autocomplete,
251
+ )
252
+ async def cmd_voice(
253
+ self,
254
+ interaction: discord.Interaction,
255
+ wake_word: str = None,
256
+ active_times: int = None,
257
+ threshold: int = None,
258
+ tts_voice: str = None,
259
+ tts_enabled: str = None,
260
+ ):
261
+ import aiohttp
262
+
263
+ if (
264
+ wake_word is None
265
+ and active_times is None
266
+ and threshold is None
267
+ and tts_voice is None
268
+ and tts_enabled is None
269
+ ):
270
+ curr_wake = self.bot_settings.get("wake_words", "None")
271
+ curr_timer = self.bot_settings.get("active_timer", 60)
272
+ curr_thresh = self.bot_settings.get("voice_threshold", 3000)
273
+ curr_tts = self.bot_settings.get("tts_voice", "en-US-AriaNeural")
274
+ curr_tts_on = "ON" if self.bot_settings.get("tts_enabled", True) else "OFF"
275
+
276
+ embed = discord.Embed(title="⚙️ Current Voice Settings", color=0x3498DB)
277
+ embed.add_field(name="🎙️ Wake Word", value=f"`{curr_wake}`", inline=False)
278
+ embed.add_field(name="⏱️ Active Time", value=f"`{curr_timer}s`", inline=False)
279
+ embed.add_field(name="🔊 Threshold", value=f"`{curr_thresh}`", inline=False)
280
+ embed.add_field(name="🗣️ TTS Voice", value=f"`{curr_tts}`", inline=False)
281
+ embed.add_field(name="🔊 TTS Enabled", value=f"`{curr_tts_on}`", inline=False)
282
+ return await interaction.response.send_message(embed=embed)
283
+
284
+ updated = []
285
+ wake_word_pending = False
286
+ if wake_word is not None:
287
+ # Deferred: wake_words only gets set once 5 samples are
288
+ # recorded and confirmed (see EnrollmentManager), not here.
289
+ wake_word_pending = True
290
+ if active_times is not None:
291
+ self.bot_settings["active_timer"] = active_times
292
+ updated.append(f"⏱️ Active Timer: `{active_times}s`")
293
+ if threshold is not None:
294
+ self.bot_settings["voice_threshold"] = threshold
295
+ updated.append(f"🔊 Threshold: `{threshold}`")
296
+ try:
297
+ async with aiohttp.ClientSession(timeout=NODE_REQUEST_TIMEOUT) as session:
298
+ await session.post(f"{NODE_VOICE_API}/set_config", json={"voice_threshold": threshold})
299
+ except aiohttp.ClientError as e:
300
+ self.logger.warning(f"Node.js sync failed for {interaction.guild_id}: {e}")
301
+ updated.append(f"(⚠️ Node.js Sync Failed: {e})")
302
+ except asyncio.TimeoutError:
303
+ self.logger.warning(f"Node.js sync timeout for {interaction.guild_id}")
304
+ updated.append("(⚠️ Node.js Sync Timeout)")
305
+ if tts_voice is not None:
306
+ self.bot_settings["tts_voice"] = tts_voice
307
+ updated.append(f"🗣️ TTS Voice: `{tts_voice}`")
308
+ if tts_enabled is not None:
309
+ is_on = tts_enabled.lower() == "on"
310
+ self.bot_settings["tts_enabled"] = is_on
311
+ updated.append(f"🔊 TTS Enabled: `{'ON' if is_on else 'OFF'}`")
312
+
313
+ self.save_bot_settings(self.bot_settings)
314
+
315
+ if wake_word_pending:
316
+ # start_wake_word_recording owns the interaction reply (error or "say it now").
317
+ started = await self.enrollment.start_wake_word_recording(interaction, wake_word)
318
+ if updated:
319
+ embed = discord.Embed(
320
+ title="⚙️ Other Voice Settings Updated", description="\n".join(updated), color=0x3498DB
321
+ )
322
+ if started:
323
+ await interaction.channel.send(embed=embed)
324
+ else:
325
+ await interaction.followup.send(embed=embed, ephemeral=True)
326
+ return
327
+
328
+ embed = discord.Embed(title="⚙️ Voice Settings Updated", description="\n".join(updated), color=0x3498DB)
329
+ await interaction.response.send_message(embed=embed)
330
+
331
+ @app_commands.command(name="leave", description="Make the bot leave the voice channel")
332
+ async def cmd_leave(self, interaction: discord.Interaction):
333
+ guild_id = interaction.guild_id
334
+ await interaction.response.send_message("👋 Disconnected from voice channel.")
335
+ try:
336
+ async with aiohttp.ClientSession(timeout=NODE_REQUEST_TIMEOUT) as session:
337
+ await session.post(f"{NODE_VOICE_API}/leave", json={"guild_id": str(guild_id)})
338
+ if str(guild_id) in self._voice_state:
339
+ del self._voice_state[str(guild_id)]
340
+ except aiohttp.ClientError as e:
341
+ self.logger.warning(f"Websocket connection closed with error: {e}")
342
+ except asyncio.CancelledError:
343
+ self.logger.debug("Websocket listener task cancelled.")
344
+
345
+ async def handle_enroll_sample(self, user_id: str, audio_bytes: bytes):
346
+ await self.enrollment.handle_enroll_sample(user_id, audio_bytes)
347
+
348
+ async def _play_audio(self, guild_id: str, audio_bytes: bytes, suppress_active_window: bool = False):
349
+ """Sends TTS bytes to Node directly (no temp file). suppress_active_window=True
350
+ (enrollment playback) stops it from extending the awake window."""
351
+ try:
352
+ async with aiohttp.ClientSession(timeout=NODE_REQUEST_TIMEOUT) as session:
353
+ await session.post(
354
+ f"{NODE_VOICE_API}/play",
355
+ params={"guild_id": guild_id, "suppress_active_window": str(suppress_active_window).lower()},
356
+ data=audio_bytes,
357
+ headers={"Content-Type": "application/octet-stream"},
358
+ )
359
+ except aiohttp.ClientError as e:
360
+ self.logger.error(f"Node.js play error (network): {e}")
361
+ except asyncio.TimeoutError:
362
+ self.logger.error("Node.js play error: Timeout")
363
+
364
+ async def handle_stt_input(self, data):
365
+ try:
366
+ guild_id = data.get("guild_id")
367
+ user_id = data.get("user_id")
368
+ # Node runs STT itself before calling this endpoint, so this is
369
+ # already-recognized text, not raw audio - keeps STT off every VAD hit.
370
+ text = data.get("text")
371
+
372
+ thread_id = self._voice_state.get(str(guild_id))
373
+ self.logger.debug(f"STT handler: guild_id={guild_id}, thread_id={thread_id}")
374
+ if not thread_id:
375
+ self.logger.debug("STT: thread_id is None, skipping")
376
+ return
377
+
378
+ thread = self.bot.get_channel(int(thread_id))
379
+ self.logger.debug(f"STT: thread={thread}")
380
+ if not thread:
381
+ self.logger.debug("STT: thread is None, skipping")
382
+ return
383
+
384
+ if not text:
385
+ self.logger.debug("STT: silence/no speech detected, skipping")
386
+ await self.stt_session.clear_partial_msg(str(guild_id))
387
+ return
388
+
389
+ import difflib
390
+ import re
391
+
392
+ # Wake detection is Node's audio-based Rustpotter detector's job.
393
+ # No text-similarity fallback: an unenrolled user (or a failed
394
+ # .rpw build - see EnrollmentManager._build_rustpotter_reference)
395
+ # just can't wake the bot until that's fixed.
396
+ is_waking_up = bool(data.get("wake_confirmed"))
397
+ matched_wake_word = data.get("matched_wake_word")
398
+ is_active = self.stt_session.is_active(str(guild_id))
399
+
400
+ if not is_active and not is_waking_up:
401
+ self.logger.debug(f"STT: ignored (sleeping): {text}")
402
+ await self.stt_session.clear_partial_msg(str(guild_id))
403
+ return
404
+
405
+ text_to_ai = text
406
+ prefix_similarity = None
407
+ if matched_wake_word:
408
+ pattern = re.compile(re.escape(matched_wake_word) + r"[아야]?\s*[^\w\s]*\s*", re.IGNORECASE)
409
+ text_to_ai = pattern.sub("", text_to_ai, count=1).strip()
410
+
411
+ if text_to_ai == text:
412
+ # Exact match failed (STT can mistranscribe); try a fuzzy phonetic prefix match.
413
+ words = text.split()
414
+ wake_word_count = max(1, len(matched_wake_word.split()))
415
+ if words:
416
+ prefix = re.sub(r"[^\w가-힣]", "", "".join(words[:wake_word_count]))
417
+ wake_clean = re.sub(r"[^\w가-힣]", "", matched_wake_word)
418
+ try:
419
+ from jamo import h2j, j2hcj
420
+
421
+ prefix_similarity = difflib.SequenceMatcher(
422
+ None, j2hcj(h2j(prefix)), j2hcj(h2j(wake_clean))
423
+ ).ratio()
424
+ except Exception:
425
+ prefix_similarity = difflib.SequenceMatcher(None, prefix, wake_clean).ratio()
426
+ if prefix_similarity >= 0.6:
427
+ text_to_ai = " ".join(words[wake_word_count:]).strip()
428
+
429
+ # No text resemblance at all + short wake word (less acoustic
430
+ # signal, more false-wakes) -> require a closer match to trust it.
431
+ wake_syllables = len(re.sub(r"[^\w가-힣]", "", matched_wake_word or ""))
432
+ min_prefix_similarity = 0.55 if wake_syllables <= 2 else 0.35
433
+ if is_waking_up and prefix_similarity is not None and prefix_similarity < min_prefix_similarity:
434
+ self.logger.info(
435
+ f"STT: ignoring wake - '{text}' doesn't resemble '{matched_wake_word}' "
436
+ f"(prefix similarity {prefix_similarity:.2f}, needed {min_prefix_similarity:.2f})"
437
+ )
438
+ await self.stt_session.clear_partial_msg(str(guild_id))
439
+ return
440
+
441
+ self.logger.debug(f"STT recognized: {text} -> AI: {text_to_ai}")
442
+
443
+ # A previous in-flight turn for this guild is now stale - cancel
444
+ # it, kill its agy process (like /stop), and stop its playback,
445
+ # rather than letting two turns race to completion.
446
+ prev_task = self._active_turns.get(str(guild_id))
447
+ if prev_task and not prev_task.done():
448
+ prev_task.cancel()
449
+
450
+ from core.agy_runner import stop_active_process
451
+
452
+ stop_active_process(str(thread_id))
453
+
454
+ prev_session = self.session_manager.get_session(str(thread_id))
455
+ if prev_session:
456
+ self.session_manager.set_session(
457
+ str(thread_id), {**prev_session, "status": "pending", "conversation_id": None}
458
+ )
459
+
460
+ try:
461
+ async with aiohttp.ClientSession(timeout=NODE_REQUEST_TIMEOUT) as session:
462
+ await session.post(f"{NODE_VOICE_API}/interrupt", json={"guild_id": guild_id})
463
+ except aiohttp.ClientError as e:
464
+ self.logger.warning(f"Failed to interrupt playback for guild {guild_id}: {e}")
465
+ self._active_turns[str(guild_id)] = asyncio.current_task()
466
+
467
+ user = self.bot.get_user(int(user_id))
468
+ if not user:
469
+ try:
470
+ user = await self.bot.fetch_user(int(user_id))
471
+ except discord.NotFound:
472
+ pass
473
+ except discord.HTTPException as e:
474
+ self.logger.warning(f"fetch_user failed for {user_id}: {e}")
475
+ username = user.display_name if user else f"User {user_id}"
476
+ await self.stt_session.finalize_partial_msg(str(guild_id), thread, f"🎤 **{username}**: {text}")
477
+
478
+ if not text_to_ai:
479
+ self.logger.debug("STT: isolated wake word handled via direct TTS")
480
+ if self.bot_settings.get("tts_enabled", True):
481
+ audio_reply = await self.tts("Yes, I am listening.")
482
+ if audio_reply:
483
+ await self._play_audio(str(guild_id), audio_reply)
484
+ return
485
+
486
+ sess = self.session_manager.get_session(str(thread_id))
487
+ if not sess:
488
+ sess = {"status": "pending", "user_id": str(user_id)}
489
+ self.session_manager.set_session(str(thread_id), sess)
490
+
491
+ conv_id = sess.get("conversation_id")
492
+ pa = self.session_manager.get_pending_approval_by_conv(conv_id) if conv_id else None
493
+
494
+ if conv_id and pa and not pa.done():
495
+ app_type = self.session_manager.get_pending_approval_type_by_conv(conv_id)
496
+ if app_type == "ask_question":
497
+ pa.set_result(text)
498
+ await thread.send(f'✅ *Voice Answer Received: "{text}"*')
499
+ return
500
+
501
+ from services.discord_helpers import check_approval_intent
502
+
503
+ intent = check_approval_intent(text)
504
+ if intent == "allow":
505
+ pa.set_result("allow")
506
+ await thread.send("✅ *Voice Approval Received*")
507
+ return
508
+ elif intent == "reject":
509
+ pa.set_result("reject")
510
+ await thread.send("❌ *Voice Rejection Received*")
511
+ return
512
+
513
+ async with thread.typing():
514
+ self.logger.debug(f"🎤 [{username} ({user_id})] said: {text}")
515
+
516
+ sess = self.session_manager.get_session(str(thread_id))
517
+ is_new_session = sess.get("status") == "pending" or not conv_id
518
+
519
+ queue = asyncio.Queue()
520
+ self.session_manager.register_queue(str(thread.id), queue)
521
+
522
+ ctx = {"status_msg": None}
523
+ consume_task = asyncio.create_task(self.stream_thinking_latest(thread, ctx, queue))
524
+
525
+ try:
526
+ if is_new_session:
527
+ raw_ans, new_conv_id = await self.agy_start_session(
528
+ text_to_ai,
529
+ model=sess.get("model"),
530
+ stream_queue=queue,
531
+ thread_id=str(thread_id),
532
+ cwd=sess.get("cwd"),
533
+ )
534
+ sess["conversation_id"] = new_conv_id
535
+ sess["status"] = "active"
536
+ self.session_manager.set_session(str(thread_id), sess)
537
+ conv_id = new_conv_id
538
+ else:
539
+ logger.debug("Voice: calling agy_send...")
540
+ raw_ans = await self.agy_send(
541
+ conv_id, text_to_ai, model=sess.get("model"), thread_id=str(thread_id), stream_queue=queue
542
+ )
543
+ logger.debug("Voice: agy_send finished")
544
+
545
+ final_text = raw_ans
546
+ finally:
547
+ logger.debug("Voice: sending __END__ and waiting for consume_task")
548
+ await queue.put(("__END__", True))
549
+ await consume_task
550
+
551
+ logger.debug("Voice: calling send_agy_response...")
552
+ response_text = ctx.get("final_text", final_text)
553
+ await self.send_agy_response(
554
+ thread, response_text, sess, ctx=ctx, start_time=time.time(), conv_id=conv_id
555
+ )
556
+ logger.debug("Voice: send_agy_response finished")
557
+
558
+ if not self.bot_settings.get("tts_enabled", True):
559
+ self.stt_session.extend_active_window(str(guild_id))
560
+
561
+ except asyncio.CancelledError:
562
+ self.logger.debug(f"handle_stt_input task cancelled for thread {thread_id}")
563
+ except Exception as e:
564
+ self.logger.exception(f"Unhandled error in handle_stt_input: {e}")
565
+
566
+ def mark_tts_finished(self, guild_id: str):
567
+ self.stt_session.mark_tts_finished(guild_id)
568
+
569
+ async def handle_stt_partial(self, data: dict):
570
+ await self.stt_session.handle_stt_partial(data)
571
+
572
+ async def cancel_stt_partial(self, guild_id: str):
573
+ await self.stt_session.cancel_stt_partial(guild_id)