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 +0 -0
- bot_master/app.py +284 -0
- bot_master/app.tcss +53 -0
- bot_master/daemon.py +176 -0
- bot_master/process_manager.py +217 -0
- bot_master/protocol.py +23 -0
- bot_master-0.1.0.dist-info/METADATA +6 -0
- bot_master-0.1.0.dist-info/RECORD +10 -0
- bot_master-0.1.0.dist-info/WHEEL +4 -0
- bot_master-0.1.0.dist-info/entry_points.txt +3 -0
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,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,,
|