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 +1 -0
- tetherly/authz.py +37 -0
- tetherly/config.py +83 -0
- tetherly/discord_bot.py +283 -0
- tetherly/discord_sender.py +93 -0
- tetherly/main.py +474 -0
- tetherly/models.py +34 -0
- tetherly/session_registry.py +93 -0
- tetherly/setup.py +221 -0
- tetherly/tmux_service.py +136 -0
- tetherly-0.1.0.dist-info/METADATA +116 -0
- tetherly-0.1.0.dist-info/RECORD +16 -0
- tetherly-0.1.0.dist-info/WHEEL +5 -0
- tetherly-0.1.0.dist-info/entry_points.txt +2 -0
- tetherly-0.1.0.dist-info/licenses/LICENSE +21 -0
- tetherly-0.1.0.dist-info/top_level.txt +1 -0
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
|
+
)
|
tetherly/discord_bot.py
ADDED
|
@@ -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
|
+
)
|