linkgravity 1.5.14 → 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.14",
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}"
@@ -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,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)
@@ -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')) {