linkgravity 1.4.0 → 1.5.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/README.md CHANGED
@@ -1,33 +1,66 @@
1
1
  # LinkGravity
2
2
 
3
- A Discord bot interface for the Antigravity agentic AI system. It translates Antigravity CLI prompts into Discord UI components and provides voice interaction capabilities.
3
+ A Discord, Telegram, and Slack bot interface for the Antigravity agentic AI system. It translates Antigravity CLI prompts into chat UI components and provides voice interaction capabilities.
4
4
 
5
5
  ## Features
6
6
 
7
7
  - **Environment Sync:** Automatically syncs with the host's `~/.gemini` configuration.
8
8
  - **Voice Interaction:** Supports voice channels with adaptive voice activity detection to segment speech and filter environmental noise, plus live "listening..." feedback while you're still talking.
9
- - **Wake Word Recognition:** Uses phoneme-level similarity to detect wake words and activate voice commands.
10
- - **Approval Flow:** Command and tool-call approvals become interactive Discord buttons. Chained shell commands are approved individually, and any approval can be scoped to auto-allow that command or tool going forward - something plain `agy` doesn't do.
9
+ - **Wake Word Recognition:** Uses phoneme-level similarity to detect wake words and activate voice commands.
10
+ - **Approval Flow:** Command and tool-call approvals become interactive chat buttons. Chained shell commands are approved individually, and any approval can be scoped to auto-allow that command or tool going forward - something plain `agy` doesn't do.
11
11
  - **Multi-Modal Input:** Attach files for the AI to read, including audio, which gets transcribed to text automatically.
12
12
 
13
+ ## Supported Platforms
14
+
15
+ | | Discord | Telegram | Slack |
16
+ | ------------------------- | ------- | -------- | ------- |
17
+ | Sessions | Threads | Flat | Threads |
18
+ | Voice | ✓ | ✗ | ✗ |
19
+ | Approval buttons | ✓ | ✓ | ✓ |
20
+ | File attachments | ✓ | ✓ | ✓ |
21
+ | Session title auto-rename | ✓ | ✗ | ✓ |
22
+ | DMs | ✓ | ✓ | ✓ |
23
+ | Group | ✓ | ✗ | ✓ |
24
+
13
25
  ## Requirements
14
26
 
15
27
  - Node.js >= 18
16
28
  - Python >= 3.10
17
29
  - Antigravity CLI installed on this machine
18
- - A messenger bot token, and at least one server/channel to allow it in
19
- - **Discord** - currently the only one supported
30
+ - A messenger bot token, and at least one server/channel to allow it in - Discord, Telegram, and Slack are all supported, and you can enable more than one at once
20
31
 
21
32
  ### Creating the Discord bot
22
33
 
