linkgravity 1.5.7 → 1.5.9
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 +6 -4
- package/bin/cli.js +15 -26
- package/package.json +1 -1
- package/requirements.txt +0 -3
- package/src/api/ui_routes.py +5 -69
- package/src/cogs/general_cog.py +31 -2
- package/src/cogs/voice_cog.py +45 -22
- package/src/config.py +4 -1
- package/src/core/session_manager.py +0 -1
- package/src/handlers/thread_reply.py +1 -1
- package/src/main_slack.py +26 -1
- package/src/main_telegram.py +19 -0
- package/src/messengers/base.py +17 -3
- package/src/messengers/discord_adapter.py +58 -2
- package/src/messengers/slack_adapter.py +75 -0
- package/src/messengers/telegram_adapter.py +61 -0
- package/src/services/discord_helpers.py +29 -1
- package/src/services/permissions.py +42 -0
- package/src/services/response.py +11 -14
- package/src/services/streaming.py +2 -2
- package/src/utils/utils.py +2 -0
- package/voice-service/index.js +5 -2
- package/voice-service/receiver.js +17 -10
- package/voice-service/routes.js +19 -5
- package/voice-service/state.js +15 -2
- package/voice-service/wakeword.js +9 -4
package/README.md
CHANGED
|
@@ -2,13 +2,15 @@
|
|
|
2
2
|
|
|
3
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
|
+

