tetherly 0.1.0__tar.gz
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.
- tetherly-0.1.0/LICENSE +21 -0
- tetherly-0.1.0/PKG-INFO +116 -0
- tetherly-0.1.0/README.md +102 -0
- tetherly-0.1.0/pyproject.toml +31 -0
- tetherly-0.1.0/setup.cfg +4 -0
- tetherly-0.1.0/src/tetherly/__init__.py +1 -0
- tetherly-0.1.0/src/tetherly/authz.py +37 -0
- tetherly-0.1.0/src/tetherly/config.py +83 -0
- tetherly-0.1.0/src/tetherly/discord_bot.py +283 -0
- tetherly-0.1.0/src/tetherly/discord_sender.py +93 -0
- tetherly-0.1.0/src/tetherly/main.py +474 -0
- tetherly-0.1.0/src/tetherly/models.py +34 -0
- tetherly-0.1.0/src/tetherly/session_registry.py +93 -0
- tetherly-0.1.0/src/tetherly/setup.py +221 -0
- tetherly-0.1.0/src/tetherly/tmux_service.py +136 -0
- tetherly-0.1.0/src/tetherly.egg-info/PKG-INFO +116 -0
- tetherly-0.1.0/src/tetherly.egg-info/SOURCES.txt +25 -0
- tetherly-0.1.0/src/tetherly.egg-info/dependency_links.txt +1 -0
- tetherly-0.1.0/src/tetherly.egg-info/entry_points.txt +2 -0
- tetherly-0.1.0/src/tetherly.egg-info/requires.txt +4 -0
- tetherly-0.1.0/src/tetherly.egg-info/top_level.txt +1 -0
- tetherly-0.1.0/tests/test_authz.py +64 -0
- tetherly-0.1.0/tests/test_discord_bot.py +77 -0
- tetherly-0.1.0/tests/test_discord_sender.py +33 -0
- tetherly-0.1.0/tests/test_main.py +197 -0
- tetherly-0.1.0/tests/test_registry.py +54 -0
- tetherly-0.1.0/tests/test_tmux_service.py +38 -0
tetherly-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 changhyeon363
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
tetherly-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: tetherly
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Discord to tmux session bridge for agent-driven workflows.
|
|
5
|
+
Author-email: changhyeon363 <changhyeon363@gmail.com>
|
|
6
|
+
License: MIT
|
|
7
|
+
Requires-Python: >=3.11
|
|
8
|
+
Description-Content-Type: text/markdown
|
|
9
|
+
License-File: LICENSE
|
|
10
|
+
Requires-Dist: discord.py<3,>=2.5
|
|
11
|
+
Provides-Extra: docs
|
|
12
|
+
Requires-Dist: zensical<1,>=0.0.37; extra == "docs"
|
|
13
|
+
Dynamic: license-file
|
|
14
|
+
|
|
15
|
+
<p align="center">
|
|
16
|
+
<img src="docs/assets/images/tetherly-icon.png" alt="tetherly icon" width="96" height="96">
|
|
17
|
+
</p>
|
|
18
|
+
|
|
19
|
+
# tetherly
|
|
20
|
+
|
|
21
|
+
Discord channel ↔ tmux session bridge.
|
|
22
|
+
|
|
23
|
+
> 📖 **Documentation is in [docs/](docs/) — split into [user docs](docs/user/) (setup, commands, troubleshooting) and [contributing docs](docs/contributing/) (internals).** This README is a quick start.
|
|
24
|
+
|
|
25
|
+
## Features
|
|
26
|
+
|
|
27
|
+
- `/bind session:<name>`: bind the current Discord channel to a tmux session
|
|
28
|
+
- `/config auto_send:<true|false>`: enable or disable plain-text auto-send for the current bound channel
|
|
29
|
+
- `/send text:<message>`: send text plus Enter into the bound tmux session
|
|
30
|
+
- `/key key:<Enter|Escape|Ctrl-C|Ctrl-D|Tab|Up|Down|Left|Right>`: send a special key into the bound tmux session
|
|
31
|
+
- `/tail lines:<n>`: fetch recent tmux output
|
|
32
|
+
- `/status`: inspect the current binding and tmux session status
|
|
33
|
+
- `tetherly discord-send --message <text>`: let an agent inside a bound tmux session send a reply back to Discord
|
|
34
|
+
- `tetherly codex-stop` / `tetherly codex-permission-request`: Codex hook handlers that forward messages to the bound Discord channel
|
|
35
|
+
|
|
36
|
+
## Requirements
|
|
37
|
+
|
|
38
|
+
- Python 3.11+
|
|
39
|
+
- `tmux` installed
|
|
40
|
+
- A Discord bot token (Message Content Intent enabled if you want plain-text auto-send)
|
|
41
|
+
|
|
42
|
+
## Setup
|
|
43
|
+
|
|
44
|
+
Install once on your machine:
|
|
45
|
+
|
|
46
|
+
```bash
|
|
47
|
+
pipx install tetherly
|
|
48
|
+
tetherly init
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
`tetherly init` is interactive. It writes `~/.tetherly/.env` and asks where to install Codex hooks:
|
|
52
|
+
|
|
53
|
+
- **Global** — writes `~/.codex/hooks.json` once. Hooks fire in every project automatically; nothing per-project.
|
|
54
|
+
- **Project** — skip global hooks and run `tetherly install-hooks` inside each project where you want them.
|
|
55
|
+
- **Skip** — don't touch Codex hooks.
|
|
56
|
+
|
|
57
|
+
Then start the bot:
|
|
58
|
+
|
|
59
|
+
```bash
|
|
60
|
+
tetherly
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
That's it. State lives at `~/.tetherly/state.json` so a single bot can serve every project.
|
|
64
|
+
|
|
65
|
+
### Per-project usage
|
|
66
|
+
|
|
67
|
+
For each project you want to drive from Discord:
|
|
68
|
+
|
|
69
|
+
```bash
|
|
70
|
+
tmux new -s <session-name>
|
|
71
|
+
# inside the bound channel on Discord:
|
|
72
|
+
# /bind session:<session-name>
|
|
73
|
+
# /config auto_send:true
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
If you chose **Project** mode during init, also run once per project:
|
|
77
|
+
|
|
78
|
+
```bash
|
|
79
|
+
cd <project>
|
|
80
|
+
tetherly install-hooks
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
`install-hooks` accepts `--global` to (re)install user-level hooks instead.
|
|
84
|
+
|
|
85
|
+
### Sending from inside a session
|
|
86
|
+
|
|
87
|
+
```bash
|
|
88
|
+
tetherly discord-send --message "작업 끝났습니다"
|
|
89
|
+
cat result.txt | tetherly discord-send --stdin
|
|
90
|
+
tetherly discord-send --session t1 --message "..." # explicit session
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
## Configuration
|
|
94
|
+
|
|
95
|
+
`tetherly init` writes everything you need. Advanced overrides live in `~/.tetherly/.env` or shell env:
|
|
96
|
+
|
|
97
|
+
| Variable | Default | Notes |
|
|
98
|
+
| --- | --- | --- |
|
|
99
|
+
| `DISCORD_BOT_TOKEN` | (required) | Bot token |
|
|
100
|
+
| `TETHERLY_ALLOWED_USER_IDS` | (required) | Comma-separated user IDs |
|
|
101
|
+
| `TETHERLY_ALLOWED_GUILD_IDS` | — | Restrict commands to these guilds |
|
|
102
|
+
| `TETHERLY_ALLOWED_ROLE_IDS` | — | Allow members holding any of these roles |
|
|
103
|
+
| `TETHERLY_TEST_GUILD_ID` | — | Dev guild for instant slash-command sync |
|
|
104
|
+
| `TETHERLY_STATE_PATH` | `~/.tetherly/state.json` | Where bindings are persisted |
|
|
105
|
+
| `TETHERLY_DEFAULT_TAIL_LINES` | `40` | Default `/tail` line count |
|
|
106
|
+
| `TETHERLY_MAX_TAIL_LINES` | `200` | Cap for `/tail` |
|
|
107
|
+
| `TETHERLY_LOG_LEVEL` | `INFO` | Logger verbosity |
|
|
108
|
+
|
|
109
|
+
A `.env` in the current working directory still overrides `~/.tetherly/.env`.
|
|
110
|
+
|
|
111
|
+
## Codex hooks
|
|
112
|
+
|
|
113
|
+
Both hooks only fire when the active tmux session has `TETHERLY_NOTIFY_ON_FINISH=1` — `/bind` sets that flag automatically, so projects without a binding stay silent even when global hooks are installed.
|
|
114
|
+
|
|
115
|
+
- `Stop` → `tetherly codex-stop` forwards `last_assistant_message` to the bound channel.
|
|
116
|
+
- `PermissionRequest` → `tetherly codex-permission-request` forwards the tool/command/reason. It does not return an `allow`/`deny` decision, so Codex's normal approval prompt still appears.
|
tetherly-0.1.0/README.md
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
<p align="center">
|
|
2
|
+
<img src="docs/assets/images/tetherly-icon.png" alt="tetherly icon" width="96" height="96">
|
|
3
|
+
</p>
|
|
4
|
+
|
|
5
|
+
# tetherly
|
|
6
|
+
|
|
7
|
+
Discord channel ↔ tmux session bridge.
|
|
8
|
+
|
|
9
|
+
> 📖 **Documentation is in [docs/](docs/) — split into [user docs](docs/user/) (setup, commands, troubleshooting) and [contributing docs](docs/contributing/) (internals).** This README is a quick start.
|
|
10
|
+
|
|
11
|
+
## Features
|
|
12
|
+
|
|
13
|
+
- `/bind session:<name>`: bind the current Discord channel to a tmux session
|
|
14
|
+
- `/config auto_send:<true|false>`: enable or disable plain-text auto-send for the current bound channel
|
|
15
|
+
- `/send text:<message>`: send text plus Enter into the bound tmux session
|
|
16
|
+
- `/key key:<Enter|Escape|Ctrl-C|Ctrl-D|Tab|Up|Down|Left|Right>`: send a special key into the bound tmux session
|
|
17
|
+
- `/tail lines:<n>`: fetch recent tmux output
|
|
18
|
+
- `/status`: inspect the current binding and tmux session status
|
|
19
|
+
- `tetherly discord-send --message <text>`: let an agent inside a bound tmux session send a reply back to Discord
|
|
20
|
+
- `tetherly codex-stop` / `tetherly codex-permission-request`: Codex hook handlers that forward messages to the bound Discord channel
|
|
21
|
+
|
|
22
|
+
## Requirements
|
|
23
|
+
|
|
24
|
+
- Python 3.11+
|
|
25
|
+
- `tmux` installed
|
|
26
|
+
- A Discord bot token (Message Content Intent enabled if you want plain-text auto-send)
|
|
27
|
+
|
|
28
|
+
## Setup
|
|
29
|
+
|
|
30
|
+
Install once on your machine:
|
|
31
|
+
|
|
32
|
+
```bash
|
|
33
|
+
pipx install tetherly
|
|
34
|
+
tetherly init
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
`tetherly init` is interactive. It writes `~/.tetherly/.env` and asks where to install Codex hooks:
|
|
38
|
+
|
|
39
|
+
- **Global** — writes `~/.codex/hooks.json` once. Hooks fire in every project automatically; nothing per-project.
|
|
40
|
+
- **Project** — skip global hooks and run `tetherly install-hooks` inside each project where you want them.
|
|
41
|
+
- **Skip** — don't touch Codex hooks.
|
|
42
|
+
|
|
43
|
+
Then start the bot:
|
|
44
|
+
|
|
45
|
+
```bash
|
|
46
|
+
tetherly
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
That's it. State lives at `~/.tetherly/state.json` so a single bot can serve every project.
|
|
50
|
+
|
|
51
|
+
### Per-project usage
|
|
52
|
+
|
|
53
|
+
For each project you want to drive from Discord:
|
|
54
|
+
|
|
55
|
+
```bash
|
|
56
|
+
tmux new -s <session-name>
|
|
57
|
+
# inside the bound channel on Discord:
|
|
58
|
+
# /bind session:<session-name>
|
|
59
|
+
# /config auto_send:true
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
If you chose **Project** mode during init, also run once per project:
|
|
63
|
+
|
|
64
|
+
```bash
|
|
65
|
+
cd <project>
|
|
66
|
+
tetherly install-hooks
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
`install-hooks` accepts `--global` to (re)install user-level hooks instead.
|
|
70
|
+
|
|
71
|
+
### Sending from inside a session
|
|
72
|
+
|
|
73
|
+
```bash
|
|
74
|
+
tetherly discord-send --message "작업 끝났습니다"
|
|
75
|
+
cat result.txt | tetherly discord-send --stdin
|
|
76
|
+
tetherly discord-send --session t1 --message "..." # explicit session
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
## Configuration
|
|
80
|
+
|
|
81
|
+
`tetherly init` writes everything you need. Advanced overrides live in `~/.tetherly/.env` or shell env:
|
|
82
|
+
|
|
83
|
+
| Variable | Default | Notes |
|
|
84
|
+
| --- | --- | --- |
|
|
85
|
+
| `DISCORD_BOT_TOKEN` | (required) | Bot token |
|
|
86
|
+
| `TETHERLY_ALLOWED_USER_IDS` | (required) | Comma-separated user IDs |
|
|
87
|
+
| `TETHERLY_ALLOWED_GUILD_IDS` | — | Restrict commands to these guilds |
|
|
88
|
+
| `TETHERLY_ALLOWED_ROLE_IDS` | — | Allow members holding any of these roles |
|
|
89
|
+
| `TETHERLY_TEST_GUILD_ID` | — | Dev guild for instant slash-command sync |
|
|
90
|
+
| `TETHERLY_STATE_PATH` | `~/.tetherly/state.json` | Where bindings are persisted |
|
|
91
|
+
| `TETHERLY_DEFAULT_TAIL_LINES` | `40` | Default `/tail` line count |
|
|
92
|
+
| `TETHERLY_MAX_TAIL_LINES` | `200` | Cap for `/tail` |
|
|
93
|
+
| `TETHERLY_LOG_LEVEL` | `INFO` | Logger verbosity |
|
|
94
|
+
|
|
95
|
+
A `.env` in the current working directory still overrides `~/.tetherly/.env`.
|
|
96
|
+
|
|
97
|
+
## Codex hooks
|
|
98
|
+
|
|
99
|
+
Both hooks only fire when the active tmux session has `TETHERLY_NOTIFY_ON_FINISH=1` — `/bind` sets that flag automatically, so projects without a binding stay silent even when global hooks are installed.
|
|
100
|
+
|
|
101
|
+
- `Stop` → `tetherly codex-stop` forwards `last_assistant_message` to the bound channel.
|
|
102
|
+
- `PermissionRequest` → `tetherly codex-permission-request` forwards the tool/command/reason. It does not return an `allow`/`deny` decision, so Codex's normal approval prompt still appears.
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=69", "wheel"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "tetherly"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Discord to tmux session bridge for agent-driven workflows."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.11"
|
|
11
|
+
license = {text = "MIT"}
|
|
12
|
+
authors = [
|
|
13
|
+
{name = "changhyeon363", email = "changhyeon363@gmail.com"},
|
|
14
|
+
]
|
|
15
|
+
dependencies = [
|
|
16
|
+
"discord.py>=2.5,<3",
|
|
17
|
+
]
|
|
18
|
+
|
|
19
|
+
[project.optional-dependencies]
|
|
20
|
+
docs = [
|
|
21
|
+
"zensical>=0.0.37,<1",
|
|
22
|
+
]
|
|
23
|
+
|
|
24
|
+
[project.scripts]
|
|
25
|
+
tetherly = "tetherly.main:main"
|
|
26
|
+
|
|
27
|
+
[tool.setuptools]
|
|
28
|
+
package-dir = {"" = "src"}
|
|
29
|
+
|
|
30
|
+
[tool.setuptools.packages.find]
|
|
31
|
+
where = ["src"]
|
tetherly-0.1.0/setup.cfg
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""tetherly package."""
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
|
|
5
|
+
import discord
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@dataclass(frozen=True)
|
|
9
|
+
class AccessController:
|
|
10
|
+
allowed_guild_ids: set[int]
|
|
11
|
+
allowed_role_ids: set[int]
|
|
12
|
+
allowed_user_ids: set[int]
|
|
13
|
+
|
|
14
|
+
def is_allowed_user(self, guild_id: int | None, user: object) -> bool:
|
|
15
|
+
if self.allowed_guild_ids and guild_id not in self.allowed_guild_ids:
|
|
16
|
+
return False
|
|
17
|
+
user_id = getattr(user, "id", None)
|
|
18
|
+
if user_id in self.allowed_user_ids:
|
|
19
|
+
return True
|
|
20
|
+
if not self.allowed_role_ids:
|
|
21
|
+
return user_id in self.allowed_user_ids
|
|
22
|
+
if isinstance(user, discord.Member):
|
|
23
|
+
role_ids = {role.id for role in user.roles}
|
|
24
|
+
return bool(role_ids & self.allowed_role_ids)
|
|
25
|
+
return False
|
|
26
|
+
|
|
27
|
+
def is_allowed(self, interaction: discord.Interaction) -> bool:
|
|
28
|
+
return self.is_allowed_user(interaction.guild_id, interaction.user)
|
|
29
|
+
|
|
30
|
+
async def assert_allowed(self, interaction: discord.Interaction) -> bool:
|
|
31
|
+
if self.is_allowed(interaction):
|
|
32
|
+
return True
|
|
33
|
+
await interaction.response.send_message(
|
|
34
|
+
"You are not allowed to use this command.",
|
|
35
|
+
ephemeral=True,
|
|
36
|
+
)
|
|
37
|
+
return False
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
import os
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
USER_CONFIG_DIR = Path.home() / ".tetherly"
|
|
10
|
+
USER_ENV_PATH = USER_CONFIG_DIR / ".env"
|
|
11
|
+
USER_STATE_PATH = USER_CONFIG_DIR / "state.json"
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _load_env_file(path: Path) -> None:
|
|
15
|
+
if not path.exists():
|
|
16
|
+
return
|
|
17
|
+
for raw_line in path.read_text().splitlines():
|
|
18
|
+
line = raw_line.strip()
|
|
19
|
+
if not line or line.startswith("#"):
|
|
20
|
+
continue
|
|
21
|
+
if line.startswith("export "):
|
|
22
|
+
line = line[len("export ") :]
|
|
23
|
+
if "=" not in line:
|
|
24
|
+
continue
|
|
25
|
+
key, value = line.split("=", 1)
|
|
26
|
+
key = key.strip()
|
|
27
|
+
value = value.strip().strip("'").strip('"')
|
|
28
|
+
os.environ.setdefault(key, value)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def load_dotenv(path: str = ".env") -> None:
|
|
32
|
+
_load_env_file(Path(path))
|
|
33
|
+
_load_env_file(USER_ENV_PATH)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _parse_id_set(raw: str | None) -> set[int]:
|
|
37
|
+
if not raw:
|
|
38
|
+
return set()
|
|
39
|
+
values: set[int] = set()
|
|
40
|
+
for chunk in raw.split(","):
|
|
41
|
+
chunk = chunk.strip()
|
|
42
|
+
if not chunk:
|
|
43
|
+
continue
|
|
44
|
+
values.add(int(chunk))
|
|
45
|
+
return values
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
@dataclass(frozen=True)
|
|
49
|
+
class Config:
|
|
50
|
+
discord_bot_token: str
|
|
51
|
+
state_path: Path
|
|
52
|
+
allowed_guild_ids: set[int]
|
|
53
|
+
allowed_role_ids: set[int]
|
|
54
|
+
allowed_user_ids: set[int]
|
|
55
|
+
test_guild_id: int | None = None
|
|
56
|
+
default_tail_lines: int = 40
|
|
57
|
+
max_tail_lines: int = 200
|
|
58
|
+
log_level: str = "INFO"
|
|
59
|
+
command_prefix: str = "/"
|
|
60
|
+
|
|
61
|
+
@classmethod
|
|
62
|
+
def from_env(cls) -> "Config":
|
|
63
|
+
token = os.environ["DISCORD_BOT_TOKEN"].strip()
|
|
64
|
+
state_path = Path(os.environ.get("TETHERLY_STATE_PATH", str(USER_STATE_PATH)))
|
|
65
|
+
return cls(
|
|
66
|
+
discord_bot_token=token,
|
|
67
|
+
state_path=state_path,
|
|
68
|
+
allowed_guild_ids=_parse_id_set(os.environ.get("TETHERLY_ALLOWED_GUILD_IDS")),
|
|
69
|
+
allowed_role_ids=_parse_id_set(os.environ.get("TETHERLY_ALLOWED_ROLE_IDS")),
|
|
70
|
+
allowed_user_ids=_parse_id_set(os.environ.get("TETHERLY_ALLOWED_USER_IDS")),
|
|
71
|
+
test_guild_id=int(os.environ["TETHERLY_TEST_GUILD_ID"])
|
|
72
|
+
if os.environ.get("TETHERLY_TEST_GUILD_ID")
|
|
73
|
+
else None,
|
|
74
|
+
default_tail_lines=int(os.environ.get("TETHERLY_DEFAULT_TAIL_LINES", "40")),
|
|
75
|
+
max_tail_lines=int(os.environ.get("TETHERLY_MAX_TAIL_LINES", "200")),
|
|
76
|
+
log_level=os.environ.get("TETHERLY_LOG_LEVEL", "INFO").upper(),
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
def configure_logging(self) -> None:
|
|
80
|
+
logging.basicConfig(
|
|
81
|
+
level=getattr(logging, self.log_level, logging.INFO),
|
|
82
|
+
format="%(asctime)s %(levelname)s %(name)s %(message)s",
|
|
83
|
+
)
|
|
@@ -0,0 +1,283 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
|
|
5
|
+
import discord
|
|
6
|
+
from discord import app_commands
|
|
7
|
+
|
|
8
|
+
from tetherly.authz import AccessController
|
|
9
|
+
from tetherly.config import Config
|
|
10
|
+
from tetherly.session_registry import SessionRegistry, SessionRegistryError
|
|
11
|
+
from tetherly.tmux_service import TmuxError, TmuxService, normalize_session_name
|
|
12
|
+
|
|
13
|
+
LOGGER = logging.getLogger(__name__)
|
|
14
|
+
AUTO_SEND_MAX_LENGTH = 4000
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _render_code_block(text: str) -> str:
|
|
18
|
+
stripped = text.strip()
|
|
19
|
+
if not stripped:
|
|
20
|
+
return "```text\n<empty>\n```"
|
|
21
|
+
return f"```text\n{stripped[:1800]}\n```"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _extract_auto_send_text(message: discord.Message) -> str | None:
|
|
25
|
+
if message.author.bot or message.webhook_id is not None:
|
|
26
|
+
return None
|
|
27
|
+
if message.guild is None or isinstance(message.channel, discord.Thread):
|
|
28
|
+
return None
|
|
29
|
+
if message.type is not discord.MessageType.default:
|
|
30
|
+
return None
|
|
31
|
+
if message.reference is not None or message.attachments:
|
|
32
|
+
return None
|
|
33
|
+
content = message.content.strip()
|
|
34
|
+
if not content or content.startswith("/") or len(content) > AUTO_SEND_MAX_LENGTH:
|
|
35
|
+
return None
|
|
36
|
+
return content
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class TetherlyBot(discord.Client):
|
|
40
|
+
def __init__(
|
|
41
|
+
self,
|
|
42
|
+
*,
|
|
43
|
+
config: Config,
|
|
44
|
+
registry: SessionRegistry,
|
|
45
|
+
tmux_service: TmuxService,
|
|
46
|
+
access_controller: AccessController,
|
|
47
|
+
) -> None:
|
|
48
|
+
intents = discord.Intents.default()
|
|
49
|
+
intents.message_content = True
|
|
50
|
+
super().__init__(intents=intents)
|
|
51
|
+
self.config = config
|
|
52
|
+
self.registry = registry
|
|
53
|
+
self.tmux_service = tmux_service
|
|
54
|
+
self.access_controller = access_controller
|
|
55
|
+
self.tree = app_commands.CommandTree(self)
|
|
56
|
+
|
|
57
|
+
async def setup_hook(self) -> None:
|
|
58
|
+
self._register_commands()
|
|
59
|
+
if self.config.test_guild_id is not None:
|
|
60
|
+
guild = discord.Object(id=self.config.test_guild_id)
|
|
61
|
+
self.tree.copy_global_to(guild=guild)
|
|
62
|
+
await self.tree.sync(guild=guild)
|
|
63
|
+
LOGGER.info("synced commands to guild %s", self.config.test_guild_id)
|
|
64
|
+
return
|
|
65
|
+
await self.tree.sync()
|
|
66
|
+
|
|
67
|
+
async def on_ready(self) -> None:
|
|
68
|
+
LOGGER.info("bot ready as %s", self.user)
|
|
69
|
+
|
|
70
|
+
async def on_message(self, message: discord.Message) -> None:
|
|
71
|
+
if message.guild is None:
|
|
72
|
+
return
|
|
73
|
+
binding = self.registry.get(message.channel.id)
|
|
74
|
+
if binding is None or not binding.auto_send:
|
|
75
|
+
return
|
|
76
|
+
if not self.access_controller.is_allowed_user(message.guild.id, message.author):
|
|
77
|
+
return
|
|
78
|
+
content = _extract_auto_send_text(message)
|
|
79
|
+
if content is None:
|
|
80
|
+
return
|
|
81
|
+
try:
|
|
82
|
+
self.tmux_service.send_text(binding.session_name, content, press_enter=True)
|
|
83
|
+
except TmuxError as exc:
|
|
84
|
+
LOGGER.warning(
|
|
85
|
+
"auto-send failed for channel %s session %s: %s",
|
|
86
|
+
binding.channel_id,
|
|
87
|
+
binding.session_name,
|
|
88
|
+
exc,
|
|
89
|
+
)
|
|
90
|
+
return
|
|
91
|
+
self.registry.touch(binding.channel_id)
|
|
92
|
+
|
|
93
|
+
def _register_commands(self) -> None:
|
|
94
|
+
@self.tree.command(name="bind", description="Bind this Discord channel to a tmux session.")
|
|
95
|
+
@app_commands.describe(session="tmux session name")
|
|
96
|
+
async def bind(interaction: discord.Interaction, session: str) -> None:
|
|
97
|
+
if not await self.access_controller.assert_allowed(interaction):
|
|
98
|
+
return
|
|
99
|
+
if interaction.guild_id is None or interaction.channel_id is None:
|
|
100
|
+
await interaction.response.send_message(
|
|
101
|
+
"This command can only be used inside a guild channel.",
|
|
102
|
+
ephemeral=True,
|
|
103
|
+
)
|
|
104
|
+
return
|
|
105
|
+
try:
|
|
106
|
+
session_name = normalize_session_name(session)
|
|
107
|
+
except ValueError as exc:
|
|
108
|
+
await interaction.response.send_message(str(exc), ephemeral=True)
|
|
109
|
+
return
|
|
110
|
+
created = self.tmux_service.ensure_session(session_name)
|
|
111
|
+
self.tmux_service.set_session_environment(
|
|
112
|
+
session_name,
|
|
113
|
+
"TETHERLY_SESSION",
|
|
114
|
+
session_name,
|
|
115
|
+
)
|
|
116
|
+
self.tmux_service.set_session_environment(
|
|
117
|
+
session_name,
|
|
118
|
+
"TETHERLY_NOTIFY_ON_FINISH",
|
|
119
|
+
"1",
|
|
120
|
+
)
|
|
121
|
+
try:
|
|
122
|
+
binding = self.registry.bind(
|
|
123
|
+
guild_id=interaction.guild_id,
|
|
124
|
+
channel_id=interaction.channel_id,
|
|
125
|
+
session_name=session_name,
|
|
126
|
+
bound_by=interaction.user.id,
|
|
127
|
+
)
|
|
128
|
+
except SessionRegistryError as exc:
|
|
129
|
+
await interaction.response.send_message(str(exc), ephemeral=True)
|
|
130
|
+
return
|
|
131
|
+
verb = "Created and bound" if created else "Bound"
|
|
132
|
+
await interaction.response.send_message(
|
|
133
|
+
f"{verb} channel <#{binding.channel_id}> to tmux session `{binding.session_name}`.",
|
|
134
|
+
ephemeral=True,
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
@self.tree.command(
|
|
138
|
+
name="config",
|
|
139
|
+
description="Configure channel behavior for the bound tmux session.",
|
|
140
|
+
)
|
|
141
|
+
@app_commands.describe(auto_send="forward plain text messages without using /send")
|
|
142
|
+
async def config(interaction: discord.Interaction, auto_send: bool) -> None:
|
|
143
|
+
if not await self.access_controller.assert_allowed(interaction):
|
|
144
|
+
return
|
|
145
|
+
binding = self.registry.get(interaction.channel_id)
|
|
146
|
+
if binding is None:
|
|
147
|
+
await interaction.response.send_message(
|
|
148
|
+
"This channel is not bound. Run `/bind session:<name>` first.",
|
|
149
|
+
ephemeral=True,
|
|
150
|
+
)
|
|
151
|
+
return
|
|
152
|
+
updated = self.registry.set_auto_send(interaction.channel_id, auto_send)
|
|
153
|
+
status = "enabled" if auto_send else "disabled"
|
|
154
|
+
await interaction.response.send_message(
|
|
155
|
+
f"Auto-send {status} for `{updated.session_name}`.",
|
|
156
|
+
ephemeral=True,
|
|
157
|
+
)
|
|
158
|
+
|
|
159
|
+
@self.tree.command(name="send", description="Send text into the bound tmux session and press Enter.")
|
|
160
|
+
@app_commands.describe(text="text to send")
|
|
161
|
+
async def send(interaction: discord.Interaction, text: str) -> None:
|
|
162
|
+
if not await self.access_controller.assert_allowed(interaction):
|
|
163
|
+
return
|
|
164
|
+
binding = self.registry.get(interaction.channel_id)
|
|
165
|
+
if binding is None:
|
|
166
|
+
await interaction.response.send_message(
|
|
167
|
+
"This channel is not bound. Run `/bind session:<name>` first.",
|
|
168
|
+
ephemeral=True,
|
|
169
|
+
)
|
|
170
|
+
return
|
|
171
|
+
try:
|
|
172
|
+
self.tmux_service.send_text(binding.session_name, text, press_enter=True)
|
|
173
|
+
except TmuxError as exc:
|
|
174
|
+
await interaction.response.send_message(
|
|
175
|
+
f"Failed to send to `{binding.session_name}`: {exc}",
|
|
176
|
+
ephemeral=True,
|
|
177
|
+
)
|
|
178
|
+
return
|
|
179
|
+
self.registry.touch(interaction.channel_id)
|
|
180
|
+
await interaction.response.send_message(
|
|
181
|
+
f"Sent to `{binding.session_name}`.",
|
|
182
|
+
ephemeral=True,
|
|
183
|
+
)
|
|
184
|
+
|
|
185
|
+
@self.tree.command(name="key", description="Send a special key into the bound tmux session.")
|
|
186
|
+
@app_commands.describe(key="special key to send")
|
|
187
|
+
@app_commands.choices(
|
|
188
|
+
key=[
|
|
189
|
+
app_commands.Choice(name="Enter", value="enter"),
|
|
190
|
+
app_commands.Choice(name="Escape", value="esc"),
|
|
191
|
+
app_commands.Choice(name="Ctrl-C", value="ctrl-c"),
|
|
192
|
+
app_commands.Choice(name="Ctrl-D", value="ctrl-d"),
|
|
193
|
+
app_commands.Choice(name="Tab", value="tab"),
|
|
194
|
+
app_commands.Choice(name="Up", value="up"),
|
|
195
|
+
app_commands.Choice(name="Down", value="down"),
|
|
196
|
+
app_commands.Choice(name="Left", value="left"),
|
|
197
|
+
app_commands.Choice(name="Right", value="right"),
|
|
198
|
+
]
|
|
199
|
+
)
|
|
200
|
+
async def key(interaction: discord.Interaction, key: app_commands.Choice[str]) -> None:
|
|
201
|
+
if not await self.access_controller.assert_allowed(interaction):
|
|
202
|
+
return
|
|
203
|
+
binding = self.registry.get(interaction.channel_id)
|
|
204
|
+
if binding is None:
|
|
205
|
+
await interaction.response.send_message(
|
|
206
|
+
"This channel is not bound. Run `/bind session:<name>` first.",
|
|
207
|
+
ephemeral=True,
|
|
208
|
+
)
|
|
209
|
+
return
|
|
210
|
+
try:
|
|
211
|
+
self.tmux_service.send_key(binding.session_name, key.value)
|
|
212
|
+
except TmuxError as exc:
|
|
213
|
+
await interaction.response.send_message(
|
|
214
|
+
f"Failed to send `{key.name}` to `{binding.session_name}`: {exc}",
|
|
215
|
+
ephemeral=True,
|
|
216
|
+
)
|
|
217
|
+
return
|
|
218
|
+
self.registry.touch(interaction.channel_id)
|
|
219
|
+
await interaction.response.send_message(
|
|
220
|
+
f"Sent `{key.name}` to `{binding.session_name}`.",
|
|
221
|
+
ephemeral=True,
|
|
222
|
+
)
|
|
223
|
+
|
|
224
|
+
@self.tree.command(name="tail", description="Show recent output from the bound tmux session.")
|
|
225
|
+
@app_commands.describe(lines="number of lines to fetch")
|
|
226
|
+
async def tail(interaction: discord.Interaction, lines: int | None = None) -> None:
|
|
227
|
+
if not await self.access_controller.assert_allowed(interaction):
|
|
228
|
+
return
|
|
229
|
+
binding = self.registry.get(interaction.channel_id)
|
|
230
|
+
if binding is None:
|
|
231
|
+
await interaction.response.send_message(
|
|
232
|
+
"This channel is not bound. Run `/bind session:<name>` first.",
|
|
233
|
+
ephemeral=True,
|
|
234
|
+
)
|
|
235
|
+
return
|
|
236
|
+
requested = lines or self.config.default_tail_lines
|
|
237
|
+
capped = min(max(1, requested), self.config.max_tail_lines)
|
|
238
|
+
try:
|
|
239
|
+
output = self.tmux_service.capture_tail(binding.session_name, capped)
|
|
240
|
+
except TmuxError as exc:
|
|
241
|
+
await interaction.response.send_message(
|
|
242
|
+
f"Failed to capture `{binding.session_name}`: {exc}",
|
|
243
|
+
ephemeral=True,
|
|
244
|
+
)
|
|
245
|
+
return
|
|
246
|
+
self.registry.touch(interaction.channel_id)
|
|
247
|
+
await interaction.response.send_message(
|
|
248
|
+
f"Recent output from `{binding.session_name}` ({capped} lines max)\n{_render_code_block(output)}",
|
|
249
|
+
ephemeral=True,
|
|
250
|
+
)
|
|
251
|
+
|
|
252
|
+
@self.tree.command(name="status", description="Show binding and tmux session status for this channel.")
|
|
253
|
+
async def status(interaction: discord.Interaction) -> None:
|
|
254
|
+
if not await self.access_controller.assert_allowed(interaction):
|
|
255
|
+
return
|
|
256
|
+
binding = self.registry.get(interaction.channel_id)
|
|
257
|
+
if binding is None:
|
|
258
|
+
await interaction.response.send_message(
|
|
259
|
+
"This channel is not bound.",
|
|
260
|
+
ephemeral=True,
|
|
261
|
+
)
|
|
262
|
+
return
|
|
263
|
+
tmux_status = self.tmux_service.get_status(binding.session_name)
|
|
264
|
+
if tmux_status.exists:
|
|
265
|
+
headline = f"🟢 Active — tmux session `{binding.session_name}` is alive"
|
|
266
|
+
else:
|
|
267
|
+
headline = (
|
|
268
|
+
f"🔴 tmux session `{binding.session_name}` is GONE — "
|
|
269
|
+
"run `/bind session:<name>` to reconnect"
|
|
270
|
+
)
|
|
271
|
+
await interaction.response.send_message(
|
|
272
|
+
"\n".join(
|
|
273
|
+
[
|
|
274
|
+
headline,
|
|
275
|
+
f"Channel: <#{binding.channel_id}>",
|
|
276
|
+
f"Auto-send: `{binding.auto_send}`",
|
|
277
|
+
f"Bound by: <@{binding.bound_by}>",
|
|
278
|
+
f"Bound at: `{binding.bound_at}`",
|
|
279
|
+
f"Last used at: `{binding.last_used_at}`",
|
|
280
|
+
]
|
|
281
|
+
),
|
|
282
|
+
ephemeral=True,
|
|
283
|
+
)
|