linkgravity 1.5.14 → 1.6.1

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 CHANGED
@@ -49,6 +49,9 @@ function runPm2(args, silent = true) {
49
49
  ...process.env,
50
50
  // pm2 gives Python a pipe not a TTY, so it block-buffers stdout and can sit on log lines indefinitely - force line buffering.
51
51
  PYTHONUNBUFFERED: '1',
52
+ // pm2 merges --update-env rather than replacing, so a LOG_LEVEL from an earlier run
53
+ // survives unless a value is passed every time.
54
+ LOG_LEVEL: process.env.LOG_LEVEL || 'INFO',
52
55
  // Version managers (fnm, nvm) put node on PATH from a shell hook the daemon never runs,
53
56
  // so the bot's own `node` lookup for voice-service would fail without this.
54
57
  PATH: `${path.dirname(process.execPath)}${path.delimiter}${process.env.PATH || ''}`,
@@ -106,7 +109,7 @@ function runSudoStepThen(sudoCommand, successMessage) {
106
109
  }
107
110
  }
108
111
 
109
- // Matches a leading timestamp from either loguru or aiohttp's access-log format; only strips the first bracket group so aiohttp's second "[INFO ]" bracket is left alone.
112
+ // Matches a leading timestamp from either loguru or aiohttp's access-log format.
110
113
  const TIMESTAMP_PREFIX = /^\[?\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}:\d{2}\]?\s*/;
111
114
  // loguru's colorize=True puts an ANSI code before the timestamp digits, breaking the '^' anchor above.
112
115
  // eslint-disable-next-line no-control-regex
@@ -390,7 +393,15 @@ if (cmd === 'version' || cmd === '-v' || cmd === '--version') {
390
393
  }
391
394
 
392
395
  info('Starting LinkGravity daemon...');
393
- runPm2(['start', LGY_SCRIPT_PATH, '--interpreter', pythonExe, '--name', LGY_PM2_NAME]);
396
+ runPm2([
397
+ 'start',
398
+ LGY_SCRIPT_PATH,
399
+ '--interpreter',
400
+ pythonExe,
401
+ '--name',
402
+ LGY_PM2_NAME,
403
+ '--update-env',
404
+ ]);
394
405
  verifyStartup().then((ok) => process.exit(ok ? 0 : 1));
