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 +54 -19
- package/bin/cli.js +27 -3
- package/bin/platforms.js +18 -1
- package/bin/setup.js +43 -0
- package/package.json +4 -2
- package/requirements.txt +1 -0
- package/src/cogs/general_cog.py +0 -21
- package/src/cogs/voice_cog.py +48 -4
- package/src/config.py +15 -2
- package/src/core/logger.py +6 -0
- package/src/core/platform_health.py +28 -0
- package/src/handlers/thread_reply.py +1 -1
- package/src/main.py +33 -7
- package/src/main_discord.py +3 -0
- package/src/main_slack.py +252 -0
- package/src/main_telegram.py +74 -23
- package/src/messengers/base.py +5 -0
- package/src/messengers/discord_adapter.py +19 -1
- package/src/messengers/slack_adapter.py +492 -0
- package/src/messengers/telegram_adapter.py +4 -4
- package/src/services/audio_service.py +6 -1
- package/src/services/streaming.py +6 -5
- package/voice-service/index.js +43 -193
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
|
|
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
|
|
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)
|
|
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
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
-
|
|
90
|
-
- Tool calls, including shell commands, go through an approval flow
|
|
91
|
-
- Your
|
|
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: ${
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
"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
package/src/cogs/general_cog.py
CHANGED
|
@@ -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)
|
package/src/cogs/voice_cog.py
CHANGED
|
@@ -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",
|
|
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
|
|
144
|
-
|
|
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
|
package/src/core/logger.py
CHANGED
|
@@ -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.
|
|
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
|
|
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
|
|
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
|
|