agent-tether 0.2.0__py3-none-any.whl
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.
- agent_tether/__init__.py +64 -0
- agent_tether/approval.py +142 -0
- agent_tether/batching.py +62 -0
- agent_tether/debounce.py +40 -0
- agent_tether/formatting.py +176 -0
- agent_tether/models.py +108 -0
- agent_tether/platforms/__init__.py +0 -0
- agent_tether/platforms/base.py +598 -0
- agent_tether/platforms/discord/__init__.py +0 -0
- agent_tether/platforms/discord/bridge.py +403 -0
- agent_tether/platforms/discord/pairing.py +90 -0
- agent_tether/platforms/slack/__init__.py +0 -0
- agent_tether/platforms/slack/bridge.py +287 -0
- agent_tether/platforms/telegram/__init__.py +0 -0
- agent_tether/platforms/telegram/bridge.py +619 -0
- agent_tether/platforms/telegram/formatting.py +197 -0
- agent_tether/py.typed +0 -0
- agent_tether/router.py +55 -0
- agent_tether/runner/__init__.py +14 -0
- agent_tether/runner/adapters/__init__.py +18 -0
- agent_tether/runner/protocol.py +192 -0
- agent_tether/runner/registry.py +81 -0
- agent_tether/state.py +105 -0
- agent_tether/subscriber.py +205 -0
- agent_tether-0.2.0.dist-info/METADATA +178 -0
- agent_tether-0.2.0.dist-info/RECORD +28 -0
- agent_tether-0.2.0.dist-info/WHEEL +4 -0
- agent_tether-0.2.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,598 @@
|
|
|
1
|
+
"""Abstract base class for platform bridges.
|
|
2
|
+
|
|
3
|
+
BridgeBase provides the shared machinery for all platform bridges:
|
|
4
|
+
approval flow, auto-approve engine, command dispatch, notification
|
|
5
|
+
batching, error debouncing, and thread state. Platform implementations
|
|
6
|
+
override the ``_platform_*`` methods.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import asyncio
|
|
12
|
+
import logging
|
|
13
|
+
import re
|
|
14
|
+
from abc import ABC, abstractmethod
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
|
|
17
|
+
from agent_tether.approval import AutoApproveEngine
|
|
18
|
+
from agent_tether.batching import NotificationBatcher
|
|
19
|
+
from agent_tether.debounce import ErrorDebouncer
|
|
20
|
+
from agent_tether.formatting import format_tool_input
|
|
21
|
+
from agent_tether.models import ApprovalRequest, CommandDef, Handlers
|
|
22
|
+
from agent_tether.state import ThreadState
|
|
23
|
+
|
|
24
|
+
logger = logging.getLogger("agent_tether.bridge")
|
|
25
|
+
|
|
26
|
+
_STATE_EMOJI: dict[str, str] = {
|
|
27
|
+
"running": "🔄",
|
|
28
|
+
"waiting": "📝",
|
|
29
|
+
"error": "❌",
|
|
30
|
+
"done": "✅",
|
|
31
|
+
"thinking": "💭",
|
|
32
|
+
"executing": "⚙️",
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class BridgeBase(ABC):
|
|
37
|
+
"""Abstract base class for platform bridges.
|
|
38
|
+
|
|
39
|
+
Consumers interact through the public API (``create_thread``,
|
|
40
|
+
``send_output``, ``send_approval_request``, etc.). Platform events
|
|
41
|
+
(human messages, button clicks) are routed through the handlers.
|
|
42
|
+
|
|
43
|
+
Subclasses implement the ``_platform_*`` methods for
|
|
44
|
+
platform-specific behavior.
|
|
45
|
+
|
|
46
|
+
Args:
|
|
47
|
+
handlers: Callback handlers for platform events.
|
|
48
|
+
commands: Custom command definitions (name → CommandDef).
|
|
49
|
+
disabled_commands: Built-in command names to disable.
|
|
50
|
+
data_dir: Directory for persistent state files.
|
|
51
|
+
auto_approve_duration: Auto-approve timer duration in seconds.
|
|
52
|
+
never_auto_approve: Tool name prefixes that are never auto-approved.
|
|
53
|
+
flush_delay: Seconds before flushing batched notifications.
|
|
54
|
+
error_debounce_seconds: Minimum seconds between error notifications.
|
|
55
|
+
command_prefix: Command prefix for this platform (default "!").
|
|
56
|
+
"""
|
|
57
|
+
|
|
58
|
+
def __init__(
|
|
59
|
+
self,
|
|
60
|
+
handlers: Handlers,
|
|
61
|
+
*,
|
|
62
|
+
commands: dict[str, CommandDef] | None = None,
|
|
63
|
+
disabled_commands: set[str] | None = None,
|
|
64
|
+
data_dir: str | Path | None = None,
|
|
65
|
+
auto_approve_duration: int = 30 * 60,
|
|
66
|
+
never_auto_approve: set[str] | frozenset[str] | None = None,
|
|
67
|
+
flush_delay: float = 1.5,
|
|
68
|
+
error_debounce_seconds: int = 0,
|
|
69
|
+
command_prefix: str = "!",
|
|
70
|
+
) -> None:
|
|
71
|
+
self._handlers = handlers
|
|
72
|
+
self._command_prefix = command_prefix
|
|
73
|
+
self._disabled_commands = disabled_commands or set()
|
|
74
|
+
self._stopped = asyncio.Event()
|
|
75
|
+
|
|
76
|
+
# Resolve data dir
|
|
77
|
+
if data_dir:
|
|
78
|
+
self._data_dir = Path(data_dir)
|
|
79
|
+
else:
|
|
80
|
+
self._data_dir = Path.home() / ".agent-tether"
|
|
81
|
+
self._data_dir.mkdir(parents=True, exist_ok=True)
|
|
82
|
+
|
|
83
|
+
# Core components
|
|
84
|
+
self._approval = AutoApproveEngine(
|
|
85
|
+
duration_s=auto_approve_duration,
|
|
86
|
+
never_auto_approve=never_auto_approve,
|
|
87
|
+
)
|
|
88
|
+
self._batcher = NotificationBatcher(
|
|
89
|
+
self._send_auto_approve_batch,
|
|
90
|
+
flush_delay=flush_delay,
|
|
91
|
+
)
|
|
92
|
+
self._debouncer = ErrorDebouncer(debounce_seconds=error_debounce_seconds)
|
|
93
|
+
|
|
94
|
+
# Pending permission requests: thread_id → ApprovalRequest
|
|
95
|
+
self._pending: dict[str, ApprovalRequest] = {}
|
|
96
|
+
|
|
97
|
+
# Command registry: built-in + custom
|
|
98
|
+
self._commands: dict[str, CommandDef] = {}
|
|
99
|
+
self._register_builtins()
|
|
100
|
+
if commands:
|
|
101
|
+
self._commands.update(commands)
|
|
102
|
+
|
|
103
|
+
# ------------------------------------------------------------------
|
|
104
|
+
# Built-in commands
|
|
105
|
+
# ------------------------------------------------------------------
|
|
106
|
+
|
|
107
|
+
def _register_builtins(self) -> None:
|
|
108
|
+
"""Register built-in commands (unless disabled)."""
|
|
109
|
+
builtins: dict[str, CommandDef] = {
|
|
110
|
+
"help": CommandDef(
|
|
111
|
+
description="Show available commands",
|
|
112
|
+
handler=self._builtin_help,
|
|
113
|
+
),
|
|
114
|
+
"status": CommandDef(
|
|
115
|
+
description="Show status",
|
|
116
|
+
handler=self._builtin_status,
|
|
117
|
+
),
|
|
118
|
+
"stop": CommandDef(
|
|
119
|
+
description="Stop / interrupt the agent",
|
|
120
|
+
handler=self._builtin_stop,
|
|
121
|
+
),
|
|
122
|
+
"usage": CommandDef(
|
|
123
|
+
description="Show token usage and cost",
|
|
124
|
+
handler=self._builtin_usage,
|
|
125
|
+
),
|
|
126
|
+
}
|
|
127
|
+
for name, cmd in builtins.items():
|
|
128
|
+
if name not in self._disabled_commands:
|
|
129
|
+
self._commands[name] = cmd
|
|
130
|
+
|
|
131
|
+
async def _builtin_help(self, thread_id: str, args: str) -> str | None:
|
|
132
|
+
"""Auto-generate help text from the command registry."""
|
|
133
|
+
prefix = self._command_prefix
|
|
134
|
+
lines = ["Available commands:\n"]
|
|
135
|
+
for name, cmd in sorted(self._commands.items()):
|
|
136
|
+
lines.append(f" {prefix}{name} — {cmd.description}")
|
|
137
|
+
lines.append(f"\nSend a text message in a thread to forward it as input.")
|
|
138
|
+
return "\n".join(lines)
|
|
139
|
+
|
|
140
|
+
async def _builtin_status(self, thread_id: str, args: str) -> str | None:
|
|
141
|
+
if self._handlers.on_status_request:
|
|
142
|
+
return await self._handlers.on_status_request()
|
|
143
|
+
return None
|
|
144
|
+
|
|
145
|
+
async def _builtin_stop(self, thread_id: str, args: str) -> str | None:
|
|
146
|
+
if self._handlers.on_stop_request:
|
|
147
|
+
return await self._handlers.on_stop_request(thread_id)
|
|
148
|
+
return None
|
|
149
|
+
|
|
150
|
+
async def _builtin_usage(self, thread_id: str, args: str) -> str | None:
|
|
151
|
+
if self._handlers.on_usage_request:
|
|
152
|
+
return await self._handlers.on_usage_request(thread_id)
|
|
153
|
+
return None
|
|
154
|
+
|
|
155
|
+
# ------------------------------------------------------------------
|
|
156
|
+
# Public API — Lifecycle
|
|
157
|
+
# ------------------------------------------------------------------
|
|
158
|
+
|
|
159
|
+
async def start(self) -> None:
|
|
160
|
+
"""Start the bridge (connects to the platform)."""
|
|
161
|
+
self._stopped.clear()
|
|
162
|
+
await self._platform_start()
|
|
163
|
+
logger.info("Bridge started: %s", type(self).__name__)
|
|
164
|
+
|
|
165
|
+
async def stop(self) -> None:
|
|
166
|
+
"""Stop the bridge."""
|
|
167
|
+
await self._platform_stop()
|
|
168
|
+
self._stopped.set()
|
|
169
|
+
logger.info("Bridge stopped: %s", type(self).__name__)
|
|
170
|
+
|
|
171
|
+
async def wait_until_stopped(self) -> None:
|
|
172
|
+
"""Block until ``stop()`` is called."""
|
|
173
|
+
await self._stopped.wait()
|
|
174
|
+
|
|
175
|
+
# ------------------------------------------------------------------
|
|
176
|
+
# Public API — Threads
|
|
177
|
+
# ------------------------------------------------------------------
|
|
178
|
+
|
|
179
|
+
async def create_thread(self, name: str, *, directory: str | None = None) -> str:
|
|
180
|
+
"""Create a new thread on the platform.
|
|
181
|
+
|
|
182
|
+
Args:
|
|
183
|
+
name: Display name for the thread.
|
|
184
|
+
directory: Optional directory path for directory-scoped auto-approve.
|
|
185
|
+
|
|
186
|
+
Returns:
|
|
187
|
+
Platform-native thread ID as a string.
|
|
188
|
+
"""
|
|
189
|
+
thread_id = await self._platform_create_thread(name)
|
|
190
|
+
self._thread_state.register(thread_id, name)
|
|
191
|
+
if directory:
|
|
192
|
+
self._approval.associate_directory(thread_id, directory)
|
|
193
|
+
logger.info("Thread created: %s (id=%s)", name, thread_id)
|
|
194
|
+
return thread_id
|
|
195
|
+
|
|
196
|
+
async def remove_thread(self, thread_id: str) -> None:
|
|
197
|
+
"""Clean up all state for a thread."""
|
|
198
|
+
self._pending.pop(thread_id, None)
|
|
199
|
+
self._approval.remove_thread(thread_id)
|
|
200
|
+
self._batcher.remove_thread(thread_id)
|
|
201
|
+
self._debouncer.remove_thread(thread_id)
|
|
202
|
+
self._thread_state.unregister(thread_id)
|
|
203
|
+
logger.info("Thread removed: %s", thread_id)
|
|
204
|
+
|
|
205
|
+
# ------------------------------------------------------------------
|
|
206
|
+
# Public API — Output
|
|
207
|
+
# ------------------------------------------------------------------
|
|
208
|
+
|
|
209
|
+
async def send_output(self, thread_id: str, text: str) -> None:
|
|
210
|
+
"""Send output text to a thread."""
|
|
211
|
+
await self._platform_send(thread_id, text)
|
|
212
|
+
|
|
213
|
+
async def send_status(self, thread_id: str, status: str) -> None:
|
|
214
|
+
"""Send a status notification to a thread.
|
|
215
|
+
|
|
216
|
+
Respects error debouncing.
|
|
217
|
+
"""
|
|
218
|
+
if status == "error" and not self._debouncer.should_send(thread_id):
|
|
219
|
+
return
|
|
220
|
+
emoji = _STATE_EMOJI.get(status, "ℹ️")
|
|
221
|
+
await self._platform_send(thread_id, f"{emoji} Status: {status}")
|
|
222
|
+
|
|
223
|
+
async def send_typing(self, thread_id: str) -> None:
|
|
224
|
+
"""Show a typing indicator (if the platform supports it)."""
|
|
225
|
+
await self._platform_typing_start(thread_id)
|
|
226
|
+
|
|
227
|
+
async def send_typing_stopped(self, thread_id: str) -> None:
|
|
228
|
+
"""Stop the typing indicator."""
|
|
229
|
+
await self._platform_typing_stop(thread_id)
|
|
230
|
+
|
|
231
|
+
# ------------------------------------------------------------------
|
|
232
|
+
# Public API — Approvals
|
|
233
|
+
# ------------------------------------------------------------------
|
|
234
|
+
|
|
235
|
+
async def send_approval_request(
|
|
236
|
+
self,
|
|
237
|
+
thread_id: str,
|
|
238
|
+
*,
|
|
239
|
+
request_id: str,
|
|
240
|
+
tool_name: str,
|
|
241
|
+
description: str,
|
|
242
|
+
) -> None:
|
|
243
|
+
"""Send an approval request to a thread.
|
|
244
|
+
|
|
245
|
+
If an auto-approve timer is active, the request is resolved
|
|
246
|
+
automatically and a batched notification is sent instead.
|
|
247
|
+
|
|
248
|
+
Args:
|
|
249
|
+
thread_id: Target thread.
|
|
250
|
+
request_id: Unique request identifier.
|
|
251
|
+
tool_name: Name of the tool requesting approval.
|
|
252
|
+
description: JSON string or text describing the tool input.
|
|
253
|
+
"""
|
|
254
|
+
request = ApprovalRequest(
|
|
255
|
+
kind="permission",
|
|
256
|
+
request_id=request_id,
|
|
257
|
+
title=tool_name,
|
|
258
|
+
description=description,
|
|
259
|
+
options=["Allow", "Deny"],
|
|
260
|
+
)
|
|
261
|
+
|
|
262
|
+
# Check auto-approve
|
|
263
|
+
reason = self._approval.check(thread_id, tool_name)
|
|
264
|
+
if reason:
|
|
265
|
+
await self._auto_approve(thread_id, request, reason)
|
|
266
|
+
return
|
|
267
|
+
|
|
268
|
+
await self._platform_typing_stop(thread_id)
|
|
269
|
+
self._pending[thread_id] = request
|
|
270
|
+
formatted = format_tool_input(description)
|
|
271
|
+
await self._platform_send_approval(thread_id, request, formatted)
|
|
272
|
+
|
|
273
|
+
async def send_choice_request(
|
|
274
|
+
self,
|
|
275
|
+
thread_id: str,
|
|
276
|
+
*,
|
|
277
|
+
request_id: str,
|
|
278
|
+
title: str,
|
|
279
|
+
description: str,
|
|
280
|
+
options: list[str],
|
|
281
|
+
) -> None:
|
|
282
|
+
"""Send a choice request (multi-option question) to a thread.
|
|
283
|
+
|
|
284
|
+
Args:
|
|
285
|
+
thread_id: Target thread.
|
|
286
|
+
request_id: Unique request identifier.
|
|
287
|
+
title: Question title / header.
|
|
288
|
+
description: Question body text.
|
|
289
|
+
options: List of option labels.
|
|
290
|
+
"""
|
|
291
|
+
request = ApprovalRequest(
|
|
292
|
+
kind="choice",
|
|
293
|
+
request_id=request_id,
|
|
294
|
+
title=title,
|
|
295
|
+
description=description,
|
|
296
|
+
options=options,
|
|
297
|
+
)
|
|
298
|
+
await self._platform_typing_stop(thread_id)
|
|
299
|
+
self._pending[thread_id] = request
|
|
300
|
+
await self._platform_send_choice(thread_id, request)
|
|
301
|
+
|
|
302
|
+
# ------------------------------------------------------------------
|
|
303
|
+
# Auto-approve internals
|
|
304
|
+
# ------------------------------------------------------------------
|
|
305
|
+
|
|
306
|
+
async def _auto_approve(self, thread_id: str, request: ApprovalRequest, reason: str) -> None:
|
|
307
|
+
"""Handle an auto-approved request: notify handler + batch notification."""
|
|
308
|
+
if self._handlers.on_approval_response:
|
|
309
|
+
await self._handlers.on_approval_response(
|
|
310
|
+
thread_id, request.request_id, True, None, reason
|
|
311
|
+
)
|
|
312
|
+
self._batcher.add(thread_id, request.title, reason)
|
|
313
|
+
|
|
314
|
+
async def _send_auto_approve_batch(self, thread_id: str, items: list[tuple[str, str]]) -> None:
|
|
315
|
+
"""Default auto-approve batch notification. Platforms may override."""
|
|
316
|
+
if len(items) == 1:
|
|
317
|
+
tool_name, reason = items[0]
|
|
318
|
+
text = f"✅ {tool_name} — auto-approved ({reason})"
|
|
319
|
+
else:
|
|
320
|
+
lines = [f"✅ Auto-approved {len(items)} tools:"]
|
|
321
|
+
for tool_name, _reason in items:
|
|
322
|
+
lines.append(f" • {tool_name}")
|
|
323
|
+
lines.append(f"({items[0][1]})")
|
|
324
|
+
text = "\n".join(lines)
|
|
325
|
+
await self._platform_send(thread_id, text)
|
|
326
|
+
|
|
327
|
+
# ------------------------------------------------------------------
|
|
328
|
+
# Incoming message dispatch (platforms call this)
|
|
329
|
+
# ------------------------------------------------------------------
|
|
330
|
+
|
|
331
|
+
async def _dispatch_message(
|
|
332
|
+
self, thread_id: str, text: str, username: str | None = None
|
|
333
|
+
) -> None:
|
|
334
|
+
"""Route an incoming human message.
|
|
335
|
+
|
|
336
|
+
Called by platform implementations when a message arrives in a thread.
|
|
337
|
+
Handles: pending approvals/choices → commands → plain input.
|
|
338
|
+
"""
|
|
339
|
+
stripped = text.strip()
|
|
340
|
+
if not stripped:
|
|
341
|
+
return
|
|
342
|
+
|
|
343
|
+
# 1. Check pending choice request
|
|
344
|
+
pending = self._pending.get(thread_id)
|
|
345
|
+
if pending and pending.kind == "choice":
|
|
346
|
+
selected = self._parse_choice_text(thread_id, stripped)
|
|
347
|
+
if selected:
|
|
348
|
+
self._pending.pop(thread_id, None)
|
|
349
|
+
if self._handlers.on_approval_response:
|
|
350
|
+
await self._handlers.on_approval_response(
|
|
351
|
+
thread_id, pending.request_id, True, selected, None
|
|
352
|
+
)
|
|
353
|
+
await self._platform_send(thread_id, f"✅ Selected: {selected}")
|
|
354
|
+
return
|
|
355
|
+
|
|
356
|
+
# 2. Check pending approval request
|
|
357
|
+
if pending and pending.kind == "permission":
|
|
358
|
+
parsed = self._parse_approval_text(stripped)
|
|
359
|
+
if parsed is not None:
|
|
360
|
+
await self._handle_approval_parsed(thread_id, pending, parsed, username)
|
|
361
|
+
return
|
|
362
|
+
|
|
363
|
+
# 3. Check for commands
|
|
364
|
+
if stripped.startswith(self._command_prefix):
|
|
365
|
+
await self._dispatch_command(thread_id, stripped, username)
|
|
366
|
+
return
|
|
367
|
+
|
|
368
|
+
# 4. Plain input
|
|
369
|
+
if self._handlers.on_input:
|
|
370
|
+
await self._handlers.on_input(thread_id, stripped, username)
|
|
371
|
+
|
|
372
|
+
async def _dispatch_command(
|
|
373
|
+
self, thread_id: str, text: str, username: str | None = None
|
|
374
|
+
) -> None:
|
|
375
|
+
"""Parse and dispatch a command."""
|
|
376
|
+
# Strip prefix
|
|
377
|
+
without_prefix = text[len(self._command_prefix) :].strip()
|
|
378
|
+
parts = without_prefix.split(None, 1)
|
|
379
|
+
if not parts:
|
|
380
|
+
return
|
|
381
|
+
cmd_name = parts[0].lower()
|
|
382
|
+
args = parts[1].strip() if len(parts) > 1 else ""
|
|
383
|
+
|
|
384
|
+
cmd = self._commands.get(cmd_name)
|
|
385
|
+
if cmd:
|
|
386
|
+
try:
|
|
387
|
+
reply = await cmd.handler(thread_id, args)
|
|
388
|
+
if reply:
|
|
389
|
+
await self._platform_send(thread_id, reply)
|
|
390
|
+
except Exception:
|
|
391
|
+
logger.exception("Command %s failed", cmd_name)
|
|
392
|
+
await self._platform_send(thread_id, f"Command failed: {cmd_name}")
|
|
393
|
+
return
|
|
394
|
+
|
|
395
|
+
# Catch-all handler
|
|
396
|
+
if self._handlers.on_command:
|
|
397
|
+
try:
|
|
398
|
+
reply = await self._handlers.on_command(thread_id, cmd_name, args)
|
|
399
|
+
if reply:
|
|
400
|
+
await self._platform_send(thread_id, reply)
|
|
401
|
+
except Exception:
|
|
402
|
+
logger.exception("Command handler failed for %s", cmd_name)
|
|
403
|
+
return
|
|
404
|
+
|
|
405
|
+
await self._platform_send(
|
|
406
|
+
thread_id,
|
|
407
|
+
f"Unknown command: {self._command_prefix}{cmd_name}\n"
|
|
408
|
+
f"Use {self._command_prefix}help for available commands.",
|
|
409
|
+
)
|
|
410
|
+
|
|
411
|
+
# ------------------------------------------------------------------
|
|
412
|
+
# Approval text parsing
|
|
413
|
+
# ------------------------------------------------------------------
|
|
414
|
+
|
|
415
|
+
@staticmethod
|
|
416
|
+
def _parse_approval_text(text: str) -> dict | None:
|
|
417
|
+
"""Parse a text message as an approval response.
|
|
418
|
+
|
|
419
|
+
Returns a dict with keys: allow (bool), reason (str|None), timer (str|None)
|
|
420
|
+
or None if the text is not an approval command.
|
|
421
|
+
"""
|
|
422
|
+
stripped = text.strip()
|
|
423
|
+
lower = stripped.lower()
|
|
424
|
+
|
|
425
|
+
# Common synonyms
|
|
426
|
+
if lower in ("proceed", "continue", "start", "go", "ok", "okay"):
|
|
427
|
+
return {"allow": True, "reason": None, "timer": None}
|
|
428
|
+
if lower in ("cancel", "stop", "abort"):
|
|
429
|
+
return {"allow": False, "reason": None, "timer": None}
|
|
430
|
+
|
|
431
|
+
# "allow all"
|
|
432
|
+
if lower == "allow all":
|
|
433
|
+
return {"allow": True, "reason": None, "timer": "all"}
|
|
434
|
+
|
|
435
|
+
# "allow dir"
|
|
436
|
+
if lower == "allow dir":
|
|
437
|
+
return {"allow": True, "reason": None, "timer": "dir"}
|
|
438
|
+
|
|
439
|
+
# "allow <tool>" (but not bare "allow")
|
|
440
|
+
if lower.startswith("allow ") and lower != "allow all":
|
|
441
|
+
rest = stripped[6:].strip()
|
|
442
|
+
if rest:
|
|
443
|
+
return {"allow": True, "reason": None, "timer": rest}
|
|
444
|
+
|
|
445
|
+
# Bare allow/approve/yes
|
|
446
|
+
if lower in ("allow", "approve", "yes"):
|
|
447
|
+
return {"allow": True, "reason": None, "timer": None}
|
|
448
|
+
|
|
449
|
+
# "deny: reason" or "reject: reason"
|
|
450
|
+
if lower.startswith(("deny:", "reject:", "no:")):
|
|
451
|
+
sep = stripped.index(":")
|
|
452
|
+
reason = stripped[sep + 1 :].strip()
|
|
453
|
+
return {"allow": False, "reason": reason or None, "timer": None}
|
|
454
|
+
|
|
455
|
+
# "deny reason" (multi-word)
|
|
456
|
+
if lower.startswith(("deny ", "reject ")):
|
|
457
|
+
first_space = stripped.index(" ")
|
|
458
|
+
reason = stripped[first_space + 1 :].strip()
|
|
459
|
+
if reason:
|
|
460
|
+
return {"allow": False, "reason": reason, "timer": None}
|
|
461
|
+
|
|
462
|
+
# Bare deny/reject/no
|
|
463
|
+
if lower in ("deny", "reject", "no"):
|
|
464
|
+
return {"allow": False, "reason": None, "timer": None}
|
|
465
|
+
|
|
466
|
+
return None
|
|
467
|
+
|
|
468
|
+
def _parse_choice_text(self, thread_id: str, text: str) -> str | None:
|
|
469
|
+
"""Parse a text message as a choice selection.
|
|
470
|
+
|
|
471
|
+
Supports numeric (1-indexed) or exact label match.
|
|
472
|
+
"""
|
|
473
|
+
pending = self._pending.get(thread_id)
|
|
474
|
+
if not pending or pending.kind != "choice":
|
|
475
|
+
return None
|
|
476
|
+
|
|
477
|
+
stripped = text.strip()
|
|
478
|
+
if not stripped:
|
|
479
|
+
return None
|
|
480
|
+
|
|
481
|
+
# Numeric selection
|
|
482
|
+
if stripped.isdigit():
|
|
483
|
+
idx = int(stripped) - 1
|
|
484
|
+
if 0 <= idx < len(pending.options):
|
|
485
|
+
return pending.options[idx]
|
|
486
|
+
return None
|
|
487
|
+
|
|
488
|
+
# Label match (case-insensitive)
|
|
489
|
+
lowered = stripped.casefold()
|
|
490
|
+
for opt in pending.options:
|
|
491
|
+
if opt.casefold() == lowered:
|
|
492
|
+
return opt
|
|
493
|
+
return None
|
|
494
|
+
|
|
495
|
+
async def _handle_approval_parsed(
|
|
496
|
+
self,
|
|
497
|
+
thread_id: str,
|
|
498
|
+
request: ApprovalRequest,
|
|
499
|
+
parsed: dict,
|
|
500
|
+
username: str | None = None,
|
|
501
|
+
) -> None:
|
|
502
|
+
"""Handle a parsed approval text response."""
|
|
503
|
+
allow = parsed["allow"]
|
|
504
|
+
reason = parsed.get("reason")
|
|
505
|
+
timer = parsed.get("timer")
|
|
506
|
+
|
|
507
|
+
if allow:
|
|
508
|
+
if timer == "all":
|
|
509
|
+
self._approval.set_allow_all(thread_id)
|
|
510
|
+
elif timer == "dir":
|
|
511
|
+
directory = self._approval.get_directory(thread_id)
|
|
512
|
+
if directory:
|
|
513
|
+
self._approval.set_allow_directory(directory)
|
|
514
|
+
else:
|
|
515
|
+
self._approval.set_allow_all(thread_id)
|
|
516
|
+
elif timer:
|
|
517
|
+
self._approval.set_allow_tool(thread_id, timer)
|
|
518
|
+
|
|
519
|
+
self._pending.pop(thread_id, None)
|
|
520
|
+
|
|
521
|
+
if self._handlers.on_approval_response:
|
|
522
|
+
await self._handlers.on_approval_response(
|
|
523
|
+
thread_id, request.request_id, allow, reason, timer
|
|
524
|
+
)
|
|
525
|
+
|
|
526
|
+
# Send confirmation
|
|
527
|
+
if allow:
|
|
528
|
+
msg = "Approved"
|
|
529
|
+
if timer == "all":
|
|
530
|
+
msg = "Allow All (30m)"
|
|
531
|
+
elif timer == "dir":
|
|
532
|
+
msg = "Allow dir (30m)"
|
|
533
|
+
elif timer:
|
|
534
|
+
msg = f"Allow {timer} (30m)"
|
|
535
|
+
display = f"✅ {msg}"
|
|
536
|
+
if username:
|
|
537
|
+
display += f" by {username}"
|
|
538
|
+
await self._platform_send(thread_id, display)
|
|
539
|
+
else:
|
|
540
|
+
msg = f"Denied: {reason}" if reason else "Denied"
|
|
541
|
+
display = f"❌ {msg}"
|
|
542
|
+
if username:
|
|
543
|
+
display += f" by {username}"
|
|
544
|
+
await self._platform_send(thread_id, display)
|
|
545
|
+
|
|
546
|
+
# ------------------------------------------------------------------
|
|
547
|
+
# Thread state (initialized lazily by subclass or in start)
|
|
548
|
+
# ------------------------------------------------------------------
|
|
549
|
+
|
|
550
|
+
@property
|
|
551
|
+
def _thread_state(self) -> ThreadState:
|
|
552
|
+
"""Thread state instance. Subclasses should set ``_thread_state_instance``."""
|
|
553
|
+
if not hasattr(self, "_thread_state_instance") or self._thread_state_instance is None:
|
|
554
|
+
platform_name = type(self).__name__.lower().replace("bridge", "")
|
|
555
|
+
path = self._data_dir / f"{platform_name}_threads.json"
|
|
556
|
+
self._thread_state_instance = ThreadState(path)
|
|
557
|
+
self._thread_state_instance.load()
|
|
558
|
+
return self._thread_state_instance
|
|
559
|
+
|
|
560
|
+
# ------------------------------------------------------------------
|
|
561
|
+
# Platform abstract methods
|
|
562
|
+
# ------------------------------------------------------------------
|
|
563
|
+
|
|
564
|
+
@abstractmethod
|
|
565
|
+
async def _platform_start(self) -> None:
|
|
566
|
+
"""Start the platform client/bot."""
|
|
567
|
+
|
|
568
|
+
@abstractmethod
|
|
569
|
+
async def _platform_stop(self) -> None:
|
|
570
|
+
"""Stop the platform client/bot."""
|
|
571
|
+
|
|
572
|
+
@abstractmethod
|
|
573
|
+
async def _platform_send(self, thread_id: str, text: str, **kwargs) -> None:
|
|
574
|
+
"""Send a text message to a thread."""
|
|
575
|
+
|
|
576
|
+
@abstractmethod
|
|
577
|
+
async def _platform_create_thread(self, name: str) -> str:
|
|
578
|
+
"""Create a thread on the platform. Return the platform-native thread ID."""
|
|
579
|
+
|
|
580
|
+
@abstractmethod
|
|
581
|
+
async def _platform_send_approval(
|
|
582
|
+
self, thread_id: str, request: ApprovalRequest, formatted_description: str
|
|
583
|
+
) -> None:
|
|
584
|
+
"""Send an approval request with platform-specific UI (buttons, text, etc.)."""
|
|
585
|
+
|
|
586
|
+
@abstractmethod
|
|
587
|
+
async def _platform_send_choice(self, thread_id: str, request: ApprovalRequest) -> None:
|
|
588
|
+
"""Send a choice request with platform-specific UI."""
|
|
589
|
+
|
|
590
|
+
# ------------------------------------------------------------------
|
|
591
|
+
# Platform optional methods (override as needed)
|
|
592
|
+
# ------------------------------------------------------------------
|
|
593
|
+
|
|
594
|
+
async def _platform_typing_start(self, thread_id: str) -> None:
|
|
595
|
+
"""Show a typing indicator. Override if the platform supports it."""
|
|
596
|
+
|
|
597
|
+
async def _platform_typing_stop(self, thread_id: str) -> None:
|
|
598
|
+
"""Stop the typing indicator. Override if the platform supports it."""
|
|
File without changes
|