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.
- bot_master-0.1.0/.gitignore +4 -0
- bot_master-0.1.0/PKG-INFO +6 -0
- bot_master-0.1.0/README.md +81 -0
- bot_master-0.1.0/bot_master/__init__.py +0 -0
- bot_master-0.1.0/bot_master/app.py +284 -0
- bot_master-0.1.0/bot_master/app.tcss +53 -0
- bot_master-0.1.0/bot_master/daemon.py +176 -0
- bot_master-0.1.0/bot_master/process_manager.py +217 -0
- bot_master-0.1.0/bot_master/protocol.py +23 -0
- bot_master-0.1.0/bots.yaml +12 -0
- bot_master-0.1.0/install.sh +46 -0
- bot_master-0.1.0/pyproject.toml +21 -0
- bot_master-0.1.0/uv.lock +749 -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()
|