linkgravity 1.5.8 → 1.5.10
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 +8 -5
- package/npm-scripts/ensure-env.js +30 -21
- package/package.json +1 -1
- package/src/api/ui_routes.py +2 -12
- package/src/cogs/general_cog.py +31 -2
- package/src/core/session_manager.py +0 -1
- package/src/main_slack.py +26 -1
- package/src/main_telegram.py +19 -0
- package/src/messengers/base.py +16 -0
- package/src/messengers/discord_adapter.py +56 -0
- package/src/messengers/slack_adapter.py +75 -0
- package/src/messengers/telegram_adapter.py +61 -0
- package/src/services/permissions.py +42 -0
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
|
@@ -376,11 +376,8 @@ if (cmd === 'version' || cmd === '-v' || cmd === '--version') {
|
|
|
376
376
|
}
|
|
377
377
|
|
|
378
378
|
if (!isEnvironmentReady()) {
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
`run ${color.cyan}lgy setup${color.reset} first (it installs everything on its first run).\n`,
|
|
382
|
-
);
|
|
383
|
-
process.exit(1);
|
|
379
|
+
info('Some dependencies are missing - installing them first...');
|
|
380
|
+
require('../npm-scripts/ensure-env').ensureEnvironment();
|
|
384
381
|
}
|
|
385
382
|
|
|
386
383
|
info('Starting LinkGravity daemon...');
|
|
@@ -602,6 +599,12 @@ if (cmd === 'version' || cmd === '-v' || cmd === '--version') {
|
|
|
602
599
|
}
|
|
603
600
|
success(`Installed v${latestVersion}.`);
|
|
604
601
|
|
|
602
|
+
// The new install ships without voice-service/node_modules, so restore anything the
|
|
603
|
+
// directory swap dropped. Loaded here rather than at the top of the file: by now npm has
|
|
604
|
+
// replaced this package on disk, and the copy required at startup is the pre-update one.
|
|
605
|
+
delete require.cache[require.resolve('../npm-scripts/ensure-env')];
|
|
606
|
+
require('../npm-scripts/ensure-env').ensureEnvironment();
|
|
607
|
+
|
|
605
608
|
if (!procBeforeUpdate) {
|
|
606
609
|
info("Daemon wasn't running - starting it fresh...");
|
|
607
610
|
runPm2(['start', LGY_SCRIPT_PATH, '--interpreter', pythonExe, '--name', LGY_PM2_NAME]);
|
|
@@ -8,31 +8,40 @@ const { pip: venvPip, python: venvPython, workspaceDir, repoRoot } = require('./
|
|
|
8
8
|
const isWin = os.platform() === 'win32';
|
|
9
9
|
const pyCmd = isWin ? 'python' : 'python3';
|
|
10
10
|
|
|
11
|
-
|
|
11
|
+
const voiceServiceDir = path.join(repoRoot, 'voice-service');
|
|
12
|
+
|
|
13
|
+
function isVenvReady() {
|
|
12
14
|
return fs.existsSync(venvPython);
|
|
13
15
|
}
|
|
14
16
|
|
|
17
|
+
function isVoiceServiceReady() {
|
|
18
|
+
return fs.existsSync(path.join(voiceServiceDir, 'node_modules'));
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function isEnvironmentReady() {
|
|
22
|
+
return isVenvReady() && isVoiceServiceReady();
|
|
23
|
+
}
|
|
24
|
+
|
|
15
25
|
function ensureEnvironment() {
|
|
16
|
-
if (
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
);
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
stdio: 'inherit',
|
|
34
|
-
|
|
35
|
-
});
|
|
26
|
+
if (!isVenvReady()) {
|
|
27
|
+
console.log('⚙️ Setting up Python Virtual Environment...');
|
|
28
|
+
console.log(
|
|
29
|
+
` (in ${path.join(workspaceDir, 'venv')} - not inside this install, so it survives`,
|
|
30
|
+
);
|
|
31
|
+
console.log(' package updates/reinstalls and works the same whether this is a global');
|
|
32
|
+
console.log(' `npm install -g linkgravity` or a local dev clone.)');
|
|
33
|
+
|
|
34
|
+
fs.mkdirSync(workspaceDir, { recursive: true });
|
|
35
|
+
execSync(`${pyCmd} -m venv "${path.join(workspaceDir, 'venv')}"`, { stdio: 'inherit' });
|
|
36
|
+
|
|
37
|
+
console.log('📦 Installing Python dependencies...');
|
|
38
|
+
execSync(`"${venvPip}" install -r requirements.txt`, { stdio: 'inherit', cwd: repoRoot });
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
if (!isVoiceServiceReady()) {
|
|
42
|
+
console.log('🎙️ Installing Voice Service dependencies...');
|
|
43
|
+
execSync('npm install', { stdio: 'inherit', cwd: voiceServiceDir });
|
|
44
|
+
}
|
|
36
45
|
|
|
37
46
|
console.log('✅ Environment ready.');
|
|
38
47
|
}
|
package/package.json
CHANGED
package/src/api/ui_routes.py
CHANGED
|
@@ -197,10 +197,7 @@ async def handle_approve_request(request):
|
|
|
197
197
|
break
|
|
198
198
|
except ValueError:
|
|
199
199
|
pass
|
|
200
|
-
elif is_tool_allowed(tool_name, {"CommandLine": sub_cmd})
|
|
201
|
-
conv_id in session_manager.session_allowed_tools
|
|
202
|
-
and tool_name in session_manager.session_allowed_tools[conv_id]
|
|
203
|
-
):
|
|
200
|
+
elif is_tool_allowed(tool_name, {"CommandLine": sub_cmd}):
|
|
204
201
|
is_auto_allowed = True
|
|
205
202
|
|
|
206
203
|
if is_auto_allowed:
|
|
@@ -271,14 +268,7 @@ async def handle_approve_request(request):
|
|
|
271
268
|
return allow_response(tool_name, tool_input)
|
|
272
269
|
|
|
273
270
|
else:
|
|
274
|
-
|
|
275
|
-
if is_tool_allowed(tool_name, tool_input) or (
|
|
276
|
-
conv_id in session_manager.session_allowed_tools
|
|
277
|
-
and tool_name in session_manager.session_allowed_tools[conv_id]
|
|
278
|
-
):
|
|
279
|
-
is_auto_allowed = True
|
|
280
|
-
|
|
281
|
-
if is_auto_allowed:
|
|
271
|
+
if is_tool_allowed(tool_name, tool_input):
|
|
282
272
|
if target_thread and tool_msg_text:
|
|
283
273
|
await send_ordered(
|
|
284
274
|
target_thread_id, lambda: _send_chunked(adapter, target_thread, tool_msg_formatted)
|
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/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
|
|
|
@@ -117,6 +129,10 @@ class MessengerAdapter(ABC):
|
|
|
117
129
|
) -> PromptHandle:
|
|
118
130
|
raise NotImplementedError
|
|
119
131
|
|
|
132
|
+
@abstractmethod
|
|
133
|
+
def create_permission_list(self, on_revoke: Callable[[PermissionEntry], Awaitable[None]]) -> PermissionListHandle:
|
|
134
|
+
raise NotImplementedError
|
|
135
|
+
|
|
120
136
|
@abstractmethod
|
|
121
137
|
def create_question_prompt(
|
|
122
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
|
|
@@ -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,
|
|
@@ -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)
|