395
406
  } else if (cmd === 'stop') {
396
407
  info('Stopping LinkGravity daemon...');
@@ -616,7 +627,15 @@ if (cmd === 'version' || cmd === '-v' || cmd === '--version') {
616
627
 
617
628
  if (!procBeforeUpdate) {
618
629
  info("Daemon wasn't running - starting it fresh...");
619
- runPm2(['start', LGY_SCRIPT_PATH, '--interpreter', pythonExe, '--name', LGY_PM2_NAME]);
630
+ runPm2([
631
+ 'start',
632
+ LGY_SCRIPT_PATH,
633
+ '--interpreter',
634
+ pythonExe,
635
+ '--name',
636
+ LGY_PM2_NAME,
637
+ '--update-env',
638
+ ]);
620
639
  verifyStartup().then((ok) => process.exit(ok ? 0 : 1));
621
640
  } else if (wasOnline) {
622
641
  info('Restarting daemon to apply the update...');
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.14",
3
+ "version": "1.6.1",
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",
@@ -7,6 +7,7 @@ import uuid
7
7
  from aiohttp import web
8
8
 
9
9
  from api.server import is_tool_allowed
10
+ from approval.protected_paths import protected_reason
10
11
  from config import APPROVAL_TIMEOUT_SEC, MAX_EMBED_LEN, logger, session_manager
11
12
  from messengers.base import ScopeOption
12
13
  from messengers.registry import get_adapter_for_platform, get_adapter_for_thread
@@ -87,6 +88,12 @@ async def handle_approve_request(request):
87
88
  # DEBUG-only (see logger.py's LOG_LEVEL). Silent by default.
88
89
  logger.debug(f"[APPROVE HOOK] tool_name={tool_name!r} conv_id={conv_id!r} tool_input={tool_input!r}")
89
90
 
91
+ # Ahead of the auto-allow lookup: this must not be overridable by a persistent grant.
92
+ blocked = protected_reason(tool_input)
93
+ if blocked:
94
+ logger.warning(f"Blocked {tool_name} touching protected config: {tool_input!r}")
95
+ return web.json_response({"decision": "deny", "reason": blocked})
96
+
90
97
  target_thread = None
91
98
  target_thread_id = None
92
99
  for thread_id_str, sess in session_manager.get_all_sessions().items():
@@ -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)
@@ -0,0 +1,42 @@
1
+ import os
2
+ import re
3
+ from pathlib import Path
4
+
5
+ from config import WORKSPACE_DIR
6
+
7
+ AGENT_SUBDIR = "workspace"
8
+
9
+ _DIR_MARKER = f"{WORKSPACE_DIR.parent.name}/{WORKSPACE_DIR.name}"
10
+
11
+ # Shell commands arrive as one opaque string, so they are matched textually rather than resolved.
12
+ CONFIG_DIR_RE = re.compile(re.escape(_DIR_MARKER) + rf"(?!/{re.escape(AGENT_SUBDIR)}\b)")
13
+
14
+ NAME_RE = re.compile(r"(?<![\w.-])(?:lgy\.json|persistent_tools\.json|approve_token)(?![\w.])")
15
+
16
+ REASON = "LinkGravity's own configuration is off limits - it holds the bot tokens."
17
+
18
+
19
+ def _is_inside_config(value: str) -> bool:
20
+ try:
21
+ resolved = Path(os.path.expandvars(os.path.expanduser(value))).resolve()
22
+ except (OSError, ValueError):
23
+ return False
24
+ root = WORKSPACE_DIR.resolve()
25
+ return resolved.is_relative_to(root) and not resolved.is_relative_to(root / AGENT_SUBDIR)
26
+
27
+
28
+ def protected_reason(tool_input) -> str | None:
29
+ if isinstance(tool_input, list):
30
+ return next((r for r in map(protected_reason, tool_input) if r), None)
31
+ if not isinstance(tool_input, dict):
32
+ return None
33
+
34
+ for value in tool_input.values():
35
+ if isinstance(value, (dict, list)):
36
+ nested = protected_reason(value)
37
+ if nested:
38
+ return nested
39
+ elif isinstance(value, str) and value:
40
+ if NAME_RE.search(value) or CONFIG_DIR_RE.search(value) or _is_inside_config(value):
41
+ return REASON
42
+ return None
@@ -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}"
@@ -290,12 +290,13 @@ class VoiceCog(commands.Cog):
290
290
 
291
291
  if own_word or not required:
292
292
  if self.bot_settings.get("tts_enabled", True):
293
- welcome_audio = await self.tts("Voice connected.")
293
+ welcome_audio = await self.tts("Voice connected.", cache=True)
294
294
  if welcome_audio:
295
295
  await self._play_audio(str(guild_id), welcome_audio, suppress_active_window=True)
296
296
  elif self.bot_settings.get("tts_enabled", True):
297
297
  prompt_audio = await self.tts(
298
- "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,
299
300
  )
300
301
  if prompt_audio:
301
302
  await self._play_audio(str(guild_id), prompt_audio, suppress_active_window=True)
@@ -637,7 +638,7 @@ class VoiceCog(commands.Cog):
637
638
  if not text_to_ai:
638
639
  self.logger.debug("STT: isolated wake word handled via direct TTS")
639
640
  if self.bot_settings.get("tts_enabled", True):
640
- audio_reply = await self.tts("Yes, I am listening.")
641
+ audio_reply = await self.tts("Yes, I am listening.", cache=True)
641
642
  if audio_reply:
642
643
  await self._play_audio(str(guild_id), audio_reply)
643
644
  return
package/src/config.py CHANGED
@@ -165,8 +165,10 @@ session_manager = SessionManager(DATA_DIR)
165
165
 
166
166
  def allowed(user_id, platform: str = "discord") -> bool:
167
167
  if platform == "telegram":
168
- ids = TELEGRAM_ALLOWED_IDS
169
- 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":
170
172
  ids = SLACK_ALLOWED_IDS
171
173
  user_id = str(user_id) # Slack IDs are strings, not ints like Discord/Telegram
172
174
  else:
@@ -6,6 +6,17 @@ from sys import stdout
6
6
  from loguru import logger
7
7
 
8
8
 
9
+ class _InterceptHandler(logging.Handler):
10
+ def emit(self, record: logging.LogRecord) -> None:
11
+ try:
12
+ level = logger.level(record.levelname).name
13
+ except ValueError:
14
+ level = record.levelno
15
+ # Without patching, {name} resolves to this file's frame instead of the library that logged.
16
+ patched = logger.patch(lambda r, name=record.name: r.update(name=name))
17
+ patched.opt(exception=record.exc_info).log(level, record.getMessage())
18
+
19
+
9
20
  def init_logger(workspace_dir: Path):
10
21
  logging.getLogger("discord").setLevel(logging.WARNING)
11
22
  # httpx is what python-telegram-bot uses under the hood for every getUpdates
@@ -22,6 +33,9 @@ def init_logger(workspace_dir: Path):
22
33
  # Defaults to INFO - set LOG_LEVEL=DEBUG then `lgy restart` for
23
34
  # verbose detail (e.g. agy_runner.py's raw agy stdout capture).
24
35
  level = os.environ.get("LOG_LEVEL", "INFO").upper()
36
+ # force=True drops handlers third-party libraries install for themselves, which otherwise
37
+ # print in their own format alongside loguru's.
38
+ logging.basicConfig(handlers=[_InterceptHandler()], level=getattr(logging, level, logging.INFO), force=True)
25
39
  logger.add(stdout, level=level, format=log_format, colorize=True)
26
40
  logger.add(
27
41
  LOG_DIR / "bot.log",
@@ -281,8 +281,6 @@ async def run_discord(stop_event: asyncio.Event) -> None:
281
281
  logger.critical("Missing DISCORD_TOKEN - run `lgy setup`")
282
282
  return
283
283
 
284
- discord.utils.setup_logging()
285
-
286
284
  async with bot:
287
285
  from cogs.voice_cog import VoiceCog
288
286
  from config import bot_settings, save_bot_settings
@@ -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,13 +222,33 @@ 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
+ logger.warning(f"Rejected Slack interaction from unauthorized user {user_id}")
238
+ # Modal submissions carry no channel, so there is nowhere to post the notice.
239
+ channel = (body.get("channel") or {}).get("id")
240
+ if channel and user_id:
241
+ try:
242
+ await self.client.chat_postEphemeral(
243
+ channel=channel, user=user_id, text="⛔ You are not allowed to use this bot."
244
+ )
245
+ except SlackApiError as e:
246
+ logger.warning(f"Failed to notify unauthorized Slack user {user_id}: {e}")
247
+ return False
248
+
231
249
  async def handle_view_submission(self, body: dict) -> None:
250
+ if not await self._reject_unauthorized(body):
251
+ return
232
252
  callback_id = (body.get("view") or {}).get("callback_id")
233
253
  handler = self._view_callbacks.pop(callback_id, None)
234
254
  if handler is 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)
@@ -7,7 +7,6 @@ from pathlib import Path
7
7
  from config import TTS_VOICE, bot_settings, logger
8
8
 
9
9
  # Verified against edge-tts's live voice list; a name that isn't in it fails at synthesis time.
10
- # Ordered - the first voice of a language is that language's default.
11
10
  TTS_VOICES = [
12
11
  "en-US-AriaNeural",
13
12
  "en-US-GuyNeural",
@@ -83,7 +82,6 @@ def default_voice_for(tag: str) -> str:
83
82
 
84
83
 
85
84
  def resolve_language() -> str:
86
- # Mirrored by resolveLanguage() in voice-service/stt.js, which is what reaches Google.
87
85
  configured = bot_settings.get("language")
88
86
  if configured:
89
87
  return configured
@@ -101,7 +99,12 @@ def voice_for(text: str) -> str:
101
99
  return default_voice_for(script)
102
100
 
103
101
 
104
- async def tts(text: str, voice: str = None) -> bytes | None:
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:
105
108
  try:
106
109
  import edge_tts
107
110
 
@@ -115,12 +118,20 @@ async def tts(text: str, voice: str = None) -> bytes | None:
115
118
  pct = round((speed - 1.0) * 100)
116
119
  rate = f"{'+' if pct >= 0 else ''}{pct}%"
117
120
 
118
- communicate = edge_tts.Communicate(clean, voice or voice_for(clean), rate=rate)
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
+
127
+ communicate = edge_tts.Communicate(clean, active_voice, rate=rate)
119
128
  with tempfile.NamedTemporaryFile(suffix=".mp3", delete=False) as f:
120
129
  tmp = f.name
121
130
  await communicate.save(tmp)
122
131
  data = Path(tmp).read_bytes()
123
132
  os.unlink(tmp)
133
+ if cache:
134
+ _fixed_phrase_cache[key] = data
124
135
  return data
125
136
  except Exception as e:
126
137
  logger.exception(f"TTS error: {e}")
@@ -5,8 +5,8 @@ const { aglConfig } = require('./config');
5
5
  // Unofficial Google STT key - same default Python's SpeechRecognition (recognize_google) ships with.
6
6
  const GOOGLE_STT_KEY = 'AIzaSyBOti4mM-6x9WDnZIjIeyEU21OpBXqWBgw';
7
7
 
8
- // Mirrored by resolve_language() in src/services/audio_service.py. edge-tts voice names are
9
- // <lang>-<REGION>-<Name>, so a pre-1.6 config carries its language in tts_voice alone.
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
10
  function resolveLanguage() {
11
11
  if (aglConfig.language) return aglConfig.language;
12
12
  const match = /^([a-z]{2}-[A-Z]{2})-/.exec(aglConfig.tts_voice || '');
@@ -41,6 +41,7 @@ function flacEncode(wavBuffer) {
41
41
  }
42
42
 
43
43
  async function googleSTT(wavBuffer, lang = LANGUAGE) {
44
+ const startedAt = Date.now();
44
45
  let flacBuffer;
45
46
  try {
46
47
  flacBuffer = await flacEncode(wavBuffer);
@@ -49,6 +50,8 @@ async function googleSTT(wavBuffer, lang = LANGUAGE) {
49
50
  return null;
50
51
  }
51
52
 
53
+ const encodedAt = Date.now();
54
+
52
55
  let res;
53
56
  try {
54
57
  res = await fetch(
@@ -65,6 +68,10 @@ async function googleSTT(wavBuffer, lang = LANGUAGE) {
65
68
  }
66
69
 
67
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
+ );
68
75
  console.debug('[STT] raw:', raw.trim().replace(/\n/g, ' | '));
69
76
  // Response is newline-delimited JSON, one object per line.
70
77
  for (const line of raw.trim().split('\n')) {