linkgravity 1.5.13 → 1.6.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/setup.js CHANGED
@@ -112,13 +112,16 @@ async function collectSessionScopes(existingScopes) {
112
112
  return scopes;
113
113
  }
114
114
 
115
- async function collectUserIds(existingIds, platformLabel) {
115
+ async function collectUserIds(existingIds, platformLabel, required = false) {
116
116
  const ids = [];
117
117
  const hasExisting = existingIds && existingIds.length > 0;
118
118
 
119
119
  p.note(
120
- 'ONLY these users can use the bot (leave completely empty to allow EVERYONE). ' +
121
- 'Not related to DMs - this only gates the channel/threads configured above.',
120
+ required
121
+ ? 'ONLY these users can use the bot. At least one is required - anyone who knows the ' +
122
+ 'bot username can message it, and there is no channel scope to fall back on.'
123
+ : 'ONLY these users can use the bot (leave completely empty to allow EVERYONE). ' +
124
+ 'Not related to DMs - this only gates the channel/threads configured above.',
122
125
  `Allowed ${platformLabel} Users`,
123
126
  );
124
127
 
@@ -143,7 +146,9 @@ async function collectUserIds(existingIds, platformLabel) {
143
146
  while (true) {
144
147
  const userId = await p.text({
145
148
  message: isFirst
146
- ? `${platformLabel} User ID to allow (leave empty to allow EVERYONE):`
149
+ ? required
150
+ ? `${platformLabel} User ID to allow (required - message @userinfobot to find yours):`
151
+ : `${platformLabel} User ID to allow (leave empty to allow EVERYONE):`
147
152
  : `Another ${platformLabel} user ID to allow (leave empty if done):`,
148
153
  });
149
154
  if (p.isCancel(userId)) {
@@ -151,7 +156,13 @@ async function collectUserIds(existingIds, platformLabel) {
151
156
  process.exit(0);
152
157
  }
153
158
 
154
- if (!userId) break;
159
+ if (!userId) {
160
+ if (required && isFirst) {
161
+ p.note(`At least one allowed user is required for ${platformLabel}.`, 'Required');
162
+ continue;
163
+ }
164
+ break;
165
+ }
155
166
  isFirst = false;
156
167
 
157
168
  ids.push(userId.trim());
@@ -334,7 +345,7 @@ async function configureTelegram(existingSettings) {
334
345
  const existingTelegramUserIds = existingSettings.telegram_allowed_user_ids
335
346
  ? splitIds(existingSettings.telegram_allowed_user_ids)
336
347
  : [];
337
- const telegramUserIds = await collectUserIds(existingTelegramUserIds, 'Telegram');
348
+ const telegramUserIds = await collectUserIds(existingTelegramUserIds, 'Telegram', true);
338
349
 
339
350
  const updates = {};
340
351
  if (telegramToken) updates.telegram_token = telegramToken;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "linkgravity",
3
- "version": "1.5.13",
3
+ "version": "1.6.0",
4
4
  "description": "Discord/Telegram bot bridge for the Antigravity (agy) CLI, with voice interaction support",
5
5
  "scripts": {
6
6
  "start": "node npm-scripts/run-dev.js",
@@ -0,0 +1,143 @@
1
+ import difflib
2
+ import os
3
+ import re
4
+ from pathlib import Path
5
+
6
+ MAX_LINES = 120
7
+ TAIL_LINES = 30
8
+ MAX_CHARS = 5000
9
+ CONTEXT_LINES = 3
10
+
11
+ PATH_KEYS = ("TargetFile", "AbsolutePath")
12
+
13
+ HUNK_HEADER = re.compile(r"^@@ -(\d+)(?:,\d+)? \+(\d+)")
14
+
15
+ MISSING = object()
16
+
17
+
18
+ def _existing_text(tool_input: dict):
19
+ path = next((tool_input[k] for k in PATH_KEYS if tool_input.get(k)), None)
20
+ if not path:
21
+ return MISSING, None
22
+ if not os.path.isfile(path):
23
+ return "", path
24
+ try:
25
+ return Path(path).read_text(encoding="utf-8"), path
26
+ except (OSError, UnicodeDecodeError):
27
+ return MISSING, path
28
+
29
+
30
+ def _find_replaced_text(tool_input: dict, haystack: str, replacement: str) -> str | None:
31
+ # agy doesn't say which key holds the replaced text, so candidates are checked against the
32
+ # file instead of trusted by name.
33
+ best = None
34
+ for key, value in tool_input.items():
35
+ if key in PATH_KEYS or not isinstance(value, str) or not value or value == replacement:
36
+ continue
37
+ if haystack.count(value) == 1 and (best is None or len(value) > len(best)):
38
+ best = value
39
+ return best
40
+
41
+
42
+ def _entries(old: list[str], new: list[str], first_line: int, width: int) -> list[tuple[int, bool, str]]:
43
+ out, old_no, new_no = [], first_line, first_line
44
+ for line in difflib.unified_diff(old, new, lineterm="", n=CONTEXT_LINES):
45
+ if line.startswith(("---", "+++")):
46
+ continue
47
+
48
+ header = HUNK_HEADER.match(line)
49
+ if header:
50
+ # difflib counts from the start of what it was given, which is a slice of the file
51
+ # for chunk edits.
52
+ old_no = int(header.group(1)) + first_line - 1
53
+ new_no = int(header.group(2)) + first_line - 1
54
+ continue
55
+
56
+ sign, body = line[0], line[1:]
57
+ number = old_no if sign == "-" else new_no
58
+ # A removed line still sits at new_no, but it doesn't occupy one, so it anchors the gap
59
+ # marker without advancing past it.
60
+ out.append((new_no, sign != "-", f"{sign} {number:>{width}} | {body}"))
61
+ if sign in " -":
62
+ old_no += 1
63
+ if sign in " +":
64
+ new_no += 1
65
+ return out
66
+
67
+
68
+ def _with_gaps(entries: list[tuple[int, bool, str]], total: int, width: int) -> list[str]:
69
+ def gap(count):
70
+ return f"{'':>{width + 5}}... ({count} unchanged {'line' if count == 1 else 'lines'})"
71
+
72
+ out, last = [], 0
73
+ for position, occupies, text in entries:
74
+ if position > last + 1:
75
+ out.append(gap(position - last - 1))
76
+ out.append(text)
77
+ last = position if occupies else max(last, position - 1)
78
+ if total > last:
79
+ out.append(gap(total - last))
80
+ return out
81
+
82
+
83
+ def _clip(lines: list[str]) -> str:
84
+ if len(lines) > MAX_LINES:
85
+ head = MAX_LINES - TAIL_LINES - 1
86
+ lines = [*lines[:head], f"... ({len(lines) - head - TAIL_LINES} more lines)", *lines[-TAIL_LINES:]]
87
+ kept, size = [], 0
88
+ for line in lines:
89
+ if size + len(line) > MAX_CHARS:
90
+ kept.append(f"... ({len(lines) - len(kept)} more lines)")
91
+ break
92
+ kept.append(line)
93
+ size += len(line) + 1
94
+ return "\n".join(kept)
95
+
96
+
97
+ def _render(name: str, entries, total: int, width: int) -> str | None:
98
+ if not entries:
99
+ return None
100
+ added = sum(1 for *_, text in entries if text.startswith("+"))
101
+ removed = sum(1 for *_, text in entries if text.startswith("-"))
102
+ return f"{name} · +{added} -{removed}\n{_clip(_with_gaps(entries, total, width))}"
103
+
104
+
105
+ def build_diff(tool_input: dict) -> str | None:
106
+ before, path = _existing_text(tool_input)
107
+ if before is MISSING:
108
+ return None
109
+ name = os.path.basename(path)
110
+ on_disk = os.path.isfile(path)
111
+
112
+ chunks = tool_input.get("ReplacementChunks")
113
+ if chunks:
114
+ if not on_disk:
115
+ return None
116
+ existing = before.splitlines()
117
+ total = len(existing)
118
+ entries, width = [], len(str(total))
119
+ for chunk in chunks:
120
+ start, end = chunk.get("StartLine"), chunk.get("EndLine")
121
+ replacement = chunk.get("ReplacementContent")
122
+ if not isinstance(start, int) or not isinstance(end, int) or replacement is None:
123
+ return None
124
+ replaced_lines = replacement.splitlines()
125
+ total += len(replaced_lines) - (end - start + 1)
126
+ entries += _entries(existing[start - 1 : end], replaced_lines, start, width)
127
+ return _render(name, entries, total, width)
128
+
129
+ if "CodeContent" in tool_input:
130
+ content = tool_input["CodeContent"]
131
+ else:
132
+ replacement = tool_input.get("ReplacementContent")
133
+ if replacement is None or not on_disk:
134
+ return None
135
+ replaced = _find_replaced_text(tool_input, before, replacement)
136
+ if replaced is None:
137
+ return None
138
+ content = before.replace(replaced, replacement, 1)
139
+
140
+ old_lines, new_lines = before.splitlines(), content.splitlines()
141
+ width = len(str(max(len(old_lines), len(new_lines))))
142
+ label = f"{name} (new file)" if not on_disk else name
143
+ return _render(label, _entries(old_lines, new_lines, 1, width), len(new_lines), width)
@@ -1,6 +1,8 @@
1
1
  import json
2
2
  import os
3
3
 
4
+ from approval.diff_preview import build_diff
5
+
4
6
  TOOL_DISPLAY_NAMES = {
5
7
  "view_file": "Read",
6
8
  "write_to_file": "Write",
@@ -39,14 +41,17 @@ def format_tool_display(tool_name: str, tool_input: dict) -> tuple[str, str, dic
39
41
  fields_text += f"**{k}**: {v}\n"
40
42
 
41
43
  code_text = ""
42
- if "CodeContent" in tool_input:
43
- code_text = f"\n**Code Content:**\n```python\n{tool_input['CodeContent'][:1000]}\n```"
44
+ diff = build_diff(tool_input)
45
+ if diff:
46
+ code_text = f"\n**Changes:**\n```diff\n{diff}\n```"
47
+ elif "CodeContent" in tool_input:
48
+ code_text = f"\n**Code Content:**\n```\n{tool_input['CodeContent'][:1000]}\n```"
44
49
  elif "ReplacementChunks" in tool_input:
45
50
  for i, chunk in enumerate(tool_input["ReplacementChunks"]):
46
51
  code_text += f"\n**Replacement Chunk #{i + 1} (Lines {chunk.get('StartLine')}-{chunk.get('EndLine')}):**\n"
47
- code_text += f"```python\n{chunk.get('ReplacementContent')[:500]}\n```"
52
+ code_text += f"```\n{chunk.get('ReplacementContent')[:500]}\n```"
48
53
  elif "ReplacementContent" in tool_input:
49
- code_text += f"\n**Replacement Content:**\n```python\n{tool_input['ReplacementContent'][:1000]}\n```"
54
+ code_text += f"\n**Replacement Content:**\n```\n{tool_input['ReplacementContent'][:1000]}\n```"
50
55
 
51
56
  if fields_text or code_text:
52
57
  desc_json = f"\n{fields_text}{code_text}"
@@ -18,6 +18,7 @@ from config import (
18
18
  save_bot_settings,
19
19
  session_manager,
20
20
  )
21
+ from core.agy_runner import clean_model_name
21
22
  from core.atomic_io import atomic_write_json, safe_load_json
22
23
  from utils.utils import get_default_cwd
23
24
 
@@ -35,7 +36,8 @@ def load_cached_models():
35
36
  "Claude Opus 4.6 (Thinking)",
36
37
  "GPT-OSS 120B (Medium)",
37
38
  ]
38
- return safe_load_json(MODELS_CACHE_FILE, default_models, logger=logger)
39
+ cached = safe_load_json(MODELS_CACHE_FILE, default_models, logger=logger)
40
+ return [clean_model_name(m) for m in cached]
39
41
 
40
42
 
41
43
  cached_models = load_cached_models()
@@ -79,7 +81,7 @@ async def fetch_models_background():
79
81
  line = line.strip()
80
82
  line = re.sub(r"[⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏]", "", line).strip()
81
83
  if line and "Fetching available models" not in line:
82
- models.append(line)
84
+ models.append(clean_model_name(line))
83
85
 
84
86
  if models:
85
87
  cached_models = models
@@ -1,5 +1,6 @@
1
1
  import asyncio
2
2
  import math
3
+ import re
3
4
  import time
4
5
 
5
6
  import aiohttp
@@ -9,6 +10,7 @@ from discord.ext import commands, tasks
9
10
 
10
11
  from config import allowed, logger
11
12
  from messengers.registry import get_adapter_for_platform
13
+ from services.audio_service import LANGUAGES, TTS_VOICES, default_voice_for, resolve_language
12
14
 
13
15
  from .voice.enrollment import EnrollmentManager
14
16
  from .voice.stt_session import SttSessionTracker
@@ -34,7 +36,6 @@ class VoiceCog(commands.Cog):
34
36
  def __init__(
35
37
  self,
36
38
  bot,
37
- stt,
38
39
  tts,
39
40
  send_agy_response,
40
41
  agy_send,
@@ -46,8 +47,6 @@ class VoiceCog(commands.Cog):
46
47
  logger,
47
48
  ):
48
49
  self.bot = bot
49
- # Unused by the voice pipeline now (STT moved to Node); kept for other callers.
50
- self.stt = stt
51
50
  self.tts = tts
52
51
  self.send_agy_response = send_agy_response
53
52
  self.agy_send = agy_send
@@ -140,37 +139,36 @@ class VoiceCog(commands.Cog):
140
139
  opts.append(app_commands.Choice(name=str(v), value=v))
141
140
  return opts
142
141
 
143
- async def tts_voice_autocomplete(
142
+ async def language_autocomplete(
144
143
  self, interaction: discord.Interaction, current: str
145
144
  ) -> list[app_commands.Choice[str]]:
146
- current_val = self.bot_settings.get("tts_voice", "en-US-AriaNeural")
147
- options = [
148
- "en-US-AriaNeural",
149
- "en-US-GuyNeural",
150
- "en-US-AnaNeural",
151
- "en-US-ChristopherNeural",
152
- "en-US-EricNeural",
153
- "en-US-MichelleNeural",
154
- "en-US-RogerNeural",
155
- "en-GB-SoniaNeural",
156
- "en-GB-RyanNeural",
157
- "en-AU-NatashaNeural",
158
- "en-AU-WilliamNeural",
159
- "ko-KR-SunHiNeural",
160
- "ko-KR-InJoonNeural",
161
- "ja-JP-NanamiNeural",
162
- "ja-JP-KeitaNeural",
163
- "fr-FR-DeniseNeural",
164
- "de-DE-KatjaNeural",
165
- "es-ES-ElviraNeural",
166
- ]
145
+ current_val = resolve_language()
146
+ query = _autocomplete_query(current).lower()
167
147
 
168
148
  choices = []
169
- if current.lower() in current_val.lower() or not current:
149
+ if query in current_val.lower() or not query:
170
150
  choices.append(app_commands.Choice(name=f"{current_val} (current)", value=current_val))
151
+ for opt in LANGUAGES:
152
+ if query in opt.lower() and opt != current_val and len(choices) < 25:
153
+ choices.append(app_commands.Choice(name=opt, value=opt))
154
+ return choices
171
155
 
172
- for opt in options:
173
- if current.lower() in opt.lower() and opt != current_val and len(choices) < 25:
156
+ async def tts_voice_autocomplete(
157
+ self, interaction: discord.Interaction, current: str
158
+ ) -> list[app_commands.Choice[str]]:
159
+ # Only same-language voices - a voice from another language would read the reply
160
+ # with that language's pronunciation.
161
+ language = resolve_language()
162
+ current_val = self.bot_settings.get("tts_voice") or default_voice_for(language)
163
+ query = _autocomplete_query(current).lower()
164
+
165
+ choices = []
166
+ if query in current_val.lower() or not query:
167
+ choices.append(app_commands.Choice(name=f"{current_val} (current)", value=current_val))
168
+ for opt in TTS_VOICES:
169
+ if not opt.startswith(f"{language}-") or opt == current_val:
170
+ continue
171
+ if query in opt.lower() and len(choices) < 25:
174
172
  choices.append(app_commands.Choice(name=opt, value=opt))
175
173
  return choices
176
174
 
@@ -292,12 +290,13 @@ class VoiceCog(commands.Cog):
292
290
 
293
291
  if own_word or not required:
294
292
  if self.bot_settings.get("tts_enabled", True):
295
- welcome_audio = await self.tts("Voice connected.")
293
+ welcome_audio = await self.tts("Voice connected.", cache=True)
296
294
  if welcome_audio:
297
295
  await self._play_audio(str(guild_id), welcome_audio, suppress_active_window=True)
298
296
  elif self.bot_settings.get("tts_enabled", True):
299
297
  prompt_audio = await self.tts(
300
- "No wake word is set up yet. Please use the sound command to set one."
298
+ "No wake word is set up yet. Please use the sound command to set one.",
299
+ cache=True,
301
300
  )
302
301
  if prompt_audio:
303
302
  await self._play_audio(str(guild_id), prompt_audio, suppress_active_window=True)
@@ -325,7 +324,8 @@ class VoiceCog(commands.Cog):
325
324
  active_times="Duration in seconds the bot stays awake",
326
325
  interrupt_threshold="Mic volume that interrupts (barges into) TTS playback (1000~10000)",
327
326
  wake_sensitivity="Wake word match sensitivity (0.1~0.9, lower = easier to trigger but more false wakes)",
328
- tts_voice="Select the AI TTS voice",
327
+ language="Language I listen and speak in (BCP-47, e.g. en-US)",
328
+ tts_voice="Pick a different voice within that language",
329
329
  tts_enabled="Turn Text-to-Speech ON or OFF",
330
330
  tts_speed="TTS playback speed multiplier, e.g. 1.3 for 1.3x (0.5~2.0)",
331
331
  require_wake_word="Require your wake word before I listen (default ON) - turn OFF if you use push-to-talk",
@@ -334,6 +334,7 @@ class VoiceCog(commands.Cog):
334
334
  active_times=active_times_autocomplete,
335
335
  interrupt_threshold=interrupt_threshold_autocomplete,
336
336
  wake_sensitivity=wake_sensitivity_autocomplete,
337
+ language=language_autocomplete,
337
338
  tts_voice=tts_voice_autocomplete,
338
339
  tts_enabled=tts_enabled_autocomplete,
339
340
  tts_speed=tts_speed_autocomplete,
@@ -346,6 +347,7 @@ class VoiceCog(commands.Cog):
346
347
  active_times: int = None,
347
348
  interrupt_threshold: int = None,
348
349
  wake_sensitivity: float = None,
350
+ language: str = None,
349
351
  tts_voice: str = None,
350
352
  tts_enabled: str = None,
351
353
  tts_speed: float = None,
@@ -362,6 +364,7 @@ class VoiceCog(commands.Cog):
362
364
  and active_times is None
363
365
  and interrupt_threshold is None
364
366
  and wake_sensitivity is None
367
+ and language is None
365
368
  and tts_voice is None
366
369
  and tts_enabled is None
367
370
  and tts_speed is None
@@ -371,7 +374,8 @@ class VoiceCog(commands.Cog):
371
374
  curr_timer = self.bot_settings.get("active_timer", 60)
372
375
  curr_interrupt_thresh = self._vad_threshold(interaction.user.id)
373
376
  curr_wake_sens = self._wake_threshold(interaction.user.id)
374
- curr_tts = self.bot_settings.get("tts_voice", "en-US-AriaNeural")
377
+ curr_lang = resolve_language()
378
+ curr_tts = self.bot_settings.get("tts_voice") or default_voice_for(curr_lang)
375
379
  curr_tts_on = "ON" if self.bot_settings.get("tts_enabled", True) else "OFF"
376
380
  curr_tts_speed = self.bot_settings.get("tts_speed", 1.0)
377
381
  curr_required = self._wake_word_required(interaction.user.id)
@@ -382,6 +386,7 @@ class VoiceCog(commands.Cog):
382
386
  embed.add_field(name="⏱️ Active Time", value=f"`{curr_timer}s`", inline=False)
383
387
  embed.add_field(name="🎯 Wake Sensitivity", value=f"`{curr_wake_sens}`", inline=False)
384
388
  embed.add_field(name="🔊 Interrupt Threshold", value=f"`{curr_interrupt_thresh}`", inline=False)
389
+ embed.add_field(name="🌐 Language", value=f"`{curr_lang}`", inline=False)
385
390
  embed.add_field(name="🗣️ TTS Voice", value=f"`{curr_tts}`", inline=False)
386
391
  embed.add_field(name="🔊 TTS Enabled", value=f"`{curr_tts_on}`", inline=False)
387
392
  embed.add_field(name="⏩ TTS Speed", value=f"`{curr_tts_speed}x`", inline=False)
@@ -420,6 +425,13 @@ class VoiceCog(commands.Cog):
420
425
  except (aiohttp.ClientError, asyncio.TimeoutError) as e:
421
426
  self.logger.warning(f"Node.js wake-threshold sync failed for {interaction.user.id}: {e}")
422
427
  updated.append(f"(⚠️ Node.js Sync Failed: {e})")
428
+ if language is not None:
429
+ self.bot_settings["language"] = language
430
+ self.bot_settings["tts_voice"] = default_voice_for(language)
431
+ updated.append(
432
+ f"🌐 Language: `{language}` (voice set to `{self.bot_settings['tts_voice']}`; "
433
+ "run `lgy restart` to apply it to speech recognition)"
434
+ )
423
435
  if tts_voice is not None:
424
436
  self.bot_settings["tts_voice"] = tts_voice
425
437
  updated.append(f"🗣️ TTS Voice: `{tts_voice}`")
@@ -532,7 +544,6 @@ class VoiceCog(commands.Cog):
532
544
  return
533
545
 
534
546
  import difflib
535
- import re
536
547
 
537
548
  # Wake detection is Node's Rustpotter detector's job - no text-similarity fallback.
538
549
  is_waking_up = bool(data.get("wake_confirmed"))
@@ -627,7 +638,7 @@ class VoiceCog(commands.Cog):
627
638
  if not text_to_ai:
628
639
  self.logger.debug("STT: isolated wake word handled via direct TTS")
629
640
  if self.bot_settings.get("tts_enabled", True):
630
- audio_reply = await self.tts("Yes, I am listening.")
641
+ audio_reply = await self.tts("Yes, I am listening.", cache=True)
631
642
  if audio_reply:
632
643
  await self._play_audio(str(guild_id), audio_reply)
633
644
  return
package/src/config.py CHANGED
@@ -31,6 +31,9 @@ DEFAULT_LGY_CONFIG = {
31
31
  "voice_thresholds": {},
32
32
  "active_timer": 60,
33
33
  "tts_voice": "ko-KR-SunHiNeural",
34
+ # BCP-47 tag driving both speech recognition and the TTS voice.
35
+ # Empty means derive it from tts_voice, which is how pre-1.6 configs carry their language.
36
+ "language": "",
34
37
  "tts_enabled": True,
35
38
  # Sticky default for /new sessions, set whenever /model succeeds.
36
39
  "default_model": "",
@@ -162,8 +165,10 @@ session_manager = SessionManager(DATA_DIR)
162
165
 
163
166
  def allowed(user_id, platform: str = "discord") -> bool:
164
167
  if platform == "telegram":
165
- ids = TELEGRAM_ALLOWED_IDS
166
- elif platform == "slack":
168
+ # Anyone who knows a Telegram bot's username can DM it, and there is no channel scope to
169
+ # fall back on, so an empty list must mean nobody rather than everyone.
170
+ return user_id in TELEGRAM_ALLOWED_IDS
171
+ if platform == "slack":
167
172
  ids = SLACK_ALLOWED_IDS
168
173
  user_id = str(user_id) # Slack IDs are strings, not ints like Discord/Telegram
169
174
  else:
@@ -339,13 +339,18 @@ async def run_agy(
339
339
  _intentionally_stopped.discard(thread_id)
340
340
 
341
341
 
342
+ def clean_model_name(model: str) -> str:
343
+ # agy models prints "<id>\t<display name>" but --model only accepts the display name.
344
+ return model.split("\t")[-1].strip()
345
+
346
+
342
347
  async def agy_new_conversation(
343
348
  content: str, model: str = None, stream_queue: asyncio.Queue = None, thread_id: str = None, cwd: str = None
344
349
  ) -> tuple[str, str]:
345
350
  # --print consumes the next token as the prompt, so the flag must come first.
346
351
  args = ["--dangerously-skip-permissions", "--print", content]
347
352
  if model:
348
- args.extend(["--model", model])
353
+ args.extend(["--model", clean_model_name(model)])
349
354
  result_text = await run_agy(*args, stream_queue=stream_queue, thread_id=thread_id, cwd=cwd)
350
355
  conv_id = await _get_latest_conversation_id()
351
356
  return result_text, conv_id
@@ -361,7 +366,7 @@ async def agy_send_message(
361
366
  ) -> str:
362
367
  args = ["--dangerously-skip-permissions", "--print", content, "--conversation", conv_id]
363
368
  if model:
364
- args.extend(["--model", model])
369
+ args.extend(["--model", clean_model_name(model)])
365
370
  return await run_agy(*args, stream_queue=stream_queue, thread_id=thread_id, cwd=cwd)
366
371
 
367
372
 
@@ -1,3 +1,6 @@
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
+
1
4
  import asyncio
2
5
  import atexit
3
6
  import os
@@ -14,7 +17,7 @@ from messengers.discord_adapter import DiscordAdapter
14
17
  from messengers.registry import register_adapter
15
18
  from services.response import send_agy_response
16
19
  from services.streaming import stream_thinking_latest
17
- from utils.utils import agy_new_conversation, agy_send_message, stt, tts
20
+ from utils.utils import agy_new_conversation, agy_send_message, tts
18
21
 
19
22
  voice_process = None
20
23
  _voice_shutting_down = False
@@ -287,7 +290,6 @@ async def run_discord(stop_event: asyncio.Event) -> None:
287
290
  await bot.add_cog(
288
291
  VoiceCog(
289
292
  bot=bot,
290
- stt=stt,
291
293
  tts=tts,
292
294
  send_agy_response=send_agy_response,
293
295
  agy_send=agy_send_message,
@@ -6,7 +6,15 @@ from pathlib import Path
6
6
  from telegram import BotCommand, InlineKeyboardButton, InlineKeyboardMarkup, Update
7
7
  from telegram.ext import Application, ApplicationBuilder, CallbackQueryHandler, CommandHandler, MessageHandler, filters
8
8
 
9
- from config import TELEGRAM_TOKEN, allowed, bot_settings, logger, save_bot_settings, session_manager
9
+ from config import (
10
+ TELEGRAM_ALLOWED_IDS,
11
+ TELEGRAM_TOKEN,
12
+ allowed,
13
+ bot_settings,
14
+ logger,
15
+ save_bot_settings,
16
+ session_manager,
17
+ )
10
18
  from core.atomic_io import atomic_write_json, safe_load_json
11
19
  from handlers.message_router import handle_message
12
20
  from messengers.registry import register_adapter
@@ -252,6 +260,14 @@ async def run_telegram(stop_event: asyncio.Event) -> None:
252
260
  logger.critical("Missing TELEGRAM_TOKEN - set telegram_token in lgy.json first.")
253
261
  return
254
262
 
263
+ if not TELEGRAM_ALLOWED_IDS:
264
+ logger.critical(
265
+ "No telegram_allowed_user_ids configured - refusing to start. A Telegram bot is "
266
+ "reachable by anyone who knows its username, so run 'lgy setup' and register your "
267
+ "user ID (message @userinfobot to find it)."
268
+ )
269
+ return
270
+
255
271
  app = build_application()
256
272
  logger.info("✅ Telegram bot starting (polling mode)...")
257
273
  await app.initialize()
@@ -7,7 +7,7 @@ from typing import Any
7
7
 
8
8
  import discord
9
9
 
10
- from config import logger
10
+ from config import allowed, logger
11
11
  from messengers.base import (
12
12
  IncomingAttachment,
13
13
  IncomingMessage,
@@ -21,6 +21,14 @@ from messengers.base import (
21
21
 
22
22
 
23
23
  class _ErrorLoggingView(discord.ui.View):
24
+ async def interaction_check(self, interaction: discord.Interaction) -> bool:
25
+ # discord.py lets anyone who can see the message press the button, so the allow-list has
26
+ # to be applied here as well as on the inbound message path.
27
+ if allowed(interaction.user.id):
28
+ return True
29
+ await interaction.response.send_message("⛔ You are not allowed to use this bot.", ephemeral=True)
30
+ return False
31
+
24
32
  async def on_error(self, interaction: discord.Interaction, error: Exception, item) -> None:
25
33
  logger.exception(f"Discord button interaction error: {error}")
26
34
  error_msg = "⚠️ **An error occurred while processing this button.** Please try again later or check the logs."
@@ -12,7 +12,7 @@ from slack_bolt.app.async_app import AsyncApp
12
12
  from slack_sdk.errors import SlackApiError
13
13
  from slack_sdk.web.async_client import AsyncWebClient
14
14
 
15
- from config import logger, session_manager
15
+ from config import allowed, logger, session_manager
16
16
  from messengers.base import (
17
17
  IncomingAttachment,
18
18
  IncomingMessage,
@@ -222,12 +222,28 @@ class SlackAdapter(MessengerAdapter):
222
222
  actions = body.get("actions") or []
223
223
  if not actions:
224
224
  return
225
+ if not await self._reject_unauthorized(body):
226
+ return
225
227
  action_id = actions[0].get("action_id")
226
228
  handler = self._callbacks.pop(action_id, None)
227
229
  if handler is None:
228
230
  return # expired/unknown action - nothing to do, Bolt already acked
229
231
  await handler(body, self.client)
230
232
 
233
+ async def _reject_unauthorized(self, body: dict) -> bool:
234
+ user_id = (body.get("user") or {}).get("id")
235
+ if allowed(user_id, "slack"):
236
+ return True
237
+ channel = (body.get("channel") or {}).get("id")
238
+ if channel and user_id:
239
+ try:
240
+ await self.client.chat_postEphemeral(
241
+ channel=channel, user=user_id, text="⛔ You are not allowed to use this bot."
242
+ )
243
+ except SlackApiError as e:
244
+ logger.warning(f"Failed to notify unauthorized Slack user {user_id}: {e}")
245
+ return False
246
+
231
247
  async def handle_view_submission(self, body: dict) -> None:
232
248
  callback_id = (body.get("view") or {}).get("callback_id")
233
249
  handler = self._view_callbacks.pop(callback_id, None)
@@ -14,7 +14,7 @@ from telegram.constants import ChatAction
14
14
  from telegram.error import TelegramError
15
15
  from telegram.ext import ContextTypes
16
16
 
17
- from config import logger
17
+ from config import allowed, logger
18
18
  from messengers.base import (
19
19
  IncomingAttachment,
20
20
  IncomingMessage,
@@ -162,6 +162,9 @@ class TelegramAdapter(MessengerAdapter):
162
162
  query = update.callback_query
163
163
  if query is None or query.data is None:
164
164
  return
165
+ if query.from_user is None or not allowed(query.from_user.id, "telegram"):
166
+ await query.answer("You are not allowed to use this bot.", show_alert=True)
167
+ return
165
168
  handler = self._callbacks.pop(query.data, None)
166
169
  if handler is None:
167
170
  await query.answer("This action has expired.", show_alert=True)
@@ -6,9 +6,105 @@ from pathlib import Path
6
6
 
7
7
  from config import TTS_VOICE, bot_settings, logger
8
8
 
9
+ # Verified against edge-tts's live voice list; a name that isn't in it fails at synthesis time.
10
+ TTS_VOICES = [
11
+ "en-US-AriaNeural",
12
+ "en-US-GuyNeural",
13
+ "en-US-AnaNeural",
14
+ "en-US-ChristopherNeural",
15
+ "en-US-EricNeural",
16
+ "en-US-MichelleNeural",
17
+ "en-US-RogerNeural",
18
+ "en-GB-SoniaNeural",
19
+ "en-GB-RyanNeural",
20
+ "en-AU-NatashaNeural",
21
+ "en-AU-WilliamMultilingualNeural",
22
+ "ko-KR-SunHiNeural",
23
+ "ko-KR-InJoonNeural",
24
+ "ja-JP-NanamiNeural",
25
+ "ja-JP-KeitaNeural",
26
+ "zh-CN-XiaoxiaoNeural",
27
+ "zh-CN-YunxiNeural",
28
+ "fr-FR-DeniseNeural",
29
+ "de-DE-KatjaNeural",
30
+ "es-ES-ElviraNeural",
31
+ "it-IT-ElsaNeural",
32
+ "pt-BR-FranciscaNeural",
33
+ "ru-RU-SvetlanaNeural",
34
+ "hi-IN-SwaraNeural",
35
+ "id-ID-GadisNeural",
36
+ "vi-VN-HoaiMyNeural",
37
+ "th-TH-PremwadeeNeural",
38
+ "tr-TR-EmelNeural",
39
+ "pl-PL-ZofiaNeural",
40
+ "nl-NL-ColetteNeural",
41
+ "ar-SA-ZariyahNeural",
42
+ ]
9
43
 
10
- async def tts(text: str, voice: str = None) -> bytes | None:
11
- voice = voice or bot_settings.get("tts_voice", TTS_VOICE)
44
+ LANGUAGES = list(dict.fromkeys(v.rsplit("-", 1)[0] for v in TTS_VOICES))
45
+
46
+ DEFAULT_LANGUAGE = "en-US"
47
+
48
+ # Ordered: the first script reaching the share threshold wins, so Hangul beats Latin in mixed
49
+ # Korean/English text, and kana beats Han in Japanese (which is written with both).
50
+ SCRIPT_PATTERNS = [
51
+ ("ko", re.compile(r"[가-힣]")),
52
+ ("ja", re.compile(r"[ぁ-ゖァ-ヺ]")),
53
+ ("ru", re.compile(r"[\u0400-\u04ff]")),
54
+ ("ar", re.compile(r"[\u0600-\u06ff]")),
55
+ ("th", re.compile(r"[\u0e00-\u0e7f]")),
56
+ ("hi", re.compile(r"[\u0900-\u097f]")),
57
+ ("zh", re.compile(r"[\u4e00-\u9fff]")),
58
+ ("en", re.compile(r"[a-zA-Z]")),
59
+ ]
60
+
61
+ SCRIPT_SHARE = 0.3
62
+
63
+
64
+ def detect_script(text: str) -> str | None:
65
+ counts = {tag: len(pattern.findall(text)) for tag, pattern in SCRIPT_PATTERNS}
66
+ total = sum(counts.values())
67
+ if not total:
68
+ return None
69
+ for tag, _ in SCRIPT_PATTERNS:
70
+ if counts[tag] / total >= SCRIPT_SHARE:
71
+ return tag
72
+ return max(counts, key=counts.get)
73
+
74
+
75
+ def default_voice_for(tag: str) -> str:
76
+ for voice in TTS_VOICES:
77
+ if voice.startswith(f"{tag}-"):
78
+ return voice
79
+ if "-" in tag:
80
+ return default_voice_for(tag.split("-")[0])
81
+ return TTS_VOICES[0]
82
+
83
+
84
+ def resolve_language() -> str:
85
+ configured = bot_settings.get("language")
86
+ if configured:
87
+ return configured
88
+ voice = bot_settings.get("tts_voice") or TTS_VOICE or ""
89
+ return voice.rsplit("-", 1)[0] if voice.count("-") >= 2 else DEFAULT_LANGUAGE
90
+
91
+
92
+ def voice_for(text: str) -> str:
93
+ # Latin script covers dozens of languages, so a configured en-GB voice must survive a bare
94
+ # "en" detection instead of being reset to the en-US default.
95
+ configured = bot_settings.get("tts_voice") or TTS_VOICE
96
+ script = detect_script(text)
97
+ if script is None or configured.startswith(f"{script}-"):
98
+ return configured
99
+ return default_voice_for(script)
100
+
101
+
102
+ # Synthesising a prompt costs a network round trip that lands directly in the wake-word
103
+ # response time, so the fixed ones are kept rather than rebuilt.
104
+ _fixed_phrase_cache: dict[tuple[str, str, str], bytes] = {}
105
+
106
+
107
+ async def tts(text: str, voice: str = None, cache: bool = False) -> bytes | None:
12
108
  try:
13
109
  import edge_tts
14
110
 
@@ -17,29 +113,25 @@ async def tts(text: str, voice: str = None) -> bytes | None:
17
113
  clean = re.sub(r"\n+", ". ", clean).strip()[:800]
18
114
  if not clean:
19
115
  return None
20
- hangul_chars = len(re.findall(r"[가-힣]", clean))
21
- alpha_chars = len(re.findall(r"[a-zA-Z]", clean))
22
- total_letters = hangul_chars + alpha_chars
23
- ko_voice = os.getenv("TTS_VOICE_KO", "ko-KR-SunHiNeural")
24
- en_voice = os.getenv("TTS_VOICE_EN", "en-US-AriaNeural")
25
-
26
- if total_letters == 0:
27
- active_voice = TTS_VOICE
28
- elif (hangul_chars / total_letters) >= 0.3:
29
- active_voice = ko_voice
30
- else:
31
- active_voice = en_voice
32
116
 
33
117
  speed = bot_settings.get("tts_speed", 1.0)
34
118
  pct = round((speed - 1.0) * 100)
35
119
  rate = f"{'+' if pct >= 0 else ''}{pct}%"
36
120
 
121
+ active_voice = voice or voice_for(clean)
122
+
123
+ key = (clean, active_voice, rate)
124
+ if cache and key in _fixed_phrase_cache:
125
+ return _fixed_phrase_cache[key]
126
+
37
127
  communicate = edge_tts.Communicate(clean, active_voice, rate=rate)
38
128
  with tempfile.NamedTemporaryFile(suffix=".mp3", delete=False) as f:
39
129
  tmp = f.name
40
130
  await communicate.save(tmp)
41
131
  data = Path(tmp).read_bytes()
42
132
  os.unlink(tmp)
133
+ if cache:
134
+ _fixed_phrase_cache[key] = data
43
135
  return data
44
136
  except Exception as e:
45
137
  logger.exception(f"TTS error: {e}")
@@ -58,7 +150,7 @@ async def stt(audio_bytes: bytes) -> str | None:
58
150
  with sr.AudioFile(io.BytesIO(audio_bytes)) as source:
59
151
  audio = r.record(source)
60
152
  try:
61
- return r.recognize_google(audio, language="ko-KR")
153
+ return r.recognize_google(audio, language=resolve_language())
62
154
  except sr.UnknownValueError:
63
155
  return None
64
156
  except sr.RequestError as e:
@@ -1,9 +1,20 @@
1
1
  const { spawn } = require('child_process');
2
2
  const ffmpegPath = require('ffmpeg-static');
3
+ const { aglConfig } = require('./config');
3
4
 
4
5
  // Unofficial Google STT key - same default Python's SpeechRecognition (recognize_google) ships with.
5
6
  const GOOGLE_STT_KEY = 'AIzaSyBOti4mM-6x9WDnZIjIeyEU21OpBXqWBgw';
6
7
 
8
+ // edge-tts voice names are <lang>-<REGION>-<Name>, so a config predating the language setting
9
+ // carries its language in tts_voice alone.
10
+ function resolveLanguage() {
11
+ if (aglConfig.language) return aglConfig.language;
12
+ const match = /^([a-z]{2}-[A-Z]{2})-/.exec(aglConfig.tts_voice || '');
13
+ return match ? match[1] : 'en-US';
14
+ }
15
+
16
+ const LANGUAGE = resolveLanguage();
17
+
7
18
  function flacEncode(wavBuffer) {
8
19
  return new Promise((resolve, reject) => {
9
20
  const ff = spawn(ffmpegPath, [
@@ -29,7 +40,8 @@ function flacEncode(wavBuffer) {
29
40
  });
30
41
  }
31
42
 
32
- async function googleSTT(wavBuffer, lang = 'ko-KR') {
43
+ async function googleSTT(wavBuffer, lang = LANGUAGE) {
44
+ const startedAt = Date.now();
33
45
  let flacBuffer;
34
46
  try {
35
47
  flacBuffer = await flacEncode(wavBuffer);
@@ -38,6 +50,8 @@ async function googleSTT(wavBuffer, lang = 'ko-KR') {
38
50
  return null;
39
51
  }
40
52
 
53
+ const encodedAt = Date.now();
54
+
41
55
  let res;
42
56
  try {
43
57
  res = await fetch(
@@ -54,6 +68,11 @@ async function googleSTT(wavBuffer, lang = 'ko-KR') {
54
68
  }
55
69
 
56
70
  const raw = await res.text();
71
+ console.debug(
72
+ `[STT] ${Math.round(wavBuffer.length / 192)}ms audio: flac ${encodedAt - startedAt}ms, ` +
73
+ `google ${Date.now() - encodedAt}ms`,
74
+ );
75
+ console.debug('[STT] raw:', raw.trim().replace(/\n/g, ' | '));
57
76
  // Response is newline-delimited JSON, one object per line.
58
77
  for (const line of raw.trim().split('\n')) {
59
78
  if (!line) continue;