linkgravity 1.4.1 → 1.5.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/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 +81 -7
- 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 +3 -0
- 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/services/audio_service.py +6 -1
- package/src/services/streaming.py +6 -5
- package/voice-service/index.js +14 -1001
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.1",
|
|
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
|
|
|
@@ -150,6 +153,33 @@ class VoiceCog(commands.Cog):
|
|
|
150
153
|
opts.append(app_commands.Choice(name=other_val, value=other_val.lower()))
|
|
151
154
|
return opts
|
|
152
155
|
|
|
156
|
+
async def require_wake_word_autocomplete(
|
|
157
|
+
self, interaction: discord.Interaction, current: str
|
|
158
|
+
) -> list[app_commands.Choice[str]]:
|
|
159
|
+
is_on = self._wake_word_required(interaction.user.id)
|
|
160
|
+
current_val = "ON" if is_on else "OFF"
|
|
161
|
+
other_val = "OFF" if is_on else "ON"
|
|
162
|
+
|
|
163
|
+
opts = []
|
|
164
|
+
if current.lower() in current_val.lower() or not current:
|
|
165
|
+
opts.append(app_commands.Choice(name=f"{current_val} (current)", value=current_val.lower()))
|
|
166
|
+
if current.lower() in other_val.lower():
|
|
167
|
+
opts.append(app_commands.Choice(name=other_val, value=other_val.lower()))
|
|
168
|
+
return opts
|
|
169
|
+
|
|
170
|
+
async def tts_speed_autocomplete(
|
|
171
|
+
self, interaction: discord.Interaction, current: str
|
|
172
|
+
) -> list[app_commands.Choice[float]]:
|
|
173
|
+
current_val = float(self.bot_settings.get("tts_speed", 1.0))
|
|
174
|
+
opts = []
|
|
175
|
+
if str(current_val) in current or not current:
|
|
176
|
+
opts.append(app_commands.Choice(name=f"{current_val}x (current)", value=current_val))
|
|
177
|
+
|
|
178
|
+
for v in [0.75, 1.0, 1.25, 1.3, 1.5, 1.75, 2.0]:
|
|
179
|
+
if v != current_val and len(opts) < 25:
|
|
180
|
+
opts.append(app_commands.Choice(name=f"{v}x", value=v))
|
|
181
|
+
return opts
|
|
182
|
+
|
|
153
183
|
@app_commands.command(name="join", description="Summon the bot to your current voice channel")
|
|
154
184
|
async def cmd_join(self, interaction: discord.Interaction):
|
|
155
185
|
if not allowed(interaction.user.id):
|
|
@@ -178,6 +208,7 @@ class VoiceCog(commands.Cog):
|
|
|
178
208
|
wake_word_map = self.bot_settings.get("wake_words") or {}
|
|
179
209
|
own_word = wake_word_map.get(str(interaction.user.id))
|
|
180
210
|
active_timer = self.bot_settings.get("active_timer", 60)
|
|
211
|
+
required = self._wake_word_required(interaction.user.id)
|
|
181
212
|
|
|
182
213
|
if own_word:
|
|
183
214
|
msg = (
|
|
@@ -185,11 +216,16 @@ class VoiceCog(commands.Cog):
|
|
|
185
216
|
f"💡 Say `{own_word}` to activate me. Once awake, I'll keep listening for {active_timer} seconds after each interaction.\n"
|
|
186
217
|
f"⚙️ You can customize settings using `/sound`."
|
|
187
218
|
)
|
|
219
|
+
elif not required:
|
|
220
|
+
msg = f"🎤 Connected to `{vc_chan.name}`.\n💡 Wake word is off for you - just talk, I'm listening."
|
|
188
221
|
else:
|
|
189
222
|
msg = (
|
|
190
223
|
f"🎤 Connected to `{vc_chan.name}`.\n"
|
|
191
224
|
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
|
|
225
|
+
f"and say your chosen word a few times to register it in your voice.\n"
|
|
226
|
+
f"💡 A wake word keeps everyone else's side conversation from triggering me by accident, and "
|
|
227
|
+
f"avoids running speech recognition on audio that isn't meant for me. If you use push-to-talk, "
|
|
228
|
+
f"turning it off with `/sound require_wake_word:off` is recommended instead."
|
|
193
229
|
)
|
|
194
230
|
await interaction.response.send_message(msg)
|
|
195
231
|
|
|
@@ -205,11 +241,17 @@ class VoiceCog(commands.Cog):
|
|
|
205
241
|
resp = await session.post(
|
|
206
242
|
f"{NODE_VOICE_API}/join", json={"guild_id": str(guild_id), "channel_id": str(vc_chan.id)}
|
|
207
243
|
)
|
|
244
|
+
if not required:
|
|
245
|
+
# Node's opt-out set is in-memory and won't survive a Node restart, unlike our own bot_settings.
|
|
246
|
+
await session.post(
|
|
247
|
+
f"{NODE_VOICE_API}/set_wake_word_required",
|
|
248
|
+
json={"user_id": str(interaction.user.id), "required": False},
|
|
249
|
+
)
|
|
208
250
|
data = await resp.json()
|
|
209
251
|
if data.get("success"):
|
|
210
252
|
self._voice_state[str(guild_id)] = interaction.channel_id
|
|
211
253
|
|
|
212
|
-
if own_word:
|
|
254
|
+
if own_word or not required:
|
|
213
255
|
if self.bot_settings.get("tts_enabled", True):
|
|
214
256
|
welcome_audio = await self.tts("Voice connected.")
|
|
215
257
|
if welcome_audio:
|
|
@@ -236,7 +278,8 @@ class VoiceCog(commands.Cog):
|
|
|
236
278
|
await interaction.channel.send("⚠️ Timeout connecting to voice backend.")
|
|
237
279
|
|
|
238
280
|
@app_commands.command(
|
|
239
|
-
name="sound",
|
|
281
|
+
name="sound",
|
|
282
|
+
description="Configure voice settings (Wake word, active time, threshold, TTS voice/speed, TTS on/off)",
|
|
240
283
|
)
|
|
241
284
|
@app_commands.describe(
|
|
242
285
|
wake_word="The single word/phrase that wakes the bot (recorded in your voice)",
|
|
@@ -244,12 +287,16 @@ class VoiceCog(commands.Cog):
|
|
|
244
287
|
threshold="Voice volume sensitivity (1000~10000)",
|
|
245
288
|
tts_voice="Select the AI TTS voice",
|
|
246
289
|
tts_enabled="Turn Text-to-Speech ON or OFF",
|
|
290
|
+
tts_speed="TTS playback speed multiplier, e.g. 1.3 for 1.3x (0.5~2.0)",
|
|
291
|
+
require_wake_word="Require your wake word before I listen (default ON) - turn OFF if you use push-to-talk",
|
|
247
292
|
)
|
|
248
293
|
@app_commands.autocomplete(
|
|
249
294
|
active_times=active_times_autocomplete,
|
|
250
295
|
threshold=threshold_autocomplete,
|
|
251
296
|
tts_voice=tts_voice_autocomplete,
|
|
252
297
|
tts_enabled=tts_enabled_autocomplete,
|
|
298
|
+
tts_speed=tts_speed_autocomplete,
|
|
299
|
+
require_wake_word=require_wake_word_autocomplete,
|
|
253
300
|
)
|
|
254
301
|
async def cmd_voice(
|
|
255
302
|
self,
|
|
@@ -259,6 +306,8 @@ class VoiceCog(commands.Cog):
|
|
|
259
306
|
threshold: int = None,
|
|
260
307
|
tts_voice: str = None,
|
|
261
308
|
tts_enabled: str = None,
|
|
309
|
+
tts_speed: float = None,
|
|
310
|
+
require_wake_word: str = None,
|
|
262
311
|
):
|
|
263
312
|
import aiohttp
|
|
264
313
|
|
|
@@ -272,19 +321,25 @@ class VoiceCog(commands.Cog):
|
|
|
272
321
|
and threshold is None
|
|
273
322
|
and tts_voice is None
|
|
274
323
|
and tts_enabled is None
|
|
324
|
+
and tts_speed is None
|
|
325
|
+
and require_wake_word is None
|
|
275
326
|
):
|
|
276
327
|
curr_wake = (self.bot_settings.get("wake_words") or {}).get(str(interaction.user.id), "None")
|
|
277
328
|
curr_timer = self.bot_settings.get("active_timer", 60)
|
|
278
329
|
curr_thresh = self.bot_settings.get("voice_threshold", 3000)
|
|
279
330
|
curr_tts = self.bot_settings.get("tts_voice", "en-US-AriaNeural")
|
|
280
331
|
curr_tts_on = "ON" if self.bot_settings.get("tts_enabled", True) else "OFF"
|
|
332
|
+
curr_tts_speed = self.bot_settings.get("tts_speed", 1.0)
|
|
333
|
+
curr_required = self._wake_word_required(interaction.user.id)
|
|
281
334
|
|
|
282
335
|
embed = discord.Embed(title="⚙️ Current Voice Settings", color=0x3498DB)
|
|
283
336
|
embed.add_field(name="🎙️ Wake Word", value=f"`{curr_wake}`", inline=False)
|
|
337
|
+
embed.add_field(name="🔒 Wake Word Required", value=f"`{'ON' if curr_required else 'OFF'}`", inline=False)
|
|
284
338
|
embed.add_field(name="⏱️ Active Time", value=f"`{curr_timer}s`", inline=False)
|
|
285
339
|
embed.add_field(name="🔊 Threshold", value=f"`{curr_thresh}`", inline=False)
|
|
286
340
|
embed.add_field(name="🗣️ TTS Voice", value=f"`{curr_tts}`", inline=False)
|
|
287
341
|
embed.add_field(name="🔊 TTS Enabled", value=f"`{curr_tts_on}`", inline=False)
|
|
342
|
+
embed.add_field(name="⏩ TTS Speed", value=f"`{curr_tts_speed}x`", inline=False)
|
|
288
343
|
return await interaction.response.send_message(embed=embed)
|
|
289
344
|
|
|
290
345
|
updated = []
|
|
@@ -314,6 +369,24 @@ class VoiceCog(commands.Cog):
|
|
|
314
369
|
is_on = tts_enabled.lower() == "on"
|
|
315
370
|
self.bot_settings["tts_enabled"] = is_on
|
|
316
371
|
updated.append(f"🔊 TTS Enabled: `{'ON' if is_on else 'OFF'}`")
|
|
372
|
+
if tts_speed is not None:
|
|
373
|
+
clamped = max(0.5, min(2.0, tts_speed))
|
|
374
|
+
self.bot_settings["tts_speed"] = clamped
|
|
375
|
+
updated.append(f"⏩ TTS Speed: `{clamped}x`")
|
|
376
|
+
if require_wake_word is not None:
|
|
377
|
+
is_required = require_wake_word.lower() != "off"
|
|
378
|
+
required_map = self.bot_settings.setdefault("wake_word_required", {})
|
|
379
|
+
required_map[str(interaction.user.id)] = is_required
|
|
380
|
+
updated.append(f"🔒 Wake Word Required: `{'ON' if is_required else 'OFF'}`")
|
|
381
|
+
try:
|
|
382
|
+
async with aiohttp.ClientSession(timeout=NODE_REQUEST_TIMEOUT) as session:
|
|
383
|
+
await session.post(
|
|
384
|
+
f"{NODE_VOICE_API}/set_wake_word_required",
|
|
385
|
+
json={"user_id": str(interaction.user.id), "required": is_required},
|
|
386
|
+
)
|
|
387
|
+
except (aiohttp.ClientError, asyncio.TimeoutError) as e:
|
|
388
|
+
self.logger.warning(f"Node.js wake-word-required sync failed for {interaction.user.id}: {e}")
|
|
389
|
+
updated.append(f"(⚠️ Node.js Sync Failed: {e})")
|
|
317
390
|
|
|
318
391
|
self.save_bot_settings(self.bot_settings)
|
|
319
392
|
|
|
@@ -408,7 +481,7 @@ class VoiceCog(commands.Cog):
|
|
|
408
481
|
matched_wake_word = data.get("matched_wake_word")
|
|
409
482
|
is_active = self.stt_session.is_active(str(guild_id))
|
|
410
483
|
|
|
411
|
-
if not is_active and not is_waking_up:
|
|
484
|
+
if not is_active and not is_waking_up and self._wake_word_required(user_id):
|
|
412
485
|
self.logger.debug(f"STT: ignored (sleeping): {text}")
|
|
413
486
|
await self.stt_session.clear_partial_msg(str(guild_id))
|
|
414
487
|
return
|
|
@@ -547,9 +620,10 @@ class VoiceCog(commands.Cog):
|
|
|
547
620
|
|
|
548
621
|
from utils.utils import generate_thread_title, update_agy_conversation_title
|
|
549
622
|
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
623
|
+
if thread.name.startswith("Session-"):
|
|
624
|
+
new_title = await generate_thread_title(text_to_ai, raw_ans)
|
|
625
|
+
await get_adapter_for_platform("discord").rename_conversation(thread, new_title)
|
|
626
|
+
await update_agy_conversation_title(new_conv_id, new_title)
|
|
553
627
|
else:
|
|
554
628
|
logger.debug("Voice: calling agy_send...")
|
|
555
629
|
raw_ans = await self.agy_send(
|
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) and thread.name.startswith("Session-"):
|
|
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)
|