|
|
6
|
+
|
|
5
7
|
## Features
|
|
6
8
|
|
|
7
|
-
- **
|
|
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.
|
|
9
|
+
- **Sessions as Threads:** Each `agy` conversation lives in its own thread, so several can run side by side and stay readable later. Threads get named from what you actually talked about instead of staying `Session-XXXX`.
|
|
10
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
|
+
- **Voice Interaction:** Talk to the agent from a Discord voice channel and hear its replies. Say your wake word to get its attention, so side conversation in the channel doesn't set it off.
|
|
11
12
|
- **Multi-Modal Input:** Attach files for the AI to read, including audio, which gets transcribed to text automatically.
|
|
13
|
+
- **Same Agent as Your Terminal:** Reads the `agy` setup already on the machine, so sessions started from chat use the same models and settings you use locally.
|
|
12
14
|
|
|
13
15
|
## Supported Platforms
|
|
14
16
|
|
|
@@ -58,7 +60,7 @@ Slack has more moving parts than the others - two separate tokens, and a few set
|
|
|
58
60
|
- 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
61
|
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
62
|
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**,
|
|
63
|
+
7. **Slash Commands** (left sidebar) > **Create New Command**, four times, for `/new`, `/model`, `/credit`, and `/permissions` (any description/hint text is fine - only the command name matters).
|
|
62
64
|
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
65
|
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.
|
|
64
66
|
|
package/bin/cli.js
CHANGED
|
@@ -175,7 +175,10 @@ function verifyStartup() {
|
|
|
175
175
|
`${color.cyan}▶${color.reset} Verifying startup status (waiting for bot to come online)...`,
|
|
176
176
|
);
|
|
177
177
|
|
|
178
|
-
|
|
178
|
+
// Spawned directly instead of through npx: npx wraps pm2 in "npm exec" + "sh -c", so cp.kill()
|
|
179
|
+
// reaps only the wrapper and leaves the real pm2 logs process orphaned onto init.
|
|
180
|
+
const pm2Bin = require.resolve('pm2/bin/pm2');
|
|
181
|
+
let cp = spawn(process.execPath, [pm2Bin, 'logs', LGY_PM2_NAME, '--raw', '--lines', '0'], {
|
|
179
182
|
cwd: path.join(__dirname, '..'),
|
|
180
183
|
});
|
|
181
184
|
|
|
@@ -188,12 +191,6 @@ function verifyStartup() {
|
|
|
188
191
|
resolve(ok);
|
|
189
192
|
};
|
|
190
193
|
|
|
191
|
-
// Give the --lines 20 replay burst a moment to flush before treating error text as a fresh crash, not old log noise.
|
|
192
|
-
let errorDetectionArmed = false;
|
|
193
|
-
setTimeout(() => {
|
|
194
|
-
errorDetectionArmed = true;
|
|
195
|
-
}, 1500);
|
|
196
|
-
|
|
197
194
|
let timer = setTimeout(() => {
|
|
198
195
|
console.log(
|
|
199
196
|
`\n\n${color.yellow}⏳ Startup verification timed out. Run 'lgy logs' to check status manually.${color.reset}`,
|
|
@@ -212,7 +209,6 @@ function verifyStartup() {
|
|
|
212
209
|
console.log(`\n${color.green}✔${color.reset} Bot successfully came online!\n`);
|
|
213
210
|
finish(true);
|
|
214
211
|
} else if (
|
|
215
|
-
errorDetectionArmed &&
|
|
216
212
|
(str.includes('Traceback (most recent call last):') ||
|
|
217
213
|
str.includes('Error:') ||
|
|
218
214
|
str.includes('Exception:')) &&
|
|
@@ -593,6 +589,9 @@ if (cmd === 'version' || cmd === '-v' || cmd === '--version') {
|
|
|
593
589
|
process.exit(0);
|
|
594
590
|
}
|
|
595
591
|
|
|
592
|
+
const procBeforeUpdate = getPm2Proc();
|
|
593
|
+
const wasOnline = !!procBeforeUpdate && procBeforeUpdate.pm2_env.status === 'online';
|
|
594
|
+
|
|
596
595
|
info(`Updating: v${currentVersion} -> v${latestVersion}...`);
|
|
597
596
|
const installResult = spawnSync('npm', ['install', '-g', 'linkgravity@latest'], {
|
|
598
597
|
stdio: 'inherit',
|
|
@@ -603,27 +602,18 @@ if (cmd === 'version' || cmd === '-v' || cmd === '--version') {
|
|
|
603
602
|
}
|
|
604
603
|
success(`Installed v${latestVersion}.`);
|
|
605
604
|
|
|
606
|
-
|
|
607
|
-
const restartResult = spawnSync('npx', ['-y', 'pm2', 'restart', LGY_PM2_NAME, '--update-env'], {
|
|
608
|
-
stdio: 'pipe',
|
|
609
|
-
cwd: path.join(__dirname, '..'),
|
|
610
|
-
env: { ...process.env, PYTHONUNBUFFERED: '1' },
|
|
611
|
-
});
|
|
612
|
-
|
|
613
|
-
if (restartResult.status === 0) {
|
|
614
|
-
runPm2(['reset', LGY_PM2_NAME]);
|
|
615
|
-
verifyStartup().then((ok) => process.exit(ok ? 0 : 1));
|
|
616
|
-
} else if ((restartResult.stderr || '').toString().includes('not found')) {
|
|
617
|
-
// Wasn't running before the update - start fresh instead of a false "restarted".
|
|
605
|
+
if (!procBeforeUpdate) {
|
|
618
606
|
info("Daemon wasn't running - starting it fresh...");
|
|
619
607
|
runPm2(['start', LGY_SCRIPT_PATH, '--interpreter', pythonExe, '--name', LGY_PM2_NAME]);
|
|
620
608
|
verifyStartup().then((ok) => process.exit(ok ? 0 : 1));
|
|
609
|
+
} else if (wasOnline) {
|
|
610
|
+
info('Restarting daemon to apply the update...');
|
|
611
|
+
runPm2(['restart', LGY_PM2_NAME, '--update-env']);
|
|
612
|
+
runPm2(['reset', LGY_PM2_NAME]);
|
|
613
|
+
verifyStartup().then((ok) => process.exit(ok ? 0 : 1));
|
|
621
614
|
} else {
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
`\n${color.yellow}⚠${color.reset} Update installed, but restarting the daemon failed - run 'lgy restart' manually.`,
|
|
625
|
-
);
|
|
626
|
-
process.exit(1);
|
|
615
|
+
success(`Daemon was stopped - leaving it stopped. Run 'lgy start' when you're ready.\n`);
|
|
616
|
+
process.exit(0);
|
|
627
617
|
}
|
|
628
618
|
} else if (cmd === 'help') {
|
|
629
619
|
console.log(
|
|
@@ -693,7 +683,6 @@ if (cmd === 'version' || cmd === '-v' || cmd === '--version') {
|
|
|
693
683
|
spawnSync(process.argv[0], [process.argv[1], action], { stdio: 'inherit' });
|
|
694
684
|
})();
|
|
695
685
|
} else {
|
|
696
|
-
// If no valid command was provided, show help
|
|
697
686
|
console.log(
|
|
698
687
|
`\n❌ Unknown command: ${cmd || 'none'}\n💡 Run 'lgy help' to see available commands.`,
|
|
699
688
|
);
|
package/package.json
CHANGED
package/requirements.txt
CHANGED
package/src/api/ui_routes.py
CHANGED
|
@@ -9,6 +9,7 @@ from aiohttp import web
|
|
|
9
9
|
from config import APPROVAL_TIMEOUT_SEC, MAX_EMBED_LEN, logger, session_manager
|
|
10
10
|
from messengers.base import ScopeOption
|
|
11
11
|
from messengers.registry import get_adapter_for_platform, get_adapter_for_thread
|
|
12
|
+
from utils.utils import split_message
|
|
12
13
|
|
|
13
14
|
|
|
14
15
|
def is_tool_allowed(tool_name, tool_input):
|
|
@@ -57,8 +58,8 @@ def allow_response(tool_name, tool_input):
|
|
|
57
58
|
async def _send_chunked(adapter, thread, text: str) -> None:
|
|
58
59
|
if not text:
|
|
59
60
|
return
|
|
60
|
-
for
|
|
61
|
-
await adapter.send_message(thread,
|
|
61
|
+
for part in split_message(text, MAX_EMBED_LEN):
|
|
62
|
+
await adapter.send_message(thread, part)
|
|
62
63
|
|
|
63
64
|
|
|
64
65
|
def _persist_scope_if_granted(prompt_handle):
|
|
@@ -196,10 +197,7 @@ async def handle_approve_request(request):
|
|
|
196
197
|
break
|
|
197
198
|
except ValueError:
|
|
198
199
|
pass
|
|
199
|
-
elif is_tool_allowed(tool_name, {"CommandLine": sub_cmd})
|
|
200
|
-
conv_id in session_manager.session_allowed_tools
|
|
201
|
-
and tool_name in session_manager.session_allowed_tools[conv_id]
|
|
202
|
-
):
|
|
200
|
+
elif is_tool_allowed(tool_name, {"CommandLine": sub_cmd}):
|
|
203
201
|
is_auto_allowed = True
|
|
204
202
|
|
|
205
203
|
if is_auto_allowed:
|
|
@@ -270,14 +268,7 @@ async def handle_approve_request(request):
|
|
|
270
268
|
return allow_response(tool_name, tool_input)
|
|
271
269
|
|
|
272
270
|
else:
|
|
273
|
-
|
|
274
|
-
if is_tool_allowed(tool_name, tool_input) or (
|
|
275
|
-
conv_id in session_manager.session_allowed_tools
|
|
276
|
-
and tool_name in session_manager.session_allowed_tools[conv_id]
|
|
277
|
-
):
|
|
278
|
-
is_auto_allowed = True
|
|
279
|
-
|
|
280
|
-
if is_auto_allowed:
|
|
271
|
+
if is_tool_allowed(tool_name, tool_input):
|
|
281
272
|
if target_thread and tool_msg_text:
|
|
282
273
|
await send_ordered(
|
|
283
274
|
target_thread_id, lambda: _send_chunked(adapter, target_thread, tool_msg_formatted)
|
|
@@ -324,58 +315,3 @@ async def handle_approve_request(request):
|
|
|
324
315
|
for key in registered_approval_keys:
|
|
325
316
|
session_manager.clear_pending_approval(key)
|
|
326
317
|
return web.json_response({"decision": "allow"})
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
async def handle_mcp_ask(request):
|
|
330
|
-
try:
|
|
331
|
-
data = await request.json()
|
|
332
|
-
thread_id = data.get("thread_id")
|
|
333
|
-
|
|
334
|
-
question = _clean_inline(data.get("question", "No question provided."))
|
|
335
|
-
options = [(_clean_inline(str(opt)) or "Option")[:80] for opt in data.get("options", [])]
|
|
336
|
-
|
|
337
|
-
adapter = get_adapter_for_thread(thread_id)
|
|
338
|
-
thread = adapter.resolve_conversation(thread_id)
|
|
339
|
-
if not thread:
|
|
340
|
-
return web.json_response({"answer": "Thread not found"}, status=400)
|
|
341
|
-
|
|
342
|
-
future = asyncio.get_event_loop().create_future()
|
|
343
|
-
# conv_id is always None here - key just needs to be unique for cleanup.
|
|
344
|
-
approval_key = f"mcp_ask:{thread_id}:{uuid.uuid4().hex}"
|
|
345
|
-
session_manager.set_pending_approval(approval_key, future, "ask_question")
|
|
346
|
-
|
|
347
|
-
prompt = adapter.create_question_prompt(future, question, options, allow_write_in=True)
|
|
348
|
-
msg = await prompt.send(thread)
|
|
349
|
-
session_manager.pending_approval_messages[approval_key] = msg
|
|
350
|
-
|
|
351
|
-
try:
|
|
352
|
-
answer = await asyncio.wait_for(future, timeout=300)
|
|
353
|
-
return web.json_response({"answer": answer})
|
|
354
|
-
except asyncio.TimeoutError:
|
|
355
|
-
return web.json_response({"answer": "User did not respond in time."})
|
|
356
|
-
finally:
|
|
357
|
-
await prompt.finalize()
|
|
358
|
-
session_manager.pending_approval_messages.pop(approval_key, None)
|
|
359
|
-
session_manager.clear_pending_approval(approval_key)
|
|
360
|
-
except Exception as e:
|
|
361
|
-
return web.json_response({"answer": f"Error: {e}"}, status=500)
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
async def handle_mcp_send_channel(request):
|
|
365
|
-
try:
|
|
366
|
-
data = await request.json()
|
|
367
|
-
channel_id = data.get("channel_id")
|
|
368
|
-
message = data.get("message", "")
|
|
369
|
-
|
|
370
|
-
adapter = get_adapter_for_thread(channel_id)
|
|
371
|
-
channel = adapter.resolve_conversation(channel_id)
|
|
372
|
-
if not channel:
|
|
373
|
-
return web.json_response({"error": "Channel not found"}, status=400)
|
|
374
|
-
|
|
375
|
-
chunks = [message[i : i + MAX_EMBED_LEN] for i in range(0, len(message), MAX_EMBED_LEN)]
|
|
376
|
-
for chunk in chunks:
|
|
377
|
-
await adapter.send_message(channel, chunk)
|
|
378
|
-
|
|
379
|
-
return web.json_response({"answer": "success"})
|
|
380
|
-
except Exception as e:
|
|
381
|
-
return web.json_response({"error": str(e)}, status=500)
|
package/src/cogs/general_cog.py
CHANGED
|
@@ -255,6 +255,23 @@ class GeneralCog(commands.Cog):
|
|
|
255
255
|
except Exception as e:
|
|
256
256
|
await interaction.response.send_message(f"⚠️ Failed to update settings: {e}", ephemeral=True)
|
|
257
257
|
|
|
258
|
+
@app_commands.command(name="permissions", description="View and remove previously allowed tools and commands")
|
|
259
|
+
async def cmd_permissions(self, interaction: discord.Interaction):
|
|
260
|
+
if not allowed(interaction.user.id):
|
|
261
|
+
return await interaction.response.send_message("❌ Denied", ephemeral=True)
|
|
262
|
+
|
|
263
|
+
from messengers.registry import get_adapter_for_platform
|
|
264
|
+
from services import permissions
|
|
265
|
+
|
|
266
|
+
adapter = get_adapter_for_platform("discord")
|
|
267
|
+
|
|
268
|
+
async def on_revoke(entry):
|
|
269
|
+
permissions.revoke(entry)
|
|
270
|
+
|
|
271
|
+
handle = adapter.create_permission_list(on_revoke)
|
|
272
|
+
await handle.send(interaction.channel)
|
|
273
|
+
await interaction.response.send_message("🔐 Permission list posted above.", ephemeral=True)
|
|
274
|
+
|
|
258
275
|
@app_commands.command(
|
|
259
276
|
name="stop", description="Stop the currently generating response or task (Equivalent to ESC in CLI)"
|
|
260
277
|
)
|
|
@@ -278,9 +295,21 @@ class GeneralCog(commands.Cog):
|
|
|
278
295
|
# Must clear conversation_id too, not just status - pending requires both unset.
|
|
279
296
|
session_manager.set_session(thread_id, {**session, "status": "pending", "conversation_id": None})
|
|
280
297
|
|
|
298
|
+
# The typing indicator only clears when the handler leaves its `async with adapter.typing()`
|
|
299
|
+
# block, and that block outlives the agy process (title generation, final send), so killing
|
|
300
|
+
# the process alone can leave the thread stuck showing "is thinking...".
|
|
301
|
+
handler = session_manager.get_handler_task(thread_id)
|
|
302
|
+
cancelled = bool(handler and handler is not asyncio.current_task() and not handler.done())
|
|
303
|
+
if cancelled:
|
|
304
|
+
handler.cancel()
|
|
305
|
+
session_manager.remove_handler_task(thread_id)
|
|
306
|
+
|
|
281
307
|
from core.agy_runner import stop_active_process
|
|
282
308
|
|
|
283
309
|
if stop_active_process(thread_id):
|
|
284
|
-
|
|
310
|
+
msg = "🛑 Process stopped natively."
|
|
311
|
+
elif cancelled:
|
|
312
|
+
msg = "🛑 Stopped."
|
|
285
313
|
else:
|
|
286
|
-
|
|
314
|
+
msg = "🛑 Nothing was running."
|
|
315
|
+
await interaction.response.send_message(msg, ephemeral=True)
|
package/src/cogs/voice_cog.py
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import asyncio
|
|
2
|
+
import math
|
|
2
3
|
import time
|
|
3
4
|
|
|
4
5
|
import aiohttp
|
|
@@ -15,6 +16,18 @@ from .voice.stt_session import SttSessionTracker
|
|
|
15
16
|
NODE_VOICE_API = "http://localhost:18081"
|
|
16
17
|
# Default aiohttp timeout is 5 minutes - too long for a dead voice service.
|
|
17
18
|
NODE_REQUEST_TIMEOUT = aiohttp.ClientTimeout(total=5)
|
|
19
|
+
# Must match DEFAULT_WAKE_THRESHOLD / DEFAULT_VAD_THRESHOLD in voice-service - the two processes
|
|
20
|
+
# decide these independently, and only voice-service's values actually gate anything.
|
|
21
|
+
DEFAULT_WAKE_THRESHOLD = 0.4
|
|
22
|
+
DEFAULT_VAD_THRESHOLD = 3000
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _autocomplete_query(current) -> str:
|
|
26
|
+
# discord.py hands a focused NUMBER option back as float('nan') when the input box is empty,
|
|
27
|
+
# and passes INTEGER options through unconverted - neither is guaranteed to be a str.
|
|
28
|
+
if isinstance(current, float) and math.isnan(current):
|
|
29
|
+
return ""
|
|
30
|
+
return str(current)
|
|
18
31
|
|
|
19
32
|
|
|
20
33
|
class VoiceCog(commands.Cog):
|
|
@@ -60,6 +73,12 @@ class VoiceCog(commands.Cog):
|
|
|
60
73
|
def _wake_word_required(self, user_id) -> bool:
|
|
61
74
|
return (self.bot_settings.get("wake_word_required") or {}).get(str(user_id), True)
|
|
62
75
|
|
|
76
|
+
def _wake_threshold(self, user_id) -> float:
|
|
77
|
+
return (self.bot_settings.get("wake_thresholds") or {}).get(str(user_id), DEFAULT_WAKE_THRESHOLD)
|
|
78
|
+
|
|
79
|
+
def _vad_threshold(self, user_id) -> int:
|
|
80
|
+
return (self.bot_settings.get("voice_thresholds") or {}).get(str(user_id), DEFAULT_VAD_THRESHOLD)
|
|
81
|
+
|
|
63
82
|
async def handle_voice_service_down(self):
|
|
64
83
|
await self.enrollment.handle_voice_service_down()
|
|
65
84
|
|
|
@@ -83,8 +102,9 @@ class VoiceCog(commands.Cog):
|
|
|
83
102
|
self, interaction: discord.Interaction, current: str
|
|
84
103
|
) -> list[app_commands.Choice[int]]:
|
|
85
104
|
current_val = int(self.bot_settings.get("active_timer", 60))
|
|
105
|
+
query = _autocomplete_query(current)
|
|
86
106
|
opts = []
|
|
87
|
-
if str(current_val) in
|
|
107
|
+
if str(current_val) in query or not query:
|
|
88
108
|
opts.append(app_commands.Choice(name=f"{current_val} (current)", value=current_val))
|
|
89
109
|
|
|
90
110
|
for v in [30, 60, 120, 300]:
|
|
@@ -95,9 +115,10 @@ class VoiceCog(commands.Cog):
|
|
|
95
115
|
async def interrupt_threshold_autocomplete(
|
|
96
116
|
self, interaction: discord.Interaction, current: str
|
|
97
117
|
) -> list[app_commands.Choice[int]]:
|
|
98
|
-
current_val =
|
|
118
|
+
current_val = self._vad_threshold(interaction.user.id)
|
|
119
|
+
query = _autocomplete_query(current)
|
|
99
120
|
opts = []
|
|
100
|
-
if str(current_val) in
|
|
121
|
+
if str(current_val) in query or not query:
|
|
101
122
|
opts.append(app_commands.Choice(name=f"{current_val} (current)", value=current_val))
|
|
102
123
|
|
|
103
124
|
for v in [1000, 2000, 3000, 5000]:
|
|
@@ -108,9 +129,10 @@ class VoiceCog(commands.Cog):
|
|
|
108
129
|
async def wake_sensitivity_autocomplete(
|
|
109
130
|
self, interaction: discord.Interaction, current: str
|
|
110
131
|
) -> list[app_commands.Choice[float]]:
|
|
111
|
-
current_val =
|
|
132
|
+
current_val = self._wake_threshold(interaction.user.id)
|
|
133
|
+
query = _autocomplete_query(current)
|
|
112
134
|
opts = []
|
|
113
|
-
if str(current_val) in
|
|
135
|
+
if str(current_val) in query or not query:
|
|
114
136
|
opts.append(app_commands.Choice(name=f"{current_val} (current)", value=current_val))
|
|
115
137
|
|
|
116
138
|
for v in [0.2, 0.3, 0.4, 0.5, 0.6]:
|
|
@@ -184,8 +206,9 @@ class VoiceCog(commands.Cog):
|
|
|
184
206
|
self, interaction: discord.Interaction, current: str
|
|
185
207
|
) -> list[app_commands.Choice[float]]:
|
|
186
208
|
current_val = float(self.bot_settings.get("tts_speed", 1.0))
|
|
209
|
+
query = _autocomplete_query(current)
|
|
187
210
|
opts = []
|
|
188
|
-
if str(current_val) in
|
|
211
|
+
if str(current_val) in query or not query:
|
|
189
212
|
opts.append(app_commands.Choice(name=f"{current_val}x (current)", value=current_val))
|
|
190
213
|
|
|
191
214
|
for v in [0.75, 1.0, 1.25, 1.3, 1.5, 1.75, 2.0]:
|
|
@@ -346,8 +369,8 @@ class VoiceCog(commands.Cog):
|
|
|
346
369
|
):
|
|
347
370
|
curr_wake = (self.bot_settings.get("wake_words") or {}).get(str(interaction.user.id), "None")
|
|
348
371
|
curr_timer = self.bot_settings.get("active_timer", 60)
|
|
349
|
-
curr_interrupt_thresh = self.
|
|
350
|
-
curr_wake_sens = self.
|
|
372
|
+
curr_interrupt_thresh = self._vad_threshold(interaction.user.id)
|
|
373
|
+
curr_wake_sens = self._wake_threshold(interaction.user.id)
|
|
351
374
|
curr_tts = self.bot_settings.get("tts_voice", "en-US-AriaNeural")
|
|
352
375
|
curr_tts_on = "ON" if self.bot_settings.get("tts_enabled", True) else "OFF"
|
|
353
376
|
curr_tts_speed = self.bot_settings.get("tts_speed", 1.0)
|
|
@@ -373,30 +396,30 @@ class VoiceCog(commands.Cog):
|
|
|
373
396
|
self.bot_settings["active_timer"] = active_times
|
|
374
397
|
updated.append(f"⏱️ Active Timer: `{active_times}s`")
|
|
375
398
|
if interrupt_threshold is not None:
|
|
376
|
-
self.bot_settings
|
|
399
|
+
self.bot_settings.setdefault("voice_thresholds", {})[str(interaction.user.id)] = interrupt_threshold
|
|
377
400
|
updated.append(f"🔊 Interrupt Threshold: `{interrupt_threshold}`")
|
|
378
401
|
try:
|
|
379
402
|
async with aiohttp.ClientSession(timeout=NODE_REQUEST_TIMEOUT) as session:
|
|
380
|
-
await session.post(
|
|
381
|
-
|
|
382
|
-
|
|
403
|
+
await session.post(
|
|
404
|
+
f"{NODE_VOICE_API}/set_vad_threshold",
|
|
405
|
+
json={"user_id": str(interaction.user.id), "threshold": interrupt_threshold},
|
|
406
|
+
)
|
|
407
|
+
except (aiohttp.ClientError, asyncio.TimeoutError) as e:
|
|
408
|
+
self.logger.warning(f"Node.js vad-threshold sync failed for {interaction.user.id}: {e}")
|
|
383
409
|
updated.append(f"(⚠️ Node.js Sync Failed: {e})")
|
|
384
|
-
except asyncio.TimeoutError:
|
|
385
|
-
self.logger.warning(f"Node.js sync timeout for {interaction.guild_id}")
|
|
386
|
-
updated.append("(⚠️ Node.js Sync Timeout)")
|
|
387
410
|
if wake_sensitivity is not None:
|
|
388
411
|
clamped_wake = max(0.05, min(0.95, wake_sensitivity))
|
|
389
|
-
self.bot_settings
|
|
412
|
+
self.bot_settings.setdefault("wake_thresholds", {})[str(interaction.user.id)] = clamped_wake
|
|
390
413
|
updated.append(f"🎯 Wake Sensitivity: `{clamped_wake}`")
|
|
391
414
|
try:
|
|
392
415
|
async with aiohttp.ClientSession(timeout=NODE_REQUEST_TIMEOUT) as session:
|
|
393
|
-
await session.post(
|
|
394
|
-
|
|
395
|
-
|
|
416
|
+
await session.post(
|
|
417
|
+
f"{NODE_VOICE_API}/set_wake_threshold",
|
|
418
|
+
json={"user_id": str(interaction.user.id), "threshold": clamped_wake},
|
|
419
|
+
)
|
|
420
|
+
except (aiohttp.ClientError, asyncio.TimeoutError) as e:
|
|
421
|
+
self.logger.warning(f"Node.js wake-threshold sync failed for {interaction.user.id}: {e}")
|
|
396
422
|
updated.append(f"(⚠️ Node.js Sync Failed: {e})")
|
|
397
|
-
except asyncio.TimeoutError:
|
|
398
|
-
self.logger.warning(f"Node.js sync timeout for {interaction.guild_id}")
|
|
399
|
-
updated.append("(⚠️ Node.js Sync Timeout)")
|
|
400
423
|
if tts_voice is not None:
|
|
401
424
|
self.bot_settings["tts_voice"] = tts_voice
|
|
402
425
|
updated.append(f"🗣️ TTS Voice: `{tts_voice}`")
|
package/src/config.py
CHANGED
|
@@ -25,8 +25,11 @@ DEFAULT_LGY_CONFIG = {
|
|
|
25
25
|
"allowed_user_ids": "",
|
|
26
26
|
# user_id (str) -> registered word, one per person (see EnrollmentManager._commit_enrollment).
|
|
27
27
|
"wake_words": {},
|
|
28
|
+
# user_id (str) -> wake-word match threshold; absent means voice-service's own default.
|
|
29
|
+
"wake_thresholds": {},
|
|
30
|
+
# user_id (str) -> interrupt/VAD RMS threshold; absent means voice-service's own default.
|
|
31
|
+
"voice_thresholds": {},
|
|
28
32
|
"active_timer": 60,
|
|
29
|
-
"voice_threshold": 3000,
|
|
30
33
|
"tts_voice": "ko-KR-SunHiNeural",
|
|
31
34
|
"tts_enabled": True,
|
|
32
35
|
# Sticky default for /new sessions, set whenever /model succeeds.
|
|
@@ -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.should_auto_title(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_slack.py
CHANGED
|
@@ -14,7 +14,12 @@ from config import SLACK_APP_TOKEN, SLACK_BOT_TOKEN, allowed, bot_settings, logg
|
|
|
14
14
|
from core import platform_health
|
|
15
15
|
from handlers.message_router import handle_message
|
|
16
16
|
from messengers.registry import register_adapter
|
|
17
|
-
from messengers.slack_adapter import
|
|
17
|
+
from messengers.slack_adapter import (
|
|
18
|
+
SlackAdapter,
|
|
19
|
+
SlackConversationRef,
|
|
20
|
+
encode_conversation_id,
|
|
21
|
+
latest_channel_session,
|
|
22
|
+
)
|
|
18
23
|
from utils.utils import get_default_cwd
|
|
19
24
|
|
|
20
25
|
|
|
@@ -129,6 +134,25 @@ async def cmd_model(ack, body, respond, context) -> None:
|
|
|
129
134
|
await respond(f"🤖 Model changed: *{final_model}*\n💾 Also set as the default for new sessions.")
|
|
130
135
|
|
|
131
136
|
|
|
137
|
+
async def cmd_permissions(ack, body, respond, context) -> None:
|
|
138
|
+
await ack()
|
|
139
|
+
adapter: SlackAdapter = context["adapter"]
|
|
140
|
+
user_id = body["user_id"]
|
|
141
|
+
channel = body["channel_id"]
|
|
142
|
+
|
|
143
|
+
if not allowed(user_id, "slack"):
|
|
144
|
+
await respond("❌ Denied")
|
|
145
|
+
return
|
|
146
|
+
|
|
147
|
+
from services import permissions
|
|
148
|
+
|
|
149
|
+
async def on_revoke(entry):
|
|
150
|
+
permissions.revoke(entry)
|
|
151
|
+
|
|
152
|
+
handle = adapter.create_permission_list(on_revoke)
|
|
153
|
+
await handle.send(SlackConversationRef(channel=channel, thread_ts=None))
|
|
154
|
+
|
|
155
|
+
|
|
132
156
|
async def cmd_credit(ack, body, respond, context) -> None:
|
|
133
157
|
await ack()
|
|
134
158
|
adapter: SlackAdapter = context["adapter"]
|
|
@@ -212,6 +236,7 @@ def build_app() -> tuple[AsyncApp, SlackAdapter]:
|
|
|
212
236
|
app.command("/new")(cmd_new)
|
|
213
237
|
app.command("/model")(cmd_model)
|
|
214
238
|
app.command("/credit")(cmd_credit)
|
|
239
|
+
app.command("/permissions")(cmd_permissions)
|
|
215
240
|
app.event("message")(on_message)
|
|
216
241
|
app.action(re.compile(".*"))(on_action)
|
|
217
242
|
app.view(re.compile(".*"))(on_view_submission)
|
package/src/main_telegram.py
CHANGED
|
@@ -135,6 +135,23 @@ async def cmd_model(update: Update, context) -> None:
|
|
|
135
135
|
)
|
|
136
136
|
|
|
137
137
|
|
|
138
|
+
async def cmd_permissions(update: Update, context) -> None:
|
|
139
|
+
user = update.effective_user
|
|
140
|
+
adapter: TelegramAdapter = context.bot_data["adapter"]
|
|
141
|
+
|
|
142
|
+
if not allowed(user.id, "telegram"):
|
|
143
|
+
await update.message.reply_text("❌ Denied")
|
|
144
|
+
return
|
|
145
|
+
|
|
146
|
+
from services import permissions
|
|
147
|
+
|
|
148
|
+
async def on_revoke(entry):
|
|
149
|
+
permissions.revoke(entry)
|
|
150
|
+
|
|
151
|
+
handle = adapter.create_permission_list(on_revoke)
|
|
152
|
+
await handle.send(update.effective_chat.id)
|
|
153
|
+
|
|
154
|
+
|
|
138
155
|
async def cmd_credit(update: Update, context) -> None:
|
|
139
156
|
user = update.effective_user
|
|
140
157
|
adapter: TelegramAdapter = context.bot_data["adapter"]
|
|
@@ -207,6 +224,7 @@ async def on_ready(app: Application) -> None:
|
|
|
207
224
|
BotCommand("new", "Start a new session (or /start)"),
|
|
208
225
|
BotCommand("model", "Change the AI model for this session"),
|
|
209
226
|
BotCommand("credit", "Turn AI Credits on/off"),
|
|
227
|
+
BotCommand("permissions", "View and remove allowed tools and commands"),
|
|
210
228
|
]
|
|
211
229
|
)
|
|
212
230
|
logger.info(f"✅ Bot is fully online and ready! Logged in as @{app.bot.username}")
|
|
@@ -221,6 +239,7 @@ def build_application() -> Application:
|
|
|
221
239
|
app.add_handler(CommandHandler(["new", "start"], cmd_new))
|
|
222
240
|
app.add_handler(CommandHandler("model", cmd_model))
|
|
223
241
|
app.add_handler(CommandHandler("credit", cmd_credit))
|
|
242
|
+
app.add_handler(CommandHandler("permissions", cmd_permissions))
|
|
224
243
|
app.add_handler(CallbackQueryHandler(adapter.handle_callback_query))
|
|
225
244
|
app.add_handler(MessageHandler(filters.ALL & ~filters.COMMAND, on_message))
|
|
226
245
|
app.add_error_handler(on_error)
|
package/src/messengers/base.py
CHANGED
|
@@ -54,6 +54,18 @@ class ToolApprovalOutcome:
|
|
|
54
54
|
scope: ScopeOption | None = None
|
|
55
55
|
|
|
56
56
|
|
|
57
|
+
@dataclass
|
|
58
|
+
class PermissionEntry:
|
|
59
|
+
kind: str
|
|
60
|
+
scope: str
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class PermissionListHandle(ABC):
|
|
64
|
+
@abstractmethod
|
|
65
|
+
async def send(self, conversation_ref: Any) -> Any:
|
|
66
|
+
raise NotImplementedError
|
|
67
|
+
|
|
68
|
+
|
|
57
69
|
class PromptHandle(ABC):
|
|
58
70
|
outcome: ToolApprovalOutcome | None = None
|
|
59
71
|
|
|
@@ -96,9 +108,7 @@ class MessengerAdapter(ABC):
|
|
|
96
108
|
async def start_conversation(self, origin_ref: Any, title: str) -> Any:
|
|
97
109
|
raise NotImplementedError
|
|
98
110
|
|
|
99
|
-
def
|
|
100
|
-
"""Per-conversation version of supports_renaming - lets a single adapter answer
|
|
101
|
-
differently depending on the target (e.g. Discord threads vs. Discord DMs)."""
|
|
111
|
+
def should_auto_title(self, conversation_ref: Any) -> bool:
|
|
102
112
|
return self.supports_renaming
|
|
103
113
|
|
|
104
114
|
@abstractmethod
|
|
@@ -119,6 +129,10 @@ class MessengerAdapter(ABC):
|
|
|
119
129
|
) -> PromptHandle:
|
|
120
130
|
raise NotImplementedError
|
|
121
131
|
|
|
132
|
+
@abstractmethod
|
|
133
|
+
def create_permission_list(self, on_revoke: Callable[[PermissionEntry], Awaitable[None]]) -> PermissionListHandle:
|
|
134
|
+
raise NotImplementedError
|
|
135
|
+
|
|
122
136
|
@abstractmethod
|
|
123
137
|
def create_question_prompt(
|
|
124
138
|
self,
|
|
@@ -12,6 +12,7 @@ from messengers.base import (
|
|
|
12
12
|
IncomingAttachment,
|
|
13
13
|
IncomingMessage,
|
|
14
14
|
MessengerAdapter,
|
|
15
|
+
PermissionListHandle,
|
|
15
16
|
PromptHandle,
|
|
16
17
|
ScopeOption,
|
|
17
18
|
ToolApprovalOutcome,
|
|
@@ -32,6 +33,58 @@ class _ErrorLoggingView(discord.ui.View):
|
|
|
32
33
|
pass
|
|
33
34
|
|
|
34
35
|
|
|
36
|
+
class _DiscordPermissionList(PermissionListHandle):
|
|
37
|
+
def __init__(self, on_revoke):
|
|
38
|
+
self.on_revoke = on_revoke
|
|
39
|
+
self.page = 0
|
|
40
|
+
self.message: discord.Message | None = None
|
|
41
|
+
|
|
42
|
+
def _build(self):
|
|
43
|
+
from services import permissions
|
|
44
|
+
|
|
45
|
+
entries = permissions.list_entries()
|
|
46
|
+
page_entries, self.page, total_pages = permissions.page_of(entries, self.page)
|
|
47
|
+
embed = discord.Embed(
|
|
48
|
+
title="🔐 Allowed Permissions",
|
|
49
|
+
description=permissions.render_body(page_entries, self.page, total_pages),
|
|
50
|
+
color=discord.Color.blurple(),
|
|
51
|
+
)
|
|
52
|
+
view = _ErrorLoggingView(timeout=None)
|
|
53
|
+
|
|
54
|
+
for entry in page_entries:
|
|
55
|
+
label = f"🗑️ {permissions.entry_label(entry)}"
|
|
56
|
+
button = discord.ui.Button(label=label[:80], style=discord.ButtonStyle.gray)
|
|
57
|
+
|
|
58
|
+
async def revoke_callback(interaction: discord.Interaction, entry=entry):
|
|
59
|
+
await self.on_revoke(entry)
|
|
60
|
+
await self._refresh(interaction)
|
|
61
|
+
|
|
62
|
+
button.callback = revoke_callback
|
|
63
|
+
view.add_item(button)
|
|
64
|
+
|
|
65
|
+
if total_pages > 1:
|
|
66
|
+
for label, delta in (("◀ Prev", -1), ("Next ▶", 1)):
|
|
67
|
+
nav = discord.ui.Button(label=label, style=discord.ButtonStyle.blurple)
|
|
68
|
+
|
|
69
|
+
async def nav_callback(interaction: discord.Interaction, delta=delta):
|
|
70
|
+
self.page += delta
|
|
71
|
+
await self._refresh(interaction)
|
|
72
|
+
|
|
73
|
+
nav.callback = nav_callback
|
|
74
|
+
view.add_item(nav)
|
|
75
|
+
|
|
76
|
+
return embed, view
|
|
77
|
+
|
|
78
|
+
async def _refresh(self, interaction: discord.Interaction) -> None:
|
|
79
|
+
embed, view = self._build()
|
|
80
|
+
await interaction.response.edit_message(embed=embed, view=view)
|
|
81
|
+
|
|
82
|
+
async def send(self, conversation_ref: discord.abc.Messageable) -> discord.Message:
|
|
83
|
+
embed, view = self._build()
|
|
84
|
+
self.message = await conversation_ref.send(embed=embed, view=view)
|
|
85
|
+
return self.message
|
|
86
|
+
|
|
87
|
+
|
|
35
88
|
class _DiscordPromptHandle(PromptHandle):
|
|
36
89
|
def __init__(self, embed: discord.Embed, view: discord.ui.View):
|
|
37
90
|
self.embed = embed
|
|
@@ -149,8 +202,8 @@ class DiscordAdapter(MessengerAdapter):
|
|
|
149
202
|
async def start_conversation(self, origin_ref: discord.Message, title: str) -> discord.Thread:
|
|
150
203
|
return await origin_ref.create_thread(name=title[:100], auto_archive_duration=1440)
|
|
151
204
|
|
|
152
|
-
def
|
|
153
|
-
return isinstance(conversation_ref, discord.Thread)
|
|
205
|
+
def should_auto_title(self, conversation_ref: Any) -> bool:
|
|
206
|
+
return isinstance(conversation_ref, discord.Thread) and conversation_ref.name.startswith("Session-")
|
|
154
207
|
|
|
155
208
|
async def rename_conversation(self, conversation_ref: discord.Thread, title: str) -> None:
|
|
156
209
|
if not isinstance(conversation_ref, discord.Thread):
|
|
@@ -215,6 +268,9 @@ class DiscordAdapter(MessengerAdapter):
|
|
|
215
268
|
|
|
216
269
|
return handle
|
|
217
270
|
|
|
271
|
+
def create_permission_list(self, on_revoke) -> PermissionListHandle:
|
|
272
|
+
return _DiscordPermissionList(on_revoke)
|
|
273
|
+
|
|
218
274
|
def create_question_prompt(
|
|
219
275
|
self,
|
|
220
276
|
answer_future: asyncio.Future,
|
|
@@ -17,6 +17,7 @@ from messengers.base import (
|
|
|
17
17
|
IncomingAttachment,
|
|
18
18
|
IncomingMessage,
|
|
19
19
|
MessengerAdapter,
|
|
20
|
+
PermissionListHandle,
|
|
20
21
|
PromptHandle,
|
|
21
22
|
ScopeOption,
|
|
22
23
|
ToolApprovalOutcome,
|
|
@@ -85,6 +86,77 @@ class SlackMessageRef:
|
|
|
85
86
|
self.ts = ts
|
|
86
87
|
|
|
87
88
|
|
|
89
|
+
class _SlackPermissionList(PermissionListHandle):
|
|
90
|
+
def __init__(self, client: AsyncWebClient, callbacks: dict, on_revoke):
|
|
91
|
+
self.client = client
|
|
92
|
+
self._callbacks = callbacks
|
|
93
|
+
self.on_revoke = on_revoke
|
|
94
|
+
self.page = 0
|
|
95
|
+
self.list_id = uuid.uuid4().hex[:12]
|
|
96
|
+
self._keys: list[str] = []
|
|
97
|
+
self.channel: str | None = None
|
|
98
|
+
self.ts: str | None = None
|
|
99
|
+
|
|
100
|
+
def _build(self):
|
|
101
|
+
from services import permissions
|
|
102
|
+
|
|
103
|
+
for key in self._keys:
|
|
104
|
+
self._callbacks.pop(key, None)
|
|
105
|
+
self._keys = []
|
|
106
|
+
|
|
107
|
+
entries = permissions.list_entries()
|
|
108
|
+
page_entries, self.page, total_pages = permissions.page_of(entries, self.page)
|
|
109
|
+
body = permissions.render_body(page_entries, self.page, total_pages)
|
|
110
|
+
text = f"*🔐 Allowed Permissions*\n\n{body}"
|
|
111
|
+
blocks = [{"type": "section", "text": {"type": "mrkdwn", "text": text[:2990]}}]
|
|
112
|
+
elements = []
|
|
113
|
+
|
|
114
|
+
for i, entry in enumerate(page_entries):
|
|
115
|
+
key = f"{self.list_id}:revoke:{i}"
|
|
116
|
+
self._callbacks[key] = lambda b, c, entry=entry: self._revoke(entry)
|
|
117
|
+
self._keys.append(key)
|
|
118
|
+
label = f"🗑️ {permissions.entry_label(entry)}"
|
|
119
|
+
elements.append({"type": "button", "text": {"type": "plain_text", "text": label[:75]}, "action_id": key})
|
|
120
|
+
|
|
121
|
+
if total_pages > 1:
|
|
122
|
+
for label, delta in (("◀ Prev", -1), ("Next ▶", 1)):
|
|
123
|
+
key = f"{self.list_id}:page:{delta}"
|
|
124
|
+
self._callbacks[key] = lambda b, c, delta=delta: self._turn(delta)
|
|
125
|
+
self._keys.append(key)
|
|
126
|
+
elements.append({"type": "button", "text": {"type": "plain_text", "text": label}, "action_id": key})
|
|
127
|
+
|
|
128
|
+
# 25 is Slack's hard per-block limit on action elements.
|
|
129
|
+
for chunk_start in range(0, len(elements), 25):
|
|
130
|
+
blocks.append({"type": "actions", "elements": elements[chunk_start : chunk_start + 25]})
|
|
131
|
+
|
|
132
|
+
return text, blocks
|
|
133
|
+
|
|
134
|
+
async def _redraw(self) -> None:
|
|
135
|
+
text, blocks = self._build()
|
|
136
|
+
if self.channel and self.ts:
|
|
137
|
+
await self.client.chat_update(channel=self.channel, ts=self.ts, text=text, blocks=blocks)
|
|
138
|
+
|
|
139
|
+
async def _revoke(self, entry) -> None:
|
|
140
|
+
await self.on_revoke(entry)
|
|
141
|
+
await self._redraw()
|
|
142
|
+
|
|
143
|
+
async def _turn(self, delta: int) -> None:
|
|
144
|
+
self.page += delta
|
|
145
|
+
await self._redraw()
|
|
146
|
+
|
|
147
|
+
async def send(self, conversation_ref: SlackConversationRef) -> dict:
|
|
148
|
+
text, blocks = self._build()
|
|
149
|
+
resp = await self.client.chat_postMessage(
|
|
150
|
+
channel=conversation_ref.channel,
|
|
151
|
+
thread_ts=conversation_ref.api_thread_ts,
|
|
152
|
+
text=text,
|
|
153
|
+
blocks=blocks,
|
|
154
|
+
)
|
|
155
|
+
self.channel = resp["channel"]
|
|
156
|
+
self.ts = resp["ts"]
|
|
157
|
+
return resp
|
|
158
|
+
|
|
159
|
+
|
|
88
160
|
class _SlackPromptHandle(PromptHandle):
|
|
89
161
|
def __init__(
|
|
90
162
|
self, client: AsyncWebClient, text: str, blocks: list[dict], cleanup: Callable[[], None] | None = None
|
|
@@ -358,6 +430,9 @@ class SlackAdapter(MessengerAdapter):
|
|
|
358
430
|
)
|
|
359
431
|
return handle
|
|
360
432
|
|
|
433
|
+
def create_permission_list(self, on_revoke) -> PermissionListHandle:
|
|
434
|
+
return _SlackPermissionList(self.client, self._callbacks, on_revoke)
|
|
435
|
+
|
|
361
436
|
def create_question_prompt(
|
|
362
437
|
self,
|
|
363
438
|
answer_future: asyncio.Future,
|
|
@@ -19,6 +19,7 @@ from messengers.base import (
|
|
|
19
19
|
IncomingAttachment,
|
|
20
20
|
IncomingMessage,
|
|
21
21
|
MessengerAdapter,
|
|
22
|
+
PermissionListHandle,
|
|
22
23
|
PromptHandle,
|
|
23
24
|
ScopeOption,
|
|
24
25
|
ToolApprovalOutcome,
|
|
@@ -52,6 +53,63 @@ async def safe_query_edit(query, **kwargs) -> None:
|
|
|
52
53
|
raise
|
|
53
54
|
|
|
54
55
|
|
|
56
|
+
class _TelegramPermissionList(PermissionListHandle):
|
|
57
|
+
def __init__(self, bot, callbacks: dict, on_revoke):
|
|
58
|
+
self.bot = bot
|
|
59
|
+
self._callbacks = callbacks
|
|
60
|
+
self.on_revoke = on_revoke
|
|
61
|
+
self.page = 0
|
|
62
|
+
self.list_id = uuid.uuid4().hex[:12]
|
|
63
|
+
self._keys: list[str] = []
|
|
64
|
+
|
|
65
|
+
def _build(self):
|
|
66
|
+
from services import permissions
|
|
67
|
+
|
|
68
|
+
for key in self._keys:
|
|
69
|
+
self._callbacks.pop(key, None)
|
|
70
|
+
self._keys = []
|
|
71
|
+
|
|
72
|
+
entries = permissions.list_entries()
|
|
73
|
+
page_entries, self.page, total_pages = permissions.page_of(entries, self.page)
|
|
74
|
+
body = permissions.render_body(page_entries, self.page, total_pages)
|
|
75
|
+
text = f"<b>🔐 Allowed Permissions</b>\n\n{html.escape(body)}"
|
|
76
|
+
keyboard: list[list[InlineKeyboardButton]] = []
|
|
77
|
+
|
|
78
|
+
for i, entry in enumerate(page_entries):
|
|
79
|
+
key = f"{self.list_id}:revoke:{i}"
|
|
80
|
+
self._callbacks[key] = lambda query, entry=entry: self._revoke(entry, query)
|
|
81
|
+
self._keys.append(key)
|
|
82
|
+
keyboard.append([InlineKeyboardButton(f"🗑️ {permissions.entry_label(entry)}"[:64], callback_data=key)])
|
|
83
|
+
|
|
84
|
+
if total_pages > 1:
|
|
85
|
+
row = []
|
|
86
|
+
for label, delta in (("◀ Prev", -1), ("Next ▶", 1)):
|
|
87
|
+
key = f"{self.list_id}:page:{delta}"
|
|
88
|
+
self._callbacks[key] = lambda query, delta=delta: self._turn(delta, query)
|
|
89
|
+
self._keys.append(key)
|
|
90
|
+
row.append(InlineKeyboardButton(label, callback_data=key))
|
|
91
|
+
keyboard.append(row)
|
|
92
|
+
|
|
93
|
+
return text, InlineKeyboardMarkup(keyboard)
|
|
94
|
+
|
|
95
|
+
async def _redraw(self, query) -> None:
|
|
96
|
+
text, markup = self._build()
|
|
97
|
+
await query.answer()
|
|
98
|
+
await safe_query_edit(query, text=text, parse_mode="HTML", reply_markup=markup)
|
|
99
|
+
|
|
100
|
+
async def _revoke(self, entry, query) -> None:
|
|
101
|
+
await self.on_revoke(entry)
|
|
102
|
+
await self._redraw(query)
|
|
103
|
+
|
|
104
|
+
async def _turn(self, delta: int, query) -> None:
|
|
105
|
+
self.page += delta
|
|
106
|
+
await self._redraw(query)
|
|
107
|
+
|
|
108
|
+
async def send(self, conversation_ref: int) -> Message:
|
|
109
|
+
text, markup = self._build()
|
|
110
|
+
return await self.bot.send_message(chat_id=conversation_ref, text=text, reply_markup=markup, parse_mode="HTML")
|
|
111
|
+
|
|
112
|
+
|
|
55
113
|
class _TelegramPromptHandle(PromptHandle):
|
|
56
114
|
def __init__(self, bot, text: str, reply_markup, cleanup: Callable[[], None] | None = None):
|
|
57
115
|
self.bot = bot
|
|
@@ -285,6 +343,9 @@ class TelegramAdapter(MessengerAdapter):
|
|
|
285
343
|
)
|
|
286
344
|
return handle
|
|
287
345
|
|
|
346
|
+
def create_permission_list(self, on_revoke) -> PermissionListHandle:
|
|
347
|
+
return _TelegramPermissionList(self.bot, self._callbacks, on_revoke)
|
|
348
|
+
|
|
288
349
|
def create_question_prompt(
|
|
289
350
|
self,
|
|
290
351
|
answer_future: asyncio.Future,
|
|
@@ -74,7 +74,7 @@ def check_approval_intent(text: str) -> str:
|
|
|
74
74
|
"ㅇㅇ",
|
|
75
75
|
"ㅇㅋ",
|
|
76
76
|
"해",
|
|
77
|
-
"
|
|
77
|
+
"그래",
|
|
78
78
|
"네",
|
|
79
79
|
"sure",
|
|
80
80
|
"yeah",
|
|
@@ -92,3 +92,31 @@ def check_approval_intent(text: str) -> str:
|
|
|
92
92
|
if word in exact_allow:
|
|
93
93
|
return "allow"
|
|
94
94
|
return None
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def split_message(text: str, limit: int) -> list[str]:
|
|
98
|
+
parts = []
|
|
99
|
+
fence = None
|
|
100
|
+
remaining = text
|
|
101
|
+
while remaining:
|
|
102
|
+
# A chunk that ends mid-code-block gets closed here and reopened at the top of the next one,
|
|
103
|
+
# otherwise the client renders the rest of the message as one runaway code block.
|
|
104
|
+
prefix = f"{fence}\n" if fence else ""
|
|
105
|
+
budget = limit - len(prefix) - len("\n```")
|
|
106
|
+
if len(remaining) <= budget:
|
|
107
|
+
body, remaining = remaining, ""
|
|
108
|
+
else:
|
|
109
|
+
window = remaining[:budget]
|
|
110
|
+
cuts = [pos + len(d) for d in ("\n\n", "\n", " ") if (pos := window.rfind(d)) > 0]
|
|
111
|
+
cut = next((c for c in cuts if c > budget // 2), max(cuts, default=0))
|
|
112
|
+
body, remaining = remaining[: cut or budget], remaining[cut or budget :]
|
|
113
|
+
chunk = prefix + body
|
|
114
|
+
fence = None
|
|
115
|
+
for match in re.finditer(r"^```(\S*)", chunk, re.MULTILINE):
|
|
116
|
+
fence = None if fence else f"```{match.group(1)}"
|
|
117
|
+
if fence:
|
|
118
|
+
chunk += "\n```"
|
|
119
|
+
if parts and not re.sub(r"^```\S*$", "", chunk, flags=re.MULTILINE).strip():
|
|
120
|
+
continue
|
|
121
|
+
parts.append(chunk)
|
|
122
|
+
return parts or [""]
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
from config import session_manager
|
|
2
|
+
from messengers.base import PermissionEntry
|
|
3
|
+
|
|
4
|
+
# Discord allows 25 components per message; the rest is headroom for paging buttons.
|
|
5
|
+
PAGE_SIZE = 20
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def list_entries() -> list[PermissionEntry]:
|
|
9
|
+
allowed = session_manager.persistent_allowed
|
|
10
|
+
return [
|
|
11
|
+
PermissionEntry(kind=kind, scope=scope) for kind in ("tools", "commands") for scope in allowed.get(kind) or []
|
|
12
|
+
]
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def revoke(entry: PermissionEntry) -> bool:
|
|
16
|
+
bucket = session_manager.persistent_allowed.get(entry.kind) or []
|
|
17
|
+
if entry.scope not in bucket:
|
|
18
|
+
return False
|
|
19
|
+
bucket.remove(entry.scope)
|
|
20
|
+
session_manager.save_persistent()
|
|
21
|
+
return True
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def page_of(entries: list[PermissionEntry], page: int) -> tuple[list[PermissionEntry], int, int]:
|
|
25
|
+
total_pages = max(1, -(-len(entries) // PAGE_SIZE))
|
|
26
|
+
page = max(0, min(page, total_pages - 1))
|
|
27
|
+
start = page * PAGE_SIZE
|
|
28
|
+
return entries[start : start + PAGE_SIZE], page, total_pages
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def entry_label(entry: PermissionEntry) -> str:
|
|
32
|
+
suffix = " (tool)" if entry.kind == "tools" else ""
|
|
33
|
+
return f"{entry.scope}{suffix}"
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def render_body(entries: list[PermissionEntry], page: int, total_pages: int) -> str:
|
|
37
|
+
if not entries:
|
|
38
|
+
return "No permissions have been allowed yet."
|
|
39
|
+
lines = [f"• {entry_label(e)}" for e in entries]
|
|
40
|
+
if total_pages > 1:
|
|
41
|
+
lines.append(f"\nPage {page + 1} of {total_pages}")
|
|
42
|
+
return "\n".join(lines)
|
package/src/services/response.py
CHANGED
|
@@ -3,6 +3,7 @@ from typing import Any
|
|
|
3
3
|
|
|
4
4
|
from config import MAX_EMBED_LEN, MODEL_CHOICES, session_manager
|
|
5
5
|
from messengers.registry import get_adapter_for_platform
|
|
6
|
+
from services.discord_helpers import split_message
|
|
6
7
|
from utils.utils import get_current_model
|
|
7
8
|
|
|
8
9
|
|
|
@@ -17,24 +18,20 @@ async def send_agy_response(
|
|
|
17
18
|
adapter = get_adapter_for_platform(session.get("platform", "discord"))
|
|
18
19
|
session_manager.save_sessions()
|
|
19
20
|
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
is_last = idx == len(parts) - 1
|
|
21
|
+
session_model = session.get("model")
|
|
22
|
+
model_display = MODEL_CHOICES.get(session_model, session_model) if session_model else get_current_model()
|
|
23
23
|
|
|
24
|
-
|
|
24
|
+
parts = split_message(response_text, MAX_EMBED_LEN)
|
|
25
|
+
for idx, part in enumerate(parts):
|
|
26
|
+
if idx == len(parts) - 1:
|
|
25
27
|
if not part.strip():
|
|
26
28
|
continue
|
|
29
|
+
part = f"{part}\n-# 🤖 {model_display}"
|
|
27
30
|
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
status_msg = ctx.get("status_msg") if ctx else None
|
|
33
|
-
if status_msg and await adapter.edit_message(status_msg, text_to_send):
|
|
34
|
-
continue
|
|
35
|
-
await adapter.send_message(thread, text_to_send)
|
|
36
|
-
else:
|
|
37
|
-
await adapter.send_message(thread, part)
|
|
31
|
+
status_msg = ctx.get("status_msg") if ctx and idx == 0 else None
|
|
32
|
+
if status_msg and await adapter.edit_message(status_msg, part):
|
|
33
|
+
continue
|
|
34
|
+
await adapter.send_message(thread, part)
|
|
38
35
|
|
|
39
36
|
files_to_send = []
|
|
40
37
|
if conv_id and start_time:
|
|
@@ -7,7 +7,7 @@ import discord # only for voice/TTS text cleanup below; messaging goes through
|
|
|
7
7
|
|
|
8
8
|
from config import MAX_EMBED_LEN, STREAM_RATE_LIMIT_SEC, bot_settings, logger, session_manager
|
|
9
9
|
from messengers.registry import get_adapter_for_thread
|
|
10
|
-
from utils.utils import clean_ansi
|
|
10
|
+
from utils.utils import clean_ansi, split_message
|
|
11
11
|
|
|
12
12
|
|
|
13
13
|
def _clear_current_tool(thread_id: str):
|
|
@@ -46,7 +46,7 @@ class StreamUpdater:
|
|
|
46
46
|
async def split(self):
|
|
47
47
|
full_text = self.current_text.strip()
|
|
48
48
|
if full_text:
|
|
49
|
-
parts =
|
|
49
|
+
parts = split_message(full_text, self.MAX_EMBED_LEN)
|
|
50
50
|
for idx, part in enumerate(parts):
|
|
51
51
|
await self._update(part, force_new=(idx > 0))
|
|
52
52
|
self.status_msg = None
|
package/src/utils/utils.py
CHANGED
|
@@ -16,6 +16,7 @@ from services.discord_helpers import (
|
|
|
16
16
|
clean_ansi,
|
|
17
17
|
cleanup_images,
|
|
18
18
|
handle_image_attachments,
|
|
19
|
+
split_message,
|
|
19
20
|
)
|
|
20
21
|
|
|
21
22
|
__all__ = [
|
|
@@ -33,6 +34,7 @@ __all__ = [
|
|
|
33
34
|
"cleanup_images",
|
|
34
35
|
"build_content_with_images",
|
|
35
36
|
"clean_ansi",
|
|
37
|
+
"split_message",
|
|
36
38
|
"check_approval_intent",
|
|
37
39
|
"get_default_cwd",
|
|
38
40
|
]
|
package/voice-service/index.js
CHANGED
|
@@ -18,8 +18,11 @@ process.on('uncaughtException', (err) => {
|
|
|
18
18
|
|
|
19
19
|
const { registerRoutes } = require('./routes');
|
|
20
20
|
|
|
21
|
-
|
|
22
|
-
state.
|
|
21
|
+
for (const [userId, value] of Object.entries(aglConfig.voice_thresholds || {})) {
|
|
22
|
+
state.vadThresholds.set(userId, parseInt(value));
|
|
23
|
+
}
|
|
24
|
+
for (const [userId, value] of Object.entries(aglConfig.wake_thresholds || {})) {
|
|
25
|
+
state.wakeThresholds.set(userId, parseFloat(value));
|
|
23
26
|
}
|
|
24
27
|
|
|
25
28
|
const app = express();
|
|
@@ -2,12 +2,18 @@ const { EndBehaviorType } = require('@discordjs/voice');
|
|
|
2
2
|
const prism = require('prism-media');
|
|
3
3
|
const { stereoToMono, createWavHeader } = require('./audioUtils');
|
|
4
4
|
const { googleSTT } = require('./stt');
|
|
5
|
-
const { getDetectorForUser, feedPCMToDetector,
|
|
5
|
+
const { getDetectorForUser, feedPCMToDetector, wakeThresholdFor } = require('./wakeword');
|
|
6
6
|
const { interruptTTS } = require('./tts');
|
|
7
7
|
const state = require('./state');
|
|
8
8
|
const { aglConfig } = require('./config');
|
|
9
|
-
const {
|
|
10
|
-
|
|
9
|
+
const {
|
|
10
|
+
activeStreams,
|
|
11
|
+
enrollingUsers,
|
|
12
|
+
isPlaying,
|
|
13
|
+
wakeWordOptedOut,
|
|
14
|
+
vadThresholdFor,
|
|
15
|
+
isGuildActive,
|
|
16
|
+
} = state;
|
|
11
17
|
|
|
12
18
|
function setupReceiver(connection, guildId, client) {
|
|
13
19
|
const receiver = connection.receiver;
|
|
@@ -138,9 +144,8 @@ function setupReceiver(connection, guildId, client) {
|
|
|
138
144
|
const isBotPlaying = isPlaying.get(guildId) || false;
|
|
139
145
|
|
|
140
146
|
if (!hasInterrupted) {
|
|
141
|
-
const
|
|
142
|
-
|
|
143
|
-
: runtime.vadThreshold;
|
|
147
|
+
const baseThreshold = vadThresholdFor(userId);
|
|
148
|
+
const dynamicThreshold = isBotPlaying ? baseThreshold * 3 : baseThreshold;
|
|
144
149
|
if (rms > dynamicThreshold) {
|
|
145
150
|
if (interruptTTS(guildId)) {
|
|
146
151
|
console.log(
|
|
@@ -226,15 +231,17 @@ function setupReceiver(connection, guildId, client) {
|
|
|
226
231
|
bestDiagScoreName = diagPaddingDetection.getName();
|
|
227
232
|
}
|
|
228
233
|
|
|
229
|
-
//
|
|
230
|
-
|
|
234
|
+
// rustpotter only emits a detection once its own per-user threshold is met, so any
|
|
235
|
+
// score reaching here is already a pass; the number below is for the log line only.
|
|
236
|
+
const threshold = wakeThresholdFor(userId);
|
|
237
|
+
wakeConfirmed = bestWakeScoreName !== null;
|
|
231
238
|
matchedWakeWord = wakeConfirmed ? bestWakeScoreName : null;
|
|
232
239
|
console.log(
|
|
233
240
|
wakeConfirmed
|
|
234
241
|
? `[Wake] ${userId}: CONFIRMED (score ${bestWakeScore.toFixed(3)} for ` +
|
|
235
|
-
`"${bestWakeScoreName}", threshold ${
|
|
242
|
+
`"${bestWakeScoreName}", threshold ${threshold})`
|
|
236
243
|
: `[Wake] ${userId}: no match (score ${bestWakeScore.toFixed(3)}, ` +
|
|
237
|
-
`threshold ${
|
|
244
|
+
`threshold ${threshold}; diagnostic-only closeness ` +
|
|
238
245
|
`${bestDiagScore.toFixed(3)} for "${bestDiagScoreName ?? 'n/a'}" - ` +
|
|
239
246
|
`different scoring config, not directly comparable to the threshold)`,
|
|
240
247
|
);
|
package/voice-service/routes.js
CHANGED
|
@@ -108,6 +108,19 @@ function registerRoutes(app, client) {
|
|
|
108
108
|
res.json({ success: true, was_cached: deleted });
|
|
109
109
|
});
|
|
110
110
|
|
|
111
|
+
app.post('/set_wake_threshold', (req, res) => {
|
|
112
|
+
const { user_id, threshold } = req.body;
|
|
113
|
+
if (!user_id || typeof threshold !== 'number') {
|
|
114
|
+
return res.status(400).json({ error: 'user_id and numeric threshold required' });
|
|
115
|
+
}
|
|
116
|
+
state.wakeThresholds.set(user_id, threshold);
|
|
117
|
+
// The threshold is baked into the rustpotter config at build time, so the cached detector
|
|
118
|
+
// has to go with it - otherwise the new value only takes effect after some unrelated reset.
|
|
119
|
+
state.detectorCache.delete(user_id);
|
|
120
|
+
console.log(`[Wake] Threshold for ${user_id} set to ${threshold}`);
|
|
121
|
+
res.json({ success: true });
|
|
122
|
+
});
|
|
123
|
+
|
|
111
124
|
app.post('/build_wakeword', async (req, res) => {
|
|
112
125
|
// Builds a .rpw in-process via WakewordRefCreator, instead of shelling out to rustpotter-cli.
|
|
113
126
|
try {
|
|
@@ -139,12 +152,13 @@ function registerRoutes(app, client) {
|
|
|
139
152
|
}
|
|
140
153
|
});
|
|
141
154
|
|
|
142
|
-
app.post('/
|
|
143
|
-
const {
|
|
144
|
-
if (
|
|
145
|
-
|
|
146
|
-
console.log(`[Config] Updated VAD threshold to ${state.runtime.vadThreshold}`);
|
|
155
|
+
app.post('/set_vad_threshold', (req, res) => {
|
|
156
|
+
const { user_id, threshold } = req.body;
|
|
157
|
+
if (!user_id || typeof threshold !== 'number') {
|
|
158
|
+
return res.status(400).json({ error: 'user_id and numeric threshold required' });
|
|
147
159
|
}
|
|
160
|
+
state.vadThresholds.set(user_id, threshold);
|
|
161
|
+
console.log(`[Config] VAD threshold for ${user_id} set to ${threshold}`);
|
|
148
162
|
res.json({ success: true });
|
|
149
163
|
});
|
|
150
164
|
|
package/voice-service/state.js
CHANGED
|
@@ -19,8 +19,18 @@ const suppressNotifyMap = new Map();
|
|
|
19
19
|
// userId -> { rustpotter, samplesPerFrame, residual: Int16Array }
|
|
20
20
|
const detectorCache = new Map();
|
|
21
21
|
|
|
22
|
+
// user_id -> wake-word match threshold; absent means DEFAULT_WAKE_THRESHOLD.
|
|
23
|
+
const wakeThresholds = new Map();
|
|
24
|
+
|
|
22
25
|
// Object property, not a plain `let` - a `let` wouldn't propagate its reassignment across modules.
|
|
23
|
-
|
|
26
|
+
// user_id -> interrupt/VAD RMS threshold; absent means DEFAULT_VAD_THRESHOLD.
|
|
27
|
+
const vadThresholds = new Map();
|
|
28
|
+
|
|
29
|
+
const DEFAULT_VAD_THRESHOLD = 3000;
|
|
30
|
+
|
|
31
|
+
function vadThresholdFor(userId) {
|
|
32
|
+
return vadThresholds.get(userId) ?? DEFAULT_VAD_THRESHOLD;
|
|
33
|
+
}
|
|
24
34
|
|
|
25
35
|
function isGuildActive(guildId) {
|
|
26
36
|
return Date.now() < (activeUntil.get(guildId) || 0);
|
|
@@ -37,6 +47,9 @@ module.exports = {
|
|
|
37
47
|
wakeWordOptedOut,
|
|
38
48
|
suppressNotifyMap,
|
|
39
49
|
detectorCache,
|
|
40
|
-
|
|
50
|
+
wakeThresholds,
|
|
51
|
+
vadThresholds,
|
|
52
|
+
DEFAULT_VAD_THRESHOLD,
|
|
53
|
+
vadThresholdFor,
|
|
41
54
|
isGuildActive,
|
|
42
55
|
};
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
const fs = require('fs');
|
|
2
2
|
const os = require('os');
|
|
3
3
|
const path = require('path');
|
|
4
|
-
const { detectorCache } = require('./state');
|
|
4
|
+
const { detectorCache, wakeThresholds } = require('./state');
|
|
5
5
|
|
|
6
6
|
// Rustpotter wake-word detection runs entirely in-process here, no Python round trip.
|
|
7
7
|
const WAKE_REF_DIR = path.join(os.homedir(), '.gemini', 'linkgravity', 'wake_refs');
|
|
@@ -21,7 +21,11 @@ function loadRustpotterModule() {
|
|
|
21
21
|
}
|
|
22
22
|
|
|
23
23
|
// Wake-word confirm cutoff - must stay well above ~0.05 (rustpotter's countdown never finalizes if noise/silence clears it too); 0.4 chosen after live use kept narrowly missing genuine hits just under 0.5.
|
|
24
|
-
const
|
|
24
|
+
const DEFAULT_WAKE_THRESHOLD = 0.4;
|
|
25
|
+
|
|
26
|
+
function wakeThresholdFor(userId) {
|
|
27
|
+
return wakeThresholds.get(userId) ?? DEFAULT_WAKE_THRESHOLD;
|
|
28
|
+
}
|
|
25
29
|
|
|
26
30
|
async function getDetectorForUser(userId) {
|
|
27
31
|
if (detectorCache.has(userId)) return detectorCache.get(userId);
|
|
@@ -37,7 +41,7 @@ async function getDetectorForUser(userId) {
|
|
|
37
41
|
config.setSampleRate(48000);
|
|
38
42
|
config.setSampleFormat(mod.SampleFormat.i16);
|
|
39
43
|
config.setChannels(1);
|
|
40
|
-
config.setThreshold(
|
|
44
|
+
config.setThreshold(wakeThresholdFor(userId));
|
|
41
45
|
config.setAveragedThreshold(0);
|
|
42
46
|
// Live logs showed genuine attempts peaking above threshold but not sustaining 4 positive-scoring
|
|
43
47
|
// frames; lowered from 4. STT-side prefix-similarity check is the backstop against false wakes.
|
|
@@ -107,7 +111,8 @@ function feedPCMToDetector(entry, chunk) {
|
|
|
107
111
|
|
|
108
112
|
module.exports = {
|
|
109
113
|
WAKE_REF_DIR,
|
|
110
|
-
|
|
114
|
+
DEFAULT_WAKE_THRESHOLD,
|
|
115
|
+
wakeThresholdFor,
|
|
111
116
|
loadRustpotterModule,
|
|
112
117
|
getDetectorForUser,
|
|
113
118
|
feedPCMToDetector,
|