bot-master 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.
bot_master/__init__.py ADDED
File without changes
bot_master/app.py ADDED
@@ -0,0 +1,284 @@
1
+ import asyncio
2
+ from pathlib import Path
3
+
4
+ from textual.app import App, ComposeResult
5
+ from textual.binding import Binding
6
+ from textual.containers import Horizontal, Vertical
7
+ from textual.message import Message
8
+ from textual.reactive import reactive
9
+ from textual.widgets import Footer, Header, RichLog, Static
10
+
11
+ from bot_master.protocol import SOCKET_PATH, read_message, write_message
12
+
13
+
14
+ class DaemonClient:
15
+ def __init__(self) -> None:
16
+ self.reader: asyncio.StreamReader | None = None
17
+ self.writer: asyncio.StreamWriter | None = None
18
+ self._lock = asyncio.Lock()
19
+
20
+ async def connect(self) -> bool:
21
+ try:
22
+ self.reader, self.writer = await asyncio.open_unix_connection(str(SOCKET_PATH))
23
+ return True
24
+ except (ConnectionRefusedError, FileNotFoundError, OSError):
25
+ return False
26
+
27
+ async def send(self, msg: dict) -> dict | None:
28
+ if not self.writer or not self.reader:
29
+ return None
30
+ async with self._lock:
31
+ try:
32
+ await write_message(self.writer, msg)
33
+ return await read_message(self.reader)
34
+ except (ConnectionError, BrokenPipeError, OSError):
35
+ return None
36
+
37
+ async def close(self) -> None:
38
+ if self.writer:
39
+ self.writer.close()
40
+ try:
41
+ await self.writer.wait_closed()
42
+ except Exception:
43
+ pass
44
+
45
+
46
+ class BotItem(Static):
47
+ selected = reactive(False)
48
+
49
+ def __init__(self, bot_name: str, **kwargs) -> None:
50
+ super().__init__(**kwargs)
51
+ self.bot_name = bot_name
52
+ self.bot_status = "stopped"
53
+ self.bot_pid: int | None = None
54
+ self.bot_uptime: int | None = None
55
+ self.bot_restarts: int = 0
56
+
57
+ class Selected(Message):
58
+ def __init__(self, bot_name: str) -> None:
59
+ super().__init__()
60
+ self.bot_name = bot_name
61
+
62
+ def on_click(self) -> None:
63
+ self.post_message(self.Selected(self.bot_name))
64
+
65
+ def render(self) -> str:
66
+ status = self.bot_status
67
+ if status == "running":
68
+ indicator = "[green]●[/]"
69
+ extra = ""
70
+ if self.bot_uptime is not None:
71
+ m, s = divmod(self.bot_uptime, 60)
72
+ h, m = divmod(m, 60)
73
+ extra = f" [dim]{h:02d}:{m:02d}:{s:02d}[/]"
74
+ elif status == "backoff":
75
+ indicator = "[yellow]●[/]"
76
+ extra = f" [dim]restart #{self.bot_restarts}[/]"
77
+ else:
78
+ indicator = "[dim]●[/]"
79
+ extra = ""
80
+
81
+ sel = "▸ " if self.selected else " "
82
+ return f"{sel}{indicator} {self.bot_name}{extra}"
83
+
84
+ def watch_selected(self, value: bool) -> None:
85
+ self.set_class(value, "--selected")
86
+
87
+
88
+ class BotMasterApp(App):
89
+ CSS_PATH = "app.tcss"
90
+ TITLE = "Bot Master"
91
+
92
+ BINDINGS = [
93
+ Binding("s", "start_bot", "Start"),
94
+ Binding("x", "stop_bot", "Stop"),
95
+ Binding("r", "restart_bot", "Restart"),
96
+ Binding("a", "start_all", "Start All"),
97
+ Binding("z", "stop_all", "Stop All"),
98
+ Binding("j", "next_bot", "Next", show=False),
99
+ Binding("k", "prev_bot", "Prev", show=False),
100
+ Binding("down", "next_bot", "Next", show=False),
101
+ Binding("up", "prev_bot", "Prev", show=False),
102
+ Binding("q", "quit", "Quit"),
103
+ ]
104
+
105
+ selected_bot: reactive[str] = reactive("")
106
+
107
+ def __init__(self) -> None:
108
+ super().__init__()
109
+ self.client = DaemonClient()
110
+ self.bot_names: list[str] = []
111
+ self._log_task: asyncio.Task | None = None
112
+ self._connected = False
113
+
114
+ def compose(self) -> ComposeResult:
115
+ yield Header()
116
+ with Horizontal(id="main-container"):
117
+ with Vertical(id="sidebar"):
118
+ yield Static("Bots", id="sidebar-title")
119
+ yield Vertical(id="bot-list")
120
+ with Vertical(id="log-container"):
121
+ yield Static("Select a bot", id="log-title")
122
+ yield RichLog(id="log-view", highlight=True, markup=True)
123
+ yield Footer()
124
+
125
+ async def on_mount(self) -> None:
126
+ connected = await self.client.connect()
127
+ if not connected:
128
+ log_view = self.query_one("#log-view", RichLog)
129
+ log_view.write("[red]Cannot connect to daemon.[/]")
130
+ log_view.write(f"[dim]Is bot-master-daemon running? Socket: {SOCKET_PATH}[/]")
131
+ log_view.write("")
132
+ log_view.write("[dim]Start it with: bot-master-daemon[/]")
133
+ return
134
+
135
+ self._connected = True
136
+ resp = await self.client.send({"action": "status"})
137
+ if resp and resp.get("ok"):
138
+ bot_list = self.query_one("#bot-list", Vertical)
139
+ for bot_info in resp["bots"]:
140
+ name = bot_info["name"]
141
+ self.bot_names.append(name)
142
+ item = BotItem(name, id=f"bot-{name}")
143
+ item.bot_status = bot_info["status"]
144
+ item.bot_pid = bot_info.get("pid")
145
+ item.bot_uptime = bot_info.get("uptime")
146
+ item.bot_restarts = bot_info.get("restart_count", 0)
147
+ await bot_list.mount(item)
148
+
149
+ if self.bot_names:
150
+ self.selected_bot = self.bot_names[0]
151
+
152
+ self.set_interval(2, self._poll_status)
153
+
154
+ async def _poll_status(self) -> None:
155
+ if not self._connected:
156
+ return
157
+ resp = await self.client.send({"action": "status"})
158
+ if not resp or not resp.get("ok"):
159
+ return
160
+ for bot_info in resp["bots"]:
161
+ name = bot_info["name"]
162
+ try:
163
+ item = self.query_one(f"#bot-{name}", BotItem)
164
+ item.bot_status = bot_info["status"]
165
+ item.bot_pid = bot_info.get("pid")
166
+ item.bot_uptime = bot_info.get("uptime")
167
+ item.bot_restarts = bot_info.get("restart_count", 0)
168
+ item.refresh()
169
+ except Exception:
170
+ pass
171
+
172
+ def on_bot_item_selected(self, event: BotItem.Selected) -> None:
173
+ self.selected_bot = event.bot_name
174
+
175
+ async def watch_selected_bot(self, bot_name: str) -> None:
176
+ if not bot_name or not self._connected:
177
+ return
178
+
179
+ # Update selection UI
180
+ for name in self.bot_names:
181
+ try:
182
+ item = self.query_one(f"#bot-{name}", BotItem)
183
+ item.selected = name == bot_name
184
+ except Exception:
185
+ pass
186
+
187
+ # Update title
188
+ title = self.query_one("#log-title", Static)
189
+ title.update(f"Logs: {bot_name}")
190
+
191
+ # Cancel existing log stream
192
+ if self._log_task:
193
+ self._log_task.cancel()
194
+ self._log_task = None
195
+
196
+ # Fetch recent logs
197
+ log_view = self.query_one("#log-view", RichLog)
198
+ log_view.clear()
199
+
200
+ # We need a fresh connection for log streaming since the protocol
201
+ # uses subscribe_logs which holds the connection
202
+ resp = await self.client.send({"action": "logs", "bot": bot_name, "lines": 500})
203
+ if resp and resp.get("ok"):
204
+ for line in resp["lines"]:
205
+ log_view.write(line)
206
+
207
+ # Start streaming in a separate connection
208
+ self._log_task = asyncio.create_task(self._stream_logs(bot_name))
209
+
210
+ async def _stream_logs(self, bot_name: str) -> None:
211
+ try:
212
+ reader, writer = await asyncio.open_unix_connection(str(SOCKET_PATH))
213
+ await write_message(writer, {"action": "subscribe_logs", "bot": bot_name})
214
+ resp = await read_message(reader)
215
+ if not resp or not resp.get("ok"):
216
+ return
217
+
218
+ log_view = self.query_one("#log-view", RichLog)
219
+ while True:
220
+ msg = await read_message(reader)
221
+ if msg is None:
222
+ break
223
+ if "log" in msg:
224
+ log_view.write(msg["log"])
225
+
226
+ except (asyncio.CancelledError, ConnectionError, OSError):
227
+ pass
228
+
229
+ async def action_start_bot(self) -> None:
230
+ if self.selected_bot and self._connected:
231
+ await self.client.send({"action": "start", "bot": self.selected_bot})
232
+
233
+ async def action_stop_bot(self) -> None:
234
+ if self.selected_bot and self._connected:
235
+ await self.client.send({"action": "stop", "bot": self.selected_bot})
236
+
237
+ async def action_restart_bot(self) -> None:
238
+ if self.selected_bot and self._connected:
239
+ await self.client.send({"action": "restart", "bot": self.selected_bot})
240
+
241
+ async def action_start_all(self) -> None:
242
+ if self._connected:
243
+ for name in self.bot_names:
244
+ await self.client.send({"action": "start", "bot": name})
245
+
246
+ async def action_stop_all(self) -> None:
247
+ if self._connected:
248
+ for name in self.bot_names:
249
+ await self.client.send({"action": "stop", "bot": name})
250
+
251
+ def action_next_bot(self) -> None:
252
+ if not self.bot_names:
253
+ return
254
+ try:
255
+ idx = self.bot_names.index(self.selected_bot)
256
+ idx = (idx + 1) % len(self.bot_names)
257
+ except ValueError:
258
+ idx = 0
259
+ self.selected_bot = self.bot_names[idx]
260
+
261
+ def action_prev_bot(self) -> None:
262
+ if not self.bot_names:
263
+ return
264
+ try:
265
+ idx = self.bot_names.index(self.selected_bot)
266
+ idx = (idx - 1) % len(self.bot_names)
267
+ except ValueError:
268
+ idx = 0
269
+ self.selected_bot = self.bot_names[idx]
270
+
271
+ async def action_quit(self) -> None:
272
+ if self._log_task:
273
+ self._log_task.cancel()
274
+ await self.client.close()
275
+ self.exit()
276
+
277
+
278
+ def main() -> None:
279
+ app = BotMasterApp()
280
+ app.run()
281
+
282
+
283
+ if __name__ == "__main__":
284
+ main()
bot_master/app.tcss ADDED
@@ -0,0 +1,53 @@
1
+ #main-container {
2
+ height: 1fr;
3
+ }
4
+
5
+ #sidebar {
6
+ width: 32;
7
+ border-right: solid $primary;
8
+ padding: 0;
9
+ }
10
+
11
+ #sidebar-title {
12
+ text-style: bold;
13
+ padding: 1 1 0 1;
14
+ color: $text;
15
+ }
16
+
17
+ #bot-list {
18
+ height: 1fr;
19
+ }
20
+
21
+ BotItem {
22
+ height: 3;
23
+ padding: 0 1;
24
+ }
25
+
26
+ BotItem:hover {
27
+ background: $boost;
28
+ }
29
+
30
+ BotItem.--selected {
31
+ background: $accent;
32
+ }
33
+
34
+ #log-container {
35
+ width: 1fr;
36
+ }
37
+
38
+ #log-title {
39
+ text-style: bold;
40
+ padding: 1;
41
+ dock: top;
42
+ height: 3;
43
+ }
44
+
45
+ #log-view {
46
+ height: 1fr;
47
+ }
48
+
49
+ #no-connection {
50
+ width: 1fr;
51
+ height: 1fr;
52
+ content-align: center middle;
53
+ }
bot_master/daemon.py ADDED
@@ -0,0 +1,176 @@
1
+ import asyncio
2
+ import os
3
+ import signal
4
+ import sys
5
+ from pathlib import Path
6
+
7
+ from bot_master.process_manager import ProcessManager
8
+ from bot_master.protocol import CONFIG_PATH, SOCKET_PATH, read_message, write_message
9
+
10
+ manager = ProcessManager()
11
+
12
+
13
+ async def handle_client(
14
+ reader: asyncio.StreamReader, writer: asyncio.StreamWriter
15
+ ) -> None:
16
+ subscription: tuple[str, asyncio.Queue] | None = None
17
+
18
+ try:
19
+ while True:
20
+ msg = await read_message(reader)
21
+ if msg is None:
22
+ break
23
+
24
+ action = msg.get("action")
25
+
26
+ # Cancel any active log subscription when a new command arrives
27
+ if subscription:
28
+ bot_name, queue = subscription
29
+ try:
30
+ manager.get_bot(bot_name).unsubscribe(queue)
31
+ except KeyError:
32
+ pass
33
+ subscription = None
34
+
35
+ if action == "status":
36
+ await write_message(writer, {"ok": True, "bots": manager.get_all_status()})
37
+
38
+ elif action in ("start", "stop", "restart"):
39
+ bot_name = msg.get("bot", "")
40
+ try:
41
+ bot = manager.get_bot(bot_name)
42
+ await getattr(bot, action)()
43
+ await write_message(writer, {"ok": True})
44
+ except KeyError:
45
+ await write_message(writer, {"ok": False, "error": f"Unknown bot: {bot_name}"})
46
+ except Exception as e:
47
+ await write_message(writer, {"ok": False, "error": str(e)})
48
+
49
+ elif action == "logs":
50
+ bot_name = msg.get("bot", "")
51
+ n = msg.get("lines", 200)
52
+ try:
53
+ bot = manager.get_bot(bot_name)
54
+ lines = bot.get_logs(n)
55
+ await write_message(writer, {"ok": True, "lines": lines})
56
+ except KeyError:
57
+ await write_message(writer, {"ok": False, "error": f"Unknown bot: {bot_name}"})
58
+
59
+ elif action == "subscribe_logs":
60
+ bot_name = msg.get("bot", "")
61
+ try:
62
+ bot = manager.get_bot(bot_name)
63
+ queue = bot.subscribe()
64
+ subscription = (bot_name, queue)
65
+ await write_message(writer, {"ok": True, "streaming": True})
66
+
67
+ # Stream logs until client sends a new command or disconnects
68
+ while True:
69
+ # Wait for either a log line or a new command
70
+ read_task = asyncio.create_task(reader.readline())
71
+ queue_task = asyncio.create_task(queue.get())
72
+
73
+ done, pending = await asyncio.wait(
74
+ {read_task, queue_task},
75
+ return_when=asyncio.FIRST_COMPLETED,
76
+ )
77
+
78
+ for task in pending:
79
+ task.cancel()
80
+ try:
81
+ await task
82
+ except (asyncio.CancelledError, Exception):
83
+ pass
84
+
85
+ if queue_task in done:
86
+ line = queue_task.result()
87
+ await write_message(writer, {"log": line})
88
+
89
+ if read_task in done:
90
+ raw = read_task.result()
91
+ if not raw:
92
+ # Client disconnected
93
+ bot.unsubscribe(queue)
94
+ subscription = None
95
+ return
96
+ # Client sent a new command — unsubscribe and process it
97
+ bot.unsubscribe(queue)
98
+ subscription = None
99
+ # Put the message back by processing it inline
100
+ try:
101
+ import json
102
+ new_msg = json.loads(raw.decode("utf-8"))
103
+ # Re-inject: we'll handle it by breaking and letting
104
+ # the outer loop pick it up. But since we already consumed
105
+ # the line, we handle it directly here.
106
+ # Simplest: just break and let client resend
107
+ except Exception:
108
+ pass
109
+ break
110
+
111
+ except KeyError:
112
+ await write_message(writer, {"ok": False, "error": f"Unknown bot: {bot_name}"})
113
+
114
+ else:
115
+ await write_message(writer, {"ok": False, "error": f"Unknown action: {action}"})
116
+
117
+ except (ConnectionError, BrokenPipeError):
118
+ pass
119
+ finally:
120
+ if subscription:
121
+ bot_name, queue = subscription
122
+ try:
123
+ manager.get_bot(bot_name).unsubscribe(queue)
124
+ except KeyError:
125
+ pass
126
+ writer.close()
127
+ try:
128
+ await writer.wait_closed()
129
+ except Exception:
130
+ pass
131
+
132
+
133
+ async def run() -> None:
134
+ config_path = Path(sys.argv[1]) if len(sys.argv) > 1 else CONFIG_PATH
135
+ if not config_path.is_absolute():
136
+ config_path = Path.cwd() / config_path
137
+
138
+ print(f"Loading config from {config_path}")
139
+ manager.load_config(config_path)
140
+
141
+ print("Starting all bots...")
142
+ await manager.start_all()
143
+
144
+ # Clean up stale socket
145
+ SOCKET_PATH.unlink(missing_ok=True)
146
+
147
+ server = await asyncio.start_unix_server(handle_client, path=str(SOCKET_PATH))
148
+ os.chmod(SOCKET_PATH, 0o600)
149
+ print(f"Daemon listening on {SOCKET_PATH}")
150
+
151
+ loop = asyncio.get_running_loop()
152
+ shutdown_event = asyncio.Event()
153
+
154
+ def _signal_handler() -> None:
155
+ print("\nShutting down...")
156
+ shutdown_event.set()
157
+
158
+ for sig in (signal.SIGTERM, signal.SIGINT):
159
+ loop.add_signal_handler(sig, _signal_handler)
160
+
161
+ await shutdown_event.wait()
162
+
163
+ print("Stopping all bots...")
164
+ await manager.stop_all()
165
+ server.close()
166
+ await server.wait_closed()
167
+ SOCKET_PATH.unlink(missing_ok=True)
168
+ print("Daemon stopped.")
169
+
170
+
171
+ def main() -> None:
172
+ asyncio.run(run())
173
+
174
+
175
+ if __name__ == "__main__":
176
+ main()
@@ -0,0 +1,217 @@
1
+ import asyncio
2
+ import logging
3
+ import os
4
+ import signal
5
+ import time
6
+ from collections import deque
7
+ from dataclasses import dataclass, field
8
+ from datetime import datetime
9
+ from logging.handlers import RotatingFileHandler
10
+ from pathlib import Path
11
+
12
+ import yaml
13
+
14
+ LOG_DIR = Path(os.environ.get("BOT_MASTER_LOG_DIR", "logs"))
15
+
16
+
17
+ def _setup_file_logger(name: str, log_dir: Path) -> logging.Logger:
18
+ log_dir.mkdir(parents=True, exist_ok=True)
19
+ logger = logging.getLogger(f"bot.{name}")
20
+ logger.setLevel(logging.DEBUG)
21
+ logger.propagate = False
22
+ if not logger.handlers:
23
+ handler = RotatingFileHandler(
24
+ log_dir / f"{name}.log",
25
+ maxBytes=10 * 1024 * 1024, # 10 MB
26
+ backupCount=5,
27
+ encoding="utf-8",
28
+ )
29
+ handler.setFormatter(logging.Formatter("%(message)s"))
30
+ logger.addHandler(handler)
31
+ return logger
32
+
33
+
34
+ @dataclass
35
+ class BotConfig:
36
+ name: str
37
+ directory: str
38
+ command: str
39
+
40
+
41
+ class BotProcess:
42
+ def __init__(self, config: BotConfig, log_dir: Path) -> None:
43
+ self.config = config
44
+ self.process: asyncio.subprocess.Process | None = None
45
+ self.status: str = "stopped" # stopped, running, backoff
46
+ self.log_buffer: deque[str] = deque(maxlen=5000)
47
+ self.subscribers: set[asyncio.Queue] = set()
48
+ self.restart_count: int = 0
49
+ self._should_run: bool = False
50
+ self._tasks: list[asyncio.Task] = []
51
+ self._start_time: float = 0
52
+ self._lock = asyncio.Lock()
53
+ self._file_logger = _setup_file_logger(config.name, log_dir)
54
+
55
+ async def start(self) -> None:
56
+ async with self._lock:
57
+ if self.status == "running":
58
+ return
59
+ self._should_run = True
60
+ self.restart_count = 0
61
+ await self._spawn()
62
+
63
+ async def _spawn(self) -> None:
64
+ env = {**os.environ, "PYTHONUNBUFFERED": "1"}
65
+ try:
66
+ self.process = await asyncio.create_subprocess_shell(
67
+ self.config.command,
68
+ cwd=self.config.directory,
69
+ stdout=asyncio.subprocess.PIPE,
70
+ stderr=asyncio.subprocess.STDOUT,
71
+ env=env,
72
+ start_new_session=True,
73
+ )
74
+ except Exception as e:
75
+ self._log(f"[bot-master] Failed to start: {e}")
76
+ self.status = "backoff"
77
+ if self._should_run:
78
+ asyncio.create_task(self._auto_restart(-1))
79
+ return
80
+
81
+ self.status = "running"
82
+ self._start_time = time.monotonic()
83
+ self._log(f"[bot-master] Started (pid={self.process.pid})")
84
+
85
+ reader_task = asyncio.create_task(self._read_output())
86
+ waiter_task = asyncio.create_task(self._wait())
87
+ self._tasks = [reader_task, waiter_task]
88
+
89
+ async def _read_output(self) -> None:
90
+ assert self.process and self.process.stdout
91
+ try:
92
+ async for raw_line in self.process.stdout:
93
+ line = raw_line.decode("utf-8", errors="replace").rstrip("\n\r")
94
+ self._log(line)
95
+ except Exception:
96
+ pass
97
+
98
+ async def _wait(self) -> None:
99
+ assert self.process
100
+ code = await self.process.wait()
101
+ ran_for = time.monotonic() - self._start_time
102
+
103
+ if self._should_run:
104
+ self._log(f"[bot-master] Exited with code {code}")
105
+ if ran_for > 30:
106
+ self.restart_count = 0
107
+ self.status = "backoff"
108
+ asyncio.create_task(self._auto_restart(code))
109
+ else:
110
+ self.status = "stopped"
111
+ self._log(f"[bot-master] Stopped (exit code {code})")
112
+
113
+ async def _auto_restart(self, code: int) -> None:
114
+ delay = min(2 ** self.restart_count, 60)
115
+ self.restart_count += 1
116
+ self._log(f"[bot-master] Restarting in {delay}s (attempt {self.restart_count})...")
117
+ await asyncio.sleep(delay)
118
+ if self._should_run:
119
+ await self._spawn()
120
+
121
+ async def stop(self) -> None:
122
+ async with self._lock:
123
+ self._should_run = False
124
+ if self.process and self.process.returncode is None:
125
+ self._log("[bot-master] Stopping...")
126
+ try:
127
+ os.killpg(os.getpgid(self.process.pid), signal.SIGTERM)
128
+ except (ProcessLookupError, OSError):
129
+ pass
130
+ try:
131
+ await asyncio.wait_for(self.process.wait(), timeout=5)
132
+ except asyncio.TimeoutError:
133
+ self._log("[bot-master] Force killing...")
134
+ try:
135
+ os.killpg(os.getpgid(self.process.pid), signal.SIGKILL)
136
+ except (ProcessLookupError, OSError):
137
+ pass
138
+ try:
139
+ await asyncio.wait_for(self.process.wait(), timeout=2)
140
+ except asyncio.TimeoutError:
141
+ pass
142
+
143
+ for task in self._tasks:
144
+ task.cancel()
145
+ self._tasks.clear()
146
+ self.process = None
147
+ self.status = "stopped"
148
+
149
+ async def restart(self) -> None:
150
+ await self.stop()
151
+ await self.start()
152
+
153
+ def subscribe(self) -> asyncio.Queue:
154
+ queue: asyncio.Queue = asyncio.Queue(maxsize=1000)
155
+ self.subscribers.add(queue)
156
+ return queue
157
+
158
+ def unsubscribe(self, queue: asyncio.Queue) -> None:
159
+ self.subscribers.discard(queue)
160
+
161
+ def get_status(self) -> dict:
162
+ pid = self.process.pid if self.process and self.process.returncode is None else None
163
+ uptime = None
164
+ if self.status == "running" and self._start_time:
165
+ uptime = int(time.monotonic() - self._start_time)
166
+ return {
167
+ "name": self.config.name,
168
+ "status": self.status,
169
+ "pid": pid,
170
+ "restart_count": self.restart_count,
171
+ "uptime": uptime,
172
+ }
173
+
174
+ def get_logs(self, n: int = 200) -> list[str]:
175
+ lines = list(self.log_buffer)
176
+ return lines[-n:]
177
+
178
+ def _log(self, line: str) -> None:
179
+ ts = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
180
+ entry = f"{ts} {line}"
181
+ self.log_buffer.append(entry)
182
+ self._file_logger.info(entry)
183
+ for q in list(self.subscribers):
184
+ try:
185
+ q.put_nowait(entry)
186
+ except asyncio.QueueFull:
187
+ pass
188
+
189
+
190
+ class ProcessManager:
191
+ def __init__(self, log_dir: Path | None = None) -> None:
192
+ self.bots: dict[str, BotProcess] = {}
193
+ self.log_dir = log_dir or LOG_DIR
194
+
195
+ def load_config(self, path: Path) -> None:
196
+ with open(path) as f:
197
+ data = yaml.safe_load(f)
198
+
199
+ for name, info in data["bots"].items():
200
+ config = BotConfig(
201
+ name=name,
202
+ directory=info["directory"],
203
+ command=info["command"],
204
+ )
205
+ self.bots[name] = BotProcess(config, self.log_dir)
206
+
207
+ async def start_all(self) -> None:
208
+ await asyncio.gather(*(bot.start() for bot in self.bots.values()))
209
+
210
+ async def stop_all(self) -> None:
211
+ await asyncio.gather(*(bot.stop() for bot in self.bots.values()))
212
+
213
+ def get_bot(self, name: str) -> BotProcess:
214
+ return self.bots[name]
215
+
216
+ def get_all_status(self) -> list[dict]:
217
+ return [bot.get_status() for bot in self.bots.values()]
bot_master/protocol.py ADDED
@@ -0,0 +1,23 @@
1
+ import asyncio
2
+ import json
3
+ import os
4
+ from pathlib import Path
5
+
6
+ SOCKET_PATH = Path(os.environ.get("BOT_MASTER_SOCK", "/tmp/bot-master.sock"))
7
+ CONFIG_PATH = Path(os.environ.get("BOT_MASTER_CONFIG", "bots.yaml"))
8
+
9
+
10
+ async def read_message(reader: asyncio.StreamReader) -> dict | None:
11
+ try:
12
+ line = await reader.readline()
13
+ if not line:
14
+ return None
15
+ return json.loads(line.decode("utf-8"))
16
+ except (json.JSONDecodeError, ConnectionError):
17
+ return None
18
+
19
+
20
+ async def write_message(writer: asyncio.StreamWriter, msg: dict) -> None:
21
+ data = json.dumps(msg) + "\n"
22
+ writer.write(data.encode("utf-8"))
23
+ await writer.drain()
@@ -0,0 +1,6 @@
1
+ Metadata-Version: 2.4
2
+ Name: bot-master
3
+ Version: 0.1.0
4
+ Requires-Python: >=3.12
5
+ Requires-Dist: pyyaml>=6.0
6
+ Requires-Dist: textual>=3.0
@@ -0,0 +1,10 @@
1
+ bot_master/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
2
+ bot_master/app.py,sha256=B0DFmQlUkudDK26T1ls2yTmC7LbRs1QDJ9pCCKmkAEA,9797
3
+ bot_master/app.tcss,sha256=D8Eq3TzUq0Hz2rU_0Nz05ywbgZq2Lh5kqb0UJWk0SrM,607
4
+ bot_master/daemon.py,sha256=M9iVFihPkaJR_GTr-JTBEpiKLHLoydoJPBgaLBktAdk,6330
5
+ bot_master/process_manager.py,sha256=CQdIKBli-zI6Cb3pp_vOY-ecL28lNxozEv3HFIua4T8,7375
6
+ bot_master/protocol.py,sha256=163sy_Eul95Lx2oEEmL6vZLcgJ6feUo61A4etU5r4Cc,668
7
+ bot_master-0.1.0.dist-info/METADATA,sha256=JIrMWhUZFeQWGXpitrj3l0O3cykf8kE1dJkOjuQgQaw,133
8
+ bot_master-0.1.0.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
9
+ bot_master-0.1.0.dist-info/entry_points.txt,sha256=83fr9qU5B5jsjrEiisvGmaB6D28gqlYNfUQCSwr1Ugg,94
10
+ bot_master-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.29.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,3 @@
1
+ [console_scripts]
2
+ bot-master = bot_master.app:main
3
+ bot-master-daemon = bot_master.daemon:main