tetherly 0.1.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.
tetherly/__init__.py ADDED
@@ -0,0 +1 @@
1
+ """tetherly package."""
tetherly/authz.py ADDED
@@ -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
tetherly/config.py ADDED
@@ -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
+ )
@@ -0,0 +1,93 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ from dataclasses import dataclass
5
+
6
+ from tetherly.config import Config
7
+ from tetherly.session_registry import SessionRegistry
8
+
9
+ DISCORD_MESSAGE_LIMIT = 2000
10
+
11
+
12
+ class DiscordSendError(RuntimeError):
13
+ pass
14
+
15
+
16
+ @dataclass(frozen=True)
17
+ class SendResult:
18
+ channel_id: int
19
+ session_name: str
20
+ chunks_sent: int
21
+
22
+
23
+ def split_message(text: str, limit: int = DISCORD_MESSAGE_LIMIT) -> list[str]:
24
+ normalized = text.strip()
25
+ if not normalized:
26
+ raise DiscordSendError("message is empty")
27
+ chunks: list[str] = []
28
+ remaining = normalized
29
+ while remaining:
30
+ if len(remaining) <= limit:
31
+ chunks.append(remaining)
32
+ break
33
+ split_at = remaining.rfind("\n", 0, limit)
34
+ if split_at <= 0:
35
+ split_at = limit
36
+ chunks.append(remaining[:split_at].rstrip())
37
+ remaining = remaining[split_at:].lstrip("\n")
38
+ return chunks
39
+
40
+
41
+ async def _post_message(token: str, channel_id: int, content: str) -> None:
42
+ import aiohttp
43
+
44
+ url = f"https://discord.com/api/v10/channels/{channel_id}/messages"
45
+ headers = {
46
+ "Authorization": f"Bot {token}",
47
+ "Content-Type": "application/json",
48
+ }
49
+ async with aiohttp.ClientSession(headers=headers) as session:
50
+ async with session.post(url, json={"content": content}) as response:
51
+ if response.status >= 400:
52
+ body = await response.text()
53
+ raise DiscordSendError(
54
+ f"discord send failed with status {response.status}: {body}"
55
+ )
56
+
57
+
58
+ async def send_to_session_async(
59
+ *,
60
+ config: Config,
61
+ registry: SessionRegistry,
62
+ session_name: str,
63
+ message: str,
64
+ ) -> SendResult:
65
+ binding = registry.get_by_session_name(session_name)
66
+ if binding is None:
67
+ raise DiscordSendError(f"no bound Discord channel for session {session_name!r}")
68
+ chunks = split_message(message)
69
+ for chunk in chunks:
70
+ await _post_message(config.discord_bot_token, binding.channel_id, chunk)
71
+ registry.touch(binding.channel_id)
72
+ return SendResult(
73
+ channel_id=binding.channel_id,
74
+ session_name=session_name,
75
+ chunks_sent=len(chunks),
76
+ )
77
+
78
+
79
+ def send_to_session(
80
+ *,
81
+ config: Config,
82
+ registry: SessionRegistry,
83
+ session_name: str,
84
+ message: str,
85
+ ) -> SendResult:
86
+ return asyncio.run(
87
+ send_to_session_async(
88
+ config=config,
89
+ registry=registry,
90
+ session_name=session_name,
91
+ message=message,
92
+ )
93
+ )