bot-master 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.
@@ -0,0 +1,4 @@
1
+ logs/
2
+ .venv/
3
+ __pycache__/
4
+ *.egg-info/
@@ -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,81 @@
1
+ # Bot Master
2
+
3
+ A process manager for Telegram bots with a terminal UI. Runs as a background daemon (survives reboots via systemd) with a Textual TUI client for monitoring.
4
+
5
+ ## Architecture
6
+
7
+ - **Daemon** (`bot-master-daemon`) — manages bot subprocesses, auto-restarts on crash (exponential backoff), buffers logs in memory and writes to disk. Communicates via Unix socket.
8
+ - **TUI Client** (`bot-master`) — connects to the daemon to view live status, stream logs, and send start/stop/restart commands. If the TUI crashes, bots keep running.
9
+
10
+ ## Setup
11
+
12
+ ```bash
13
+ cd ~/bots/bot-master
14
+ uv sync
15
+ ```
16
+
17
+ ## Configuration
18
+
19
+ Edit `bots.yaml`:
20
+
21
+ ```yaml
22
+ bots:
23
+ my-bot:
24
+ directory: /path/to/bot
25
+ command: uv run python main.py
26
+ ```
27
+
28
+ ## Usage
29
+
30
+ ### Start the daemon manually
31
+
32
+ ```bash
33
+ uv run bot-master-daemon
34
+ ```
35
+
36
+ ### Install as a systemd service (auto-start on boot, auto-restart on failure)
37
+
38
+ ```bash
39
+ ./install.sh
40
+ ```
41
+
42
+ This generates the systemd unit from the current directory, enables and starts the service.
43
+
44
+ ### Connect with the TUI
45
+
46
+ ```bash
47
+ uv run bot-master
48
+ ```
49
+
50
+ ### TUI Keybindings
51
+
52
+ | Key | Action |
53
+ |-----|--------|
54
+ | `s` | Start selected bot |
55
+ | `x` | Stop selected bot |
56
+ | `r` | Restart selected bot |
57
+ | `a` | Start all bots |
58
+ | `z` | Stop all bots |
59
+ | `j`/`k` or arrows | Navigate bot list |
60
+ | `q` | Quit TUI (daemon keeps running) |
61
+
62
+ ## Logs
63
+
64
+ Logs are stored in the `logs/` directory (one file per bot, 10 MB rotation with 5 backups):
65
+
66
+ ```
67
+ logs/
68
+ au-tomator.log
69
+ zapier.log
70
+ writing-assistant.log
71
+ ```
72
+
73
+ The daemon also keeps the last 5000 lines per bot in memory for fast streaming to the TUI.
74
+
75
+ ## Environment Variables
76
+
77
+ | Variable | Default | Description |
78
+ |----------|---------|-------------|
79
+ | `BOT_MASTER_SOCK` | `/tmp/bot-master.sock` | Unix socket path |
80
+ | `BOT_MASTER_CONFIG` | `bots.yaml` | Config file path |
81
+ | `BOT_MASTER_LOG_DIR` | `logs` | Log directory path |
File without changes
@@ -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()
@@ -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
+ }
@@ -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()