23
- In the [Discord Developer Portal](https://discord.com/developers/applications), create an application and bot, then:
34
+ In the [Discord Developer Portal](https://discord.com/developers/applications):
35
+
36
+ 1. **New Application**, name it, then go to **Bot** in the left sidebar.
37
+ 2. Under **Privileged Gateway Intents**, enable **Message Content Intent** - required, since the bot reads message text/attachments.
38
+ 3. Click **Reset Token** to reveal the bot token, and copy it - this is what goes into `lgy setup`'s `discord_token`.
39
+ 4. Go to **OAuth2 > URL Generator** in the sidebar. Under **Scopes**, check **bot** and **applications.commands**. Under the **Bot Permissions** box that appears below, check:
40
+ - Send Messages, Send Messages in Threads, Create Public Threads
41
+ - Read Message History, Attach Files, Embed Links, Add Reactions
42
+ - Connect, Speak (for voice channel support)
43
+ 5. Copy the **Generated URL** at the bottom of that page, open it in a browser, and invite the bot to your server.
44
+
45
+ ### Creating the Telegram bot
24
46
 
25
- - Under **Bot**, enable the **Message Content** privileged intent - required, since the bot reads message text/attachments.
26
- - Under **OAuth2 → URL Generator**, select the **bot** and **applications.commands** scopes, then these bot permissions:
27
- - Send Messages, Send Messages in Threads, Create Public Threads
28
- - Read Message History, Attach Files, Embed Links, Add Reactions
29
- - Connect, Speak - for voice channel support
30
- - Use the generated URL to invite the bot to your server.
47
+ Message [@BotFather](https://t.me/BotFather) on Telegram, send `/newbot`, and follow the prompts to get a bot token. No further permission setup is needed - Telegram sessions are 1 chat = 1 session, so just message your bot directly (or add it to a group) and run `/new`.
48
+
49
+ ### Creating the Slack app
50
+
51
+ Slack has more moving parts than the others - two separate tokens, and a few settings pages that gate each other. Going in this order avoids re-doing steps:
52
+
53
+ 1. Go to [api.slack.com/apps](https://api.slack.com/apps) > **Create New App** > **From scratch**, name it, and pick your workspace.
54
+ 2. **Socket Mode** (left sidebar) > toggle it on. This avoids needing a public HTTP endpoint. Slack will prompt you to generate an app-level token here - name it anything, add the `connections:write` scope, and **Generate**. Copy this token (starts with `xapp-`) - this is `slack_app_token`.
55
+ - If it doesn't prompt you, go to **Basic Information > App-Level Tokens > Generate Token and Scopes** instead.
56
+ 3. **OAuth & Permissions** (left sidebar) > scroll to **Scopes > Bot Token Scopes** (not **User Token Scopes** - that's a different section further up the page, for a different token, and is not used here). **Add an OAuth Scope** for each of: `chat:write`, `channels:history`, `groups:history`, `im:history`, `mpim:history`, `reactions:write`, `files:read`, `files:write`.
57
+ 4. Scroll to the top of that same page > **Install to Workspace** > **Allow**. This generates the token under **OAuth Tokens > Bot User OAuth Token**, starting with `xoxb-`. Copy that one - this is `slack_bot_token`.
58
+ - It's easy to grab the wrong token here - the page also shows a **User OAuth Token** (`xoxp-...`) further down, which is a different thing and won't work for this bot.
59
+ 5. **App Home** (left sidebar) > under **Show Tabs**, turn on **Messages Tab**, then check **Allow users to send Slash commands and messages from the messages tab** - this is what lets you DM the bot at all. (If this section looks greyed out, it's because step 3 hasn't been saved/installed yet - go back and do that first.)
60
+ 6. **Event Subscriptions** (left sidebar) > toggle **Enable Events** on > under **Subscribe to bot events**, add `message.channels`, `message.groups`, `message.im`, and `message.mpim` > **Save Changes**.
61
+ 7. **Slash Commands** (left sidebar) > **Create New Command**, three times, for `/new`, `/model`, and `/credit` (any description/hint text is fine - only the command name matters).
62
+ 8. Back on **OAuth & Permissions**, since scopes/events changed after the initial install, click **Reinstall to Workspace** to push those changes live. Any time you change scopes or events later, you'll need to repeat this step.
63
+ 9. In Slack itself, for any **channel** (not DM) you want the bot usable in, run `/invite @<your bot's name>` there first - the bot can't post in a channel it hasn't been added to.
31
64
 
32
65
  ## Installation
33
66
 
@@ -39,20 +72,22 @@ Sets up its own Python environment automatically - no manual `pip install` neede
39
72
 
40
73
  ## Setup
41
74
 
42
- Run the configuration wizard once to set your bot token, allowed servers/channels, and other settings:
75
+ Run the configuration wizard once to pick which platform(s) to enable and set their tokens, allowed users, and other settings:
43
76
 
44
77
  ```bash
45
78
  lgy setup
46
79
  ```
47
80
 
48
- This writes to `~/.gemini/linkgravity/lgy.json`, outside the package directory, so `npm update`/reinstall never touches it. You can re-run `lgy setup` any time to change settings later - each field keeps its current value if you leave it empty.
81
+ This writes to `~/.gemini/linkgravity/lgy.json`, outside the package directory, so `npm update`/reinstall never touches it. You can re-run `lgy setup` any time to change settings later - each field keeps its current value if you leave it empty. Discord, Telegram, and Slack can all be turned on independently; the bot runs whichever ones are enabled in a single shared process.
49
82
 
50
- During setup you'll be asked for one or more Discord servers to allow, and optionally specific channels within each:
83
+ For Discord, during setup you'll be asked for one or more servers to allow, and optionally specific channels within each:
51
84
 
52
85
  - Leave the channel list empty for a server → **the whole server** is allowed - any channel can start a session.
53
86
  - List specific channel IDs for a server → **only those channels** in that server are allowed.
54
87
 
55
- A new session is only ever started with the **`/new`** slash command in Discord - never just by typing a message. `/new` works both in a regular channel and from inside an existing thread.
88
+ Telegram and Slack have no server/channel gating yet - every chat/channel you message the bot from can start a session, subject to the allowed-users list you set during setup.
89
+
90
+ A new session is started with the **`/new`** slash command - never just by typing a message. On Discord and Slack, `/new` works both in a regular channel and from inside an existing thread; on Telegram, it applies to whichever chat you send it in.
56
91
 
57
92
  ## Usage
58
93
 
@@ -86,6 +121,6 @@ Use `lgy logs -t` to include timestamps.
86
121
 
87
122
  This bot gives an AI agent broad access to the machine it runs on - **that's inherent to what it does, so don't expose it publicly or run it somewhere you don't fully trust its users.**
88
123
 
89
- - `allowed_user_ids`, set via `lgy setup`, is your primary access control - always set it.
90
- - Tool calls, including shell commands, go through an approval flow in Discord by default; treat anyone in `allowed_user_ids` as having effectively full control of this machine.
91
- - Your Discord token and other settings live in `~/.gemini/linkgravity/lgy.json`, outside this repo/package directory - never commit or share that file.
124
+ - The allowed-users list for each platform, set via `lgy setup`, is your primary access control - always set it.
125
+ - Tool calls, including shell commands, go through an approval flow by default; treat anyone on an allowed-users list as having effectively full control of this machine.
126
+ - Your bot tokens and other settings live in `~/.gemini/linkgravity/lgy.json`, outside this repo/package directory - never commit or share that file.
package/bin/cli.js CHANGED
@@ -8,6 +8,7 @@ const {
8
8
  getSettings,
9
9
  platformState,
10
10
  getSessions,
11
+ getPlatformHealth,
11
12
  LGY_PM2_NAME,
12
13
  LGY_SCRIPT_PATH,
13
14
  } = require('./platforms');
@@ -289,6 +290,7 @@ if (cmd === 'version' || cmd === '-v' || cmd === '--version') {
289
290
  } else if (cmd === 'status') {
290
291
  const settings = getSettings();
291
292
  const sessions = getSessions();
293
+ const health = getPlatformHealth();
292
294
 
293
295
  let pm2Procs = [];
294
296
  const jlist = spawnSync('npx', ['-y', 'pm2', 'jlist'], { stdio: 'pipe' });
@@ -304,19 +306,41 @@ if (cmd === 'version' || cmd === '-v' || cmd === '--version') {
304
306
  } else {
305
307
  const mem = proc.monit ? `${Math.round(proc.monit.memory / 1024 / 1024)}mb` : '?';
306
308
  const cpu = proc.monit ? `${proc.monit.cpu}%` : '?';
309
+ const uptimeMs = proc.pm2_env.status === 'online' ? Date.now() - proc.pm2_env.pm_uptime : 0;
307
310
  const uptime =
308
311
  proc.pm2_env.status === 'online' ? formatUptime(proc.pm2_env.pm_uptime) : '-';
312
+ const restarts = proc.pm2_env.restart_time;
309
313
  console.log(
310
- `\n${color.cyan}▶${color.reset} daemon: ${proc.pm2_env.status} (uptime: ${uptime}, restarts: ${proc.pm2_env.restart_time}, cpu: ${cpu}, mem: ${mem})\n`,
314
+ `\n${color.cyan}▶${color.reset} daemon: ${proc.pm2_env.status} (uptime: ${uptime}, restarts: ${restarts}, cpu: ${cpu}, mem: ${mem})\n`,
311
315
  );
316
+ // Flag "many restarts" only alongside a short current uptime - restart_time alone is cumulative, not live.
317
+ if (
318
+ proc.pm2_env.status === 'online' &&
319
+ restarts > 5 &&
320
+ uptimeMs > 0 &&
321
+ uptimeMs < 5 * 60 * 1000
322
+ ) {
323
+ console.log(
324
+ `${color.yellow}⚠${color.reset} ${restarts} restarts and only ${uptime} of uptime - looks like it's crash-looping. Run ${color.cyan}lgy logs${color.reset} to see why.\n`,
325
+ );
326
+ }
312
327
  }
313
328
 
314
329
  const rows = Object.entries(PLATFORMS).map(([key, def]) => {
315
330
  const { enabled } = platformState(key, settings);
316
331
  const sessionCount = Object.values(sessions).filter((s) => s.platform === key).length;
317
- return [def.label, enabled ? 'yes' : 'no', String(sessionCount)];
332
+ const h = health[key];
333
+ let connection = '-';
334
+ if (enabled) {
335
+ if (!h) connection = 'unknown';
336
+ else if (h.status === 'running') connection = 'connected';
337
+ else if (h.status === 'connecting') connection = 'connecting...';
338
+ else if (h.status === 'error') connection = `error: ${h.detail || '?'}`;
339
+ else if (h.status === 'stopped') connection = 'stopped';
340
+ }
341
+ return [def.label, enabled ? 'yes' : 'no', connection, String(sessionCount)];
318
342
  });
319
- console.log(renderTable(['platform', 'enabled', 'sessions'], rows));
343
+ console.log(renderTable(['platform', 'enabled', 'connection', 'sessions'], rows));
320
344
  console.log();
321
345
  } else if (cmd === 'enable') {
322
346
  if (isWin) {
package/bin/platforms.js CHANGED
@@ -5,6 +5,7 @@ const os = require('os');
5
5
  const workspaceDir = path.join(os.homedir(), '.gemini', 'linkgravity');
6
6
  const settingsPath = path.join(workspaceDir, 'lgy.json');
7
7
  const sessionsPath = path.join(workspaceDir, 'data', 'sessions.json');
8
+ const healthPath = path.join(workspaceDir, 'data', 'platform_health.json');
8
9
 
9
10
  if (!fs.existsSync(workspaceDir)) fs.mkdirSync(workspaceDir, { recursive: true });
10
11
 
@@ -34,6 +35,16 @@ function getSessions() {
34
35
  return {};
35
36
  }
36
37
 
38
+ // Written live by main.py - distinct from `enabled`, which is just the static config flag.
39
+ function getPlatformHealth() {
40
+ if (fs.existsSync(healthPath)) {
41
+ try {
42
+ return JSON.parse(fs.readFileSync(healthPath, 'utf8'));
43
+ } catch (e) {}
44
+ }
45
+ return {};
46
+ }
47
+
37
48
  // Both platforms now run in one shared pm2 process - main.py checks discord_enabled/telegram_enabled at startup.
38
49
  const LGY_PM2_NAME = 'lgy';
39
50
  const LGY_SCRIPT_PATH = path.join(__dirname, '..', 'src', 'main.py');
@@ -41,10 +52,15 @@ const LGY_SCRIPT_PATH = path.join(__dirname, '..', 'src', 'main.py');
41
52
  const PLATFORMS = {
42
53
  discord: { label: 'Discord' },
43
54
  telegram: { label: 'Telegram' },
55
+ slack: { label: 'Slack' },
44
56
  };
45
57
 
46
58
  function platformState(key, settings) {
47
- const configured = !!settings[`${key}_token`];
59
+ // Slack needs two tokens (bot + app-level, for Socket Mode) instead of the single `${key}_token` the others use.
60
+ const configured =
61
+ key === 'slack'
62
+ ? !!(settings.slack_bot_token && settings.slack_app_token)
63
+ : !!settings[`${key}_token`];
48
64
  const enabled = settings[`${key}_enabled`] ?? (key === 'discord' && configured);
49
65
  return { configured, enabled };
50
66
  }
@@ -56,6 +72,7 @@ module.exports = {
56
72
  getSettings,
57
73
  updateSettings,
58
74
  getSessions,
75
+ getPlatformHealth,
59
76
  LGY_PM2_NAME,
60
77
  LGY_SCRIPT_PATH,
61
78
  PLATFORMS,
package/bin/setup.js CHANGED
@@ -317,9 +317,52 @@ async function configureTelegram(existingSettings) {
317
317
  return updates;
318
318
  }
319
319
 
320
+ async function configureSlack(existingSettings) {
321
+ p.note(
322
+ 'Create a Slack app at api.slack.com/apps, enable Socket Mode, and add an app-level token with the ' +
323
+ '`connections:write` scope (Basic Information → App-Level Tokens). The bot token (starts with xoxb-) ' +
324
+ 'is under OAuth & Permissions.',
325
+ 'Slack App Setup',
326
+ );
327
+
328
+ const slackBotToken = await p.password({
329
+ message: 'Slack Bot Token (xoxb-..., leave empty to keep current):',
330
+ });
331
+ if (p.isCancel(slackBotToken)) {
332
+ p.cancel('Setup cancelled.');
333
+ process.exit(0);
334
+ }
335
+
336
+ const slackAppToken = await p.password({
337
+ message: 'Slack App-Level Token (xapp-..., leave empty to keep current):',
338
+ });
339
+ if (p.isCancel(slackAppToken)) {
340
+ p.cancel('Setup cancelled.');
341
+ process.exit(0);
342
+ }
343
+
344
+ p.note(
345
+ 'Slack has no channel/server gating yet, and threads have no title surface (same as Telegram) - ' +
346
+ 'there is no voice support yet either.',
347
+ 'Slack Access',
348
+ );
349
+
350
+ const existingSlackUserIds = existingSettings.slack_allowed_user_ids
351
+ ? splitIds(existingSettings.slack_allowed_user_ids)
352
+ : [];
353
+ const slackUserIds = await collectUserIds(existingSlackUserIds, 'Slack');
354
+
355
+ const updates = {};
356
+ if (slackBotToken) updates.slack_bot_token = slackBotToken;
357
+ if (slackAppToken) updates.slack_app_token = slackAppToken;
358
+ if (slackUserIds !== null) updates.slack_allowed_user_ids = slackUserIds.join(',');
359
+ return updates;
360
+ }
361
+
320
362
  const CONFIGURERS = {
321
363
  discord: configureDiscord,
322
364
  telegram: configureTelegram,
365
+ slack: configureSlack,
323
366
  };
324
367
 
325
368
  function applyDaemonState() {
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "linkgravity",
3
- "version": "1.4.0",
4
- "description": "Discord bot bridge for the Antigravity (agy) CLI, with voice interaction support",
3
+ "version": "1.5.0",
4
+ "description": "Discord/Telegram bot bridge for the Antigravity (agy) CLI, with voice interaction support",
5
5
  "scripts": {
6
6
  "postinstall": "node npm-scripts/postinstall.js",
7
7
  "start": "node npm-scripts/run-dev.js",
@@ -23,6 +23,8 @@
23
23
  "keywords": [
24
24
  "discord",
25
25
  "discord-bot",
26
+ "telegram",
27
+ "telegram-bot",
26
28
  "antigravity",
27
29
  "agy",
28
30
  "cli",
package/requirements.txt CHANGED
@@ -1,5 +1,6 @@
1
1
  discord.py[voice]>=2.4.0
2
2
  python-telegram-bot>=21.0
3
+ slack-bolt>=1.20.0
3
4
  PyNaCl>=1.5.0
4
5
  numpy>=1.24.0
5
6
  edge-tts
@@ -11,7 +11,6 @@ from discord.ext import commands
11
11
  from config import (
12
12
  DATA_DIR,
13
13
  EMBED_COLOR,
14
- MODEL_CHOICES,
15
14
  allowed,
16
15
  bot_settings,
17
16
  is_allowed_session_channel,
@@ -285,23 +284,3 @@ class GeneralCog(commands.Cog):
285
284
  await interaction.response.send_message("🛑 Process stopped natively.", ephemeral=True)
286
285
  else:
287
286
  await interaction.response.send_message("🛑 Process stopped.", ephemeral=True)
288
-
289
- @app_commands.command(name="list", description="Active session list")
290
- async def cmd_sessions(self, interaction: discord.Interaction):
291
- if not allowed(interaction.user.id):
292
- return await interaction.response.send_message("❌ Denied", ephemeral=True)
293
- all_sessions = session_manager.get_all_sessions()
294
- if not all_sessions:
295
- return await interaction.response.send_message("No active sessions.", ephemeral=True)
296
-
297
- embed = discord.Embed(title="📋 Session List", color=EMBED_COLOR)
298
- from utils.utils import get_current_model
299
-
300
- for thread_id, sess in list(all_sessions.items())[-10:]:
301
- ch = self.bot.get_channel(int(thread_id))
302
- embed.add_field(
303
- name=f"#{getattr(ch, 'name', f'ID:{thread_id}')}",
304
- value=f"🤖 {MODEL_CHOICES.get(sess.get('model'), sess.get('model')) or get_current_model()}",
305
- inline=False,
306
- )
307
- await interaction.response.send_message(embed=embed, ephemeral=True)
@@ -57,6 +57,9 @@ class VoiceCog(commands.Cog):
57
57
  self.cleanup_old_voice_files.cancel()
58
58
  self.enrollment.stop()
59
59
 
60
+ def _wake_word_required(self, user_id) -> bool:
61
+ return (self.bot_settings.get("wake_word_required") or {}).get(str(user_id), True)
62
+
60
63
  async def handle_voice_service_down(self):
61
64
  await self.enrollment.handle_voice_service_down()
62
65
 
@@ -178,6 +181,7 @@ class VoiceCog(commands.Cog):
178
181
  wake_word_map = self.bot_settings.get("wake_words") or {}
179
182
  own_word = wake_word_map.get(str(interaction.user.id))
180
183
  active_timer = self.bot_settings.get("active_timer", 60)
184
+ required = self._wake_word_required(interaction.user.id)
181
185
 
182
186
  if own_word:
183
187
  msg = (
@@ -185,11 +189,16 @@ class VoiceCog(commands.Cog):
185
189
  f"💡 Say `{own_word}` to activate me. Once awake, I'll keep listening for {active_timer} seconds after each interaction.\n"
186
190
  f"⚙️ You can customize settings using `/sound`."
187
191
  )
192
+ elif not required:
193
+ msg = f"🎤 Connected to `{vc_chan.name}`.\n💡 Wake word is off for you - just talk, I'm listening."
188
194
  else:
189
195
  msg = (
190
196
  f"🎤 Connected to `{vc_chan.name}`.\n"
191
197
  f"🎙️ You haven't set up a wake word yet, so I can't hear you - run `/sound wake_word:<word>` "
192
- f"and say your chosen word a few times to register it in your voice."
198
+ f"and say your chosen word a few times to register it in your voice.\n"
199
+ f"💡 A wake word keeps everyone else's side conversation from triggering me by accident, and "
200
+ f"avoids running speech recognition on audio that isn't meant for me. If you use push-to-talk, "
201
+ f"turning it off with `/sound require_wake_word:off` is recommended instead."
193
202
  )
194
203
  await interaction.response.send_message(msg)
195
204
 
@@ -205,11 +214,17 @@ class VoiceCog(commands.Cog):
205
214
  resp = await session.post(
206
215
  f"{NODE_VOICE_API}/join", json={"guild_id": str(guild_id), "channel_id": str(vc_chan.id)}
207
216
  )
217
+ if not required:
218
+ # Node's opt-out set is in-memory and won't survive a Node restart, unlike our own bot_settings.
219
+ await session.post(
220
+ f"{NODE_VOICE_API}/set_wake_word_required",
221
+ json={"user_id": str(interaction.user.id), "required": False},
222
+ )
208
223
  data = await resp.json()
209
224
  if data.get("success"):
210
225
  self._voice_state[str(guild_id)] = interaction.channel_id
211
226
 
212
- if own_word:
227
+ if own_word or not required:
213
228
  if self.bot_settings.get("tts_enabled", True):
214
229
  welcome_audio = await self.tts("Voice connected.")
215
230
  if welcome_audio:
@@ -236,7 +251,8 @@ class VoiceCog(commands.Cog):
236
251
  await interaction.channel.send("⚠️ Timeout connecting to voice backend.")
237
252
 
238
253
  @app_commands.command(
239
- name="sound", description="Configure voice settings (Wake word, active time, threshold, TTS voice, TTS on/off)"
254
+ name="sound",
255
+ description="Configure voice settings (Wake word, active time, threshold, TTS voice/speed, TTS on/off)",
240
256
  )
241
257
  @app_commands.describe(
242
258
  wake_word="The single word/phrase that wakes the bot (recorded in your voice)",
@@ -244,6 +260,8 @@ class VoiceCog(commands.Cog):
244
260
  threshold="Voice volume sensitivity (1000~10000)",
245
261
  tts_voice="Select the AI TTS voice",
246
262
  tts_enabled="Turn Text-to-Speech ON or OFF",
263
+ tts_speed="TTS playback speed multiplier, e.g. 1.3 for 1.3x (0.5~2.0)",
264
+ require_wake_word="Require your wake word before I listen (default ON) - turn OFF if you use push-to-talk",
247
265
  )
248
266
  @app_commands.autocomplete(
249
267
  active_times=active_times_autocomplete,
@@ -259,6 +277,8 @@ class VoiceCog(commands.Cog):
259
277
  threshold: int = None,
260
278
  tts_voice: str = None,
261
279
  tts_enabled: str = None,
280
+ tts_speed: float = None,
281
+ require_wake_word: str = None,
262
282
  ):
263
283
  import aiohttp
264
284
 
@@ -272,19 +292,25 @@ class VoiceCog(commands.Cog):
272
292
  and threshold is None
273
293
  and tts_voice is None
274
294
  and tts_enabled is None
295
+ and tts_speed is None
296
+ and require_wake_word is None
275
297
  ):
276
298
  curr_wake = (self.bot_settings.get("wake_words") or {}).get(str(interaction.user.id), "None")
277
299
  curr_timer = self.bot_settings.get("active_timer", 60)
278
300
  curr_thresh = self.bot_settings.get("voice_threshold", 3000)
279
301
  curr_tts = self.bot_settings.get("tts_voice", "en-US-AriaNeural")
280
302
  curr_tts_on = "ON" if self.bot_settings.get("tts_enabled", True) else "OFF"
303
+ curr_tts_speed = self.bot_settings.get("tts_speed", 1.0)
304
+ curr_required = self._wake_word_required(interaction.user.id)
281
305
 
282
306
  embed = discord.Embed(title="⚙️ Current Voice Settings", color=0x3498DB)
283
307
  embed.add_field(name="🎙️ Wake Word", value=f"`{curr_wake}`", inline=False)
308
+ embed.add_field(name="🔒 Wake Word Required", value=f"`{'ON' if curr_required else 'OFF'}`", inline=False)
284
309
  embed.add_field(name="⏱️ Active Time", value=f"`{curr_timer}s`", inline=False)
285
310
  embed.add_field(name="🔊 Threshold", value=f"`{curr_thresh}`", inline=False)
286
311
  embed.add_field(name="🗣️ TTS Voice", value=f"`{curr_tts}`", inline=False)
287
312
  embed.add_field(name="🔊 TTS Enabled", value=f"`{curr_tts_on}`", inline=False)
313
+ embed.add_field(name="⏩ TTS Speed", value=f"`{curr_tts_speed}x`", inline=False)
288
314
  return await interaction.response.send_message(embed=embed)
289
315
 
290
316
  updated = []
@@ -314,6 +340,24 @@ class VoiceCog(commands.Cog):
314
340
  is_on = tts_enabled.lower() == "on"
315
341
  self.bot_settings["tts_enabled"] = is_on
316
342
  updated.append(f"🔊 TTS Enabled: `{'ON' if is_on else 'OFF'}`")
343
+ if tts_speed is not None:
344
+ clamped = max(0.5, min(2.0, tts_speed))
345
+ self.bot_settings["tts_speed"] = clamped
346
+ updated.append(f"⏩ TTS Speed: `{clamped}x`")
347
+ if require_wake_word is not None:
348
+ is_required = require_wake_word.lower() != "off"
349
+ required_map = self.bot_settings.setdefault("wake_word_required", {})
350
+ required_map[str(interaction.user.id)] = is_required
351
+ updated.append(f"🔒 Wake Word Required: `{'ON' if is_required else 'OFF'}`")
352
+ try:
353
+ async with aiohttp.ClientSession(timeout=NODE_REQUEST_TIMEOUT) as session:
354
+ await session.post(
355
+ f"{NODE_VOICE_API}/set_wake_word_required",
356
+ json={"user_id": str(interaction.user.id), "required": is_required},
357
+ )
358
+ except (aiohttp.ClientError, asyncio.TimeoutError) as e:
359
+ self.logger.warning(f"Node.js wake-word-required sync failed for {interaction.user.id}: {e}")
360
+ updated.append(f"(⚠️ Node.js Sync Failed: {e})")
317
361
 
318
362
  self.save_bot_settings(self.bot_settings)
319
363
 
@@ -408,7 +452,7 @@ class VoiceCog(commands.Cog):
408
452
  matched_wake_word = data.get("matched_wake_word")
409
453
  is_active = self.stt_session.is_active(str(guild_id))
410
454
 
411
- if not is_active and not is_waking_up:
455
+ if not is_active and not is_waking_up and self._wake_word_required(user_id):
412
456
  self.logger.debug(f"STT: ignored (sleeping): {text}")
413
457
  await self.stt_session.clear_partial_msg(str(guild_id))
414
458
  return
package/src/config.py CHANGED
@@ -17,6 +17,9 @@ DEFAULT_LGY_CONFIG = {
17
17
  "discord_token": "",
18
18
  "telegram_token": "",
19
19
  "telegram_allowed_user_ids": "",
20
+ "slack_bot_token": "",
21
+ "slack_app_token": "",
22
+ "slack_allowed_user_ids": "",
20
23
  "session_scopes": [],
21
24
  "allowed_user_ids": "",
22
25
  # user_id (str) -> registered word, one per person (see EnrollmentManager._commit_enrollment).
@@ -76,6 +79,8 @@ logger = init_logger(WORKSPACE_DIR)
76
79
 
77
80
  DISCORD_TOKEN = bot_settings.get("discord_token", "")
78
81
  TELEGRAM_TOKEN = bot_settings.get("telegram_token", "")
82
+ SLACK_BOT_TOKEN = bot_settings.get("slack_bot_token", "")
83
+ SLACK_APP_TOKEN = bot_settings.get("slack_app_token", "")
79
84
 
80
85
 
81
86
  def _parse_session_scopes(raw_scopes) -> dict:
@@ -100,6 +105,8 @@ def _parse_session_scopes(raw_scopes) -> dict:
100
105
  SESSION_SCOPES = _parse_session_scopes(bot_settings.get("session_scopes"))
101
106
  ALLOWED_IDS = set(int(x) for x in bot_settings.get("allowed_user_ids", "").split(",") if x.strip())
102
107
  TELEGRAM_ALLOWED_IDS = set(int(x) for x in bot_settings.get("telegram_allowed_user_ids", "").split(",") if x.strip())
108
+ # Slack user IDs are strings (e.g. "U0123ABC"), unlike Discord/Telegram's numeric IDs.
109
+ SLACK_ALLOWED_IDS = set(x.strip() for x in bot_settings.get("slack_allowed_user_ids", "").split(",") if x.strip())
103
110
  TTS_VOICE = bot_settings.get("tts_voice", "ko-KR-SunHiNeural")
104
111
 
105
112
 
@@ -140,6 +147,12 @@ AGY_BIN = os.getenv("AGY_BIN_PATH", str(Path.home() / ".local/bin/agy"))
140
147
  session_manager = SessionManager(DATA_DIR)
141
148
 
142
149
 
143
- def allowed(user_id: int, platform: str = "discord") -> bool:
144
- ids = TELEGRAM_ALLOWED_IDS if platform == "telegram" else ALLOWED_IDS
150
+ def allowed(user_id, platform: str = "discord") -> bool:
151
+ if platform == "telegram":
152
+ ids = TELEGRAM_ALLOWED_IDS
153
+ elif platform == "slack":
154
+ ids = SLACK_ALLOWED_IDS
155
+ user_id = str(user_id) # Slack IDs are strings, not ints like Discord/Telegram
156
+ else:
157
+ ids = ALLOWED_IDS
145
158
  return not ids or user_id in ids
@@ -8,6 +8,12 @@ from loguru import logger
8
8
 
9
9
  def init_logger(workspace_dir: Path):
10
10
  logging.getLogger("discord").setLevel(logging.WARNING)
11
+ # httpx is what python-telegram-bot uses under the hood for every getUpdates
12
+ # long-poll request - left unset, it logs each one at INFO, which floods
13
+ # `lgy logs` with a line every poll cycle. httpcore is httpx's own transport
14
+ # layer and is just as noisy at DEBUG, so it's included pre-emptively too.
15
+ logging.getLogger("httpx").setLevel(logging.WARNING)
16
+ logging.getLogger("httpcore").setLevel(logging.WARNING)
11
17
  LOG_DIR = workspace_dir / "logs"
12
18
  LOG_DIR.mkdir(parents=True, exist_ok=True)
13
19
  logger.remove()
@@ -0,0 +1,28 @@
1
+ """Tracks each platform's live connection health, separately from the static "enabled" config flag."""
2
+
3
+ from datetime import datetime
4
+ from pathlib import Path
5
+
6
+ from core.atomic_io import atomic_write_json, safe_load_json
7
+
8
+
9
+ def _path() -> Path:
10
+ from config import DATA_DIR
11
+
12
+ return DATA_DIR / "platform_health.json"
13
+
14
+
15
+ def set_status(platform: str, status: str, detail: str = "") -> None:
16
+ """status: 'connecting' | 'running' | 'error'"""
17
+ path = _path()
18
+ data = safe_load_json(path, {})
19
+ data[platform] = {
20
+ "status": status,
21
+ "detail": detail,
22
+ "at": datetime.now().isoformat(),
23
+ }
24
+ atomic_write_json(path, data)
25
+
26
+
27
+ def get_all() -> dict:
28
+ return safe_load_json(_path(), {})
@@ -74,7 +74,7 @@ async def handle_pending_session(
74
74
  await stream_task
75
75
 
76
76
  response_text = result_text
77
- if adapter.supports_renaming:
77
+ if adapter.can_rename(thread):
78
78
  new_title = await generate_thread_title(content, response_text)
79
79
  await adapter.rename_conversation(thread, new_title)
80
80
  await update_agy_conversation_title(new_conv_id, new_title)
package/src/main.py CHANGED
@@ -1,11 +1,29 @@
1
- """Top-level entrypoint. Starts whichever platforms are enabled
2
- (main_discord.run_discord / main_telegram.run_telegram) concurrently in
3
- one process, sharing a single webhook server and exception handler."""
1
+ """Top-level entrypoint - starts whichever platforms are enabled, concurrently in one process."""
4
2
 
5
3
  import asyncio
6
4
 
7
5
  from api import server
8
- from config import DISCORD_TOKEN, SESSION_SCOPES, bot_settings, logger, session_manager
6
+ from config import (
7
+ DISCORD_TOKEN,
8
+ SESSION_SCOPES,
9
+ SLACK_APP_TOKEN,
10
+ SLACK_BOT_TOKEN,
11
+ bot_settings,
12
+ logger,
13
+ session_manager,
14
+ )
15
+ from core import platform_health
16
+
17
+
18
+ async def _run_platform_isolated(platform: str, coro) -> None:
19
+ """Catches exceptions so one platform crashing can't take the others down via asyncio.gather."""
20
+ platform_health.set_status(platform, "connecting")
21
+ try:
22
+ await coro
23
+ platform_health.set_status(platform, "stopped")
24
+ except Exception as e:
25
+ logger.opt(exception=e).error(f"{platform} platform crashed - other platforms will keep running")
26
+ platform_health.set_status(platform, "error", detail=str(e))
9
27
 
10
28
 
11
29
  async def main():
@@ -15,14 +33,18 @@ async def main():
15
33
 
16
34
  discord_enabled = bot_settings.get("discord_enabled", bool(DISCORD_TOKEN))
17
35
  telegram_enabled = bot_settings.get("telegram_enabled", False)
36
+ slack_enabled = bot_settings.get("slack_enabled", False)
18
37
 
19
38
  if discord_enabled and not SESSION_SCOPES:
20
39
  logger.warning("Discord enabled but no server/channel configured (session_scopes) - run `lgy setup`")
21
40
  if discord_enabled and not DISCORD_TOKEN:
22
41
  logger.critical("Discord enabled but missing DISCORD_TOKEN - run `lgy setup`")
23
42
  discord_enabled = False
43
+ if slack_enabled and not (SLACK_BOT_TOKEN and SLACK_APP_TOKEN):
44
+ logger.critical("Slack enabled but missing SLACK_BOT_TOKEN/SLACK_APP_TOKEN - run `lgy setup`")
45
+ slack_enabled = False
24
46
 
25
- if not discord_enabled and not telegram_enabled:
47
+ if not discord_enabled and not telegram_enabled and not slack_enabled:
26
48
  logger.critical("No messenger platform is enabled - run `lgy setup`")
27
49
  return
28
50
 
@@ -59,11 +81,15 @@ async def main():
59
81
  if discord_enabled:
60
82
  from main_discord import run_discord
61
83
 
62
- tasks.append(asyncio.create_task(run_discord(stop_event)))
84
+ tasks.append(asyncio.create_task(_run_platform_isolated("discord", run_discord(stop_event))))
63
85
  if telegram_enabled:
64
86
  from main_telegram import run_telegram
65
87
 
66
- tasks.append(asyncio.create_task(run_telegram(stop_event)))
88
+ tasks.append(asyncio.create_task(_run_platform_isolated("telegram", run_telegram(stop_event))))
89
+ if slack_enabled:
90
+ from main_slack import run_slack
91
+
92
+ tasks.append(asyncio.create_task(_run_platform_isolated("slack", run_slack(stop_event))))
67
93
 
68
94
  await asyncio.gather(*tasks)
69
95