telegram-bot-control 0.5.1__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.
- telegram_bot_control/__init__.py +22 -0
- telegram_bot_control/__main__.py +6 -0
- telegram_bot_control/bridge.py +204 -0
- telegram_bot_control/cli.py +141 -0
- telegram_bot_control/config.py +85 -0
- telegram_bot_control/launch_output.py +94 -0
- telegram_bot_control/monitor.py +415 -0
- telegram_bot_control/privacy.py +37 -0
- telegram_bot_control/processes.py +621 -0
- telegram_bot_control/projects.py +140 -0
- telegram_bot_control/runner.py +194 -0
- telegram_bot_control/store.py +515 -0
- telegram_bot_control/telemetry.py +80 -0
- telegram_bot_control/testing.py +133 -0
- telegram_bot_control/web.py +303 -0
- telegram_bot_control/web_assets/INTER-LICENSE.txt +93 -0
- telegram_bot_control/web_assets/WEB-LIBRARY-LICENSES.txt +67 -0
- telegram_bot_control/web_assets/assets/index--AZnpYML.css +1 -0
- telegram_bot_control/web_assets/assets/index-daaqjWEj.js +269 -0
- telegram_bot_control/web_assets/assets/inter-cyrillic-wght-normal-DqGufNeO.woff2 +0 -0
- telegram_bot_control/web_assets/assets/inter-latin-wght-normal-Dx4kXJAl.woff2 +0 -0
- telegram_bot_control/web_assets/index.html +17 -0
- telegram_bot_control/web_manager.py +80 -0
- telegram_bot_control-0.5.1.dist-info/METADATA +121 -0
- telegram_bot_control-0.5.1.dist-info/RECORD +29 -0
- telegram_bot_control-0.5.1.dist-info/WHEEL +5 -0
- telegram_bot_control-0.5.1.dist-info/entry_points.txt +2 -0
- telegram_bot_control-0.5.1.dist-info/licenses/LICENSE +21 -0
- telegram_bot_control-0.5.1.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
"""Embedded telemetry for aiogram. Importing this package performs no I/O."""
|
|
2
|
+
|
|
3
|
+
__version__ = "0.5.1"
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def start(**kwargs):
|
|
7
|
+
"""Lazily import aiogram integration, keeping the stdio reader lightweight."""
|
|
8
|
+
from .monitor import start as attach
|
|
9
|
+
|
|
10
|
+
# Keep caller location: the wrapper must not become the detected entrypoint.
|
|
11
|
+
if kwargs.get("entrypoint") is None:
|
|
12
|
+
import inspect
|
|
13
|
+
|
|
14
|
+
frame = inspect.currentframe()
|
|
15
|
+
try:
|
|
16
|
+
filename = frame.f_back.f_globals.get("__file__") if frame and frame.f_back else None
|
|
17
|
+
if not filename:
|
|
18
|
+
raise ValueError("Set entrypoint explicitly when running interactively")
|
|
19
|
+
kwargs["entrypoint"] = filename
|
|
20
|
+
finally:
|
|
21
|
+
del frame
|
|
22
|
+
return attach(**kwargs)
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
"""A child process speaking JSON lines over stdin/stdout. Never opens a socket."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import json
|
|
7
|
+
import sqlite3
|
|
8
|
+
import sys
|
|
9
|
+
import time
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
from . import __version__
|
|
14
|
+
from .config import data_directory, load_settings, save_settings, validate_settings
|
|
15
|
+
from .store import cleanup_connection, mailbox_files, read_snapshot, reader, read_logs, table_exists
|
|
16
|
+
from .processes import ProcessManager
|
|
17
|
+
|
|
18
|
+
MAX_LINE = 1024 * 1024
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class Bridge:
|
|
22
|
+
def __init__(self, directory: Path):
|
|
23
|
+
self.directory = directory
|
|
24
|
+
self._last_cleanup = 0.0
|
|
25
|
+
self.processes = ProcessManager(directory)
|
|
26
|
+
self.web_manager = None
|
|
27
|
+
|
|
28
|
+
def close(self):
|
|
29
|
+
if self.web_manager:
|
|
30
|
+
self.web_manager.stop()
|
|
31
|
+
|
|
32
|
+
def preview(self, settings: dict[str, Any]) -> dict[str, int]:
|
|
33
|
+
result = {"events": 0, "errors": 0, "diagnostics": 0, "logs": 0, "resources": 0}
|
|
34
|
+
for path in mailbox_files(self.directory):
|
|
35
|
+
connection = None
|
|
36
|
+
try:
|
|
37
|
+
connection = reader(path)
|
|
38
|
+
for table, key in (
|
|
39
|
+
("events", "statistics_days"),
|
|
40
|
+
("errors", "errors_days"),
|
|
41
|
+
("diagnostics", "diagnostics_days"),
|
|
42
|
+
("logs", "logs_days"),
|
|
43
|
+
("resources", "resources_days"),
|
|
44
|
+
):
|
|
45
|
+
if settings[key] is not None and table_exists(connection, table):
|
|
46
|
+
result[table] += connection.execute(
|
|
47
|
+
f"SELECT COUNT(*) FROM {table} WHERE ts<?",
|
|
48
|
+
(time.time() - settings[key] * 86400,),
|
|
49
|
+
).fetchone()[0]
|
|
50
|
+
except (sqlite3.Error, ValueError):
|
|
51
|
+
continue
|
|
52
|
+
finally:
|
|
53
|
+
if connection:
|
|
54
|
+
connection.close()
|
|
55
|
+
return result
|
|
56
|
+
|
|
57
|
+
def cleanup(self, settings: dict[str, Any]) -> dict[str, int]:
|
|
58
|
+
result = {"events": 0, "errors": 0, "diagnostics": 0, "logs": 0, "resources": 0}
|
|
59
|
+
for path in mailbox_files(self.directory):
|
|
60
|
+
connection = None
|
|
61
|
+
try:
|
|
62
|
+
connection = sqlite3.connect(path, timeout=0.3)
|
|
63
|
+
deleted = cleanup_connection(connection, settings)
|
|
64
|
+
for key in result:
|
|
65
|
+
result[key] += deleted[key]
|
|
66
|
+
except sqlite3.Error:
|
|
67
|
+
# Another bot may be flushing a batch; retry during the next sweep.
|
|
68
|
+
continue
|
|
69
|
+
finally:
|
|
70
|
+
if connection:
|
|
71
|
+
connection.close()
|
|
72
|
+
self._last_cleanup = time.monotonic()
|
|
73
|
+
return result
|
|
74
|
+
|
|
75
|
+
def call(self, method: str, params: dict[str, Any]) -> Any:
|
|
76
|
+
if method == "logs":
|
|
77
|
+
return read_logs(self.directory, params)
|
|
78
|
+
if method == "inspect_project":
|
|
79
|
+
from .projects import inspect_project, integration_preview
|
|
80
|
+
|
|
81
|
+
project = inspect_project(str(params["directory"]))
|
|
82
|
+
entrypoint = params.get("entrypoint") or next(iter(project["entrypoints"]), "")
|
|
83
|
+
if entrypoint:
|
|
84
|
+
project["integration"] = integration_preview(
|
|
85
|
+
Path(project["directory"]), str(entrypoint), self.directory
|
|
86
|
+
)
|
|
87
|
+
return project
|
|
88
|
+
if method == "register_project":
|
|
89
|
+
from .projects import register_project
|
|
90
|
+
|
|
91
|
+
return register_project(self.directory, params)
|
|
92
|
+
if method in ("web_status", "web_start", "web_stop"):
|
|
93
|
+
from .web_manager import WebManager
|
|
94
|
+
|
|
95
|
+
if self.web_manager is None:
|
|
96
|
+
self.web_manager = WebManager(self.directory)
|
|
97
|
+
if method == "web_start":
|
|
98
|
+
return self.web_manager.start(
|
|
99
|
+
params.get("host", "127.0.0.1"), params.get("port", 8765)
|
|
100
|
+
)
|
|
101
|
+
if method == "web_stop":
|
|
102
|
+
return self.web_manager.stop()
|
|
103
|
+
return self.web_manager.status()
|
|
104
|
+
if method == "hello":
|
|
105
|
+
return {"version": __version__, "transport": "stdio", "data_dir": str(self.directory)}
|
|
106
|
+
if method == "snapshot":
|
|
107
|
+
if time.monotonic() - self._last_cleanup >= 60:
|
|
108
|
+
self.cleanup(load_settings(self.directory))
|
|
109
|
+
snapshot = read_snapshot(self.directory, params)
|
|
110
|
+
for bot in snapshot["bots"]:
|
|
111
|
+
try:
|
|
112
|
+
bot["control"] = self.processes.status(bot["bot_id"])
|
|
113
|
+
control = bot["control"]
|
|
114
|
+
if (
|
|
115
|
+
control["run"]
|
|
116
|
+
and control["run"]["status"]
|
|
117
|
+
in ("starting", "preparing", "running", "backoff")
|
|
118
|
+
and not control["verified_sessions"]
|
|
119
|
+
):
|
|
120
|
+
bot["state"] = "waiting"
|
|
121
|
+
if bot.get("integration_pending"):
|
|
122
|
+
bot["state"] = "waiting"
|
|
123
|
+
except (sqlite3.Error, ValueError, OSError) as error:
|
|
124
|
+
bot["control"] = {
|
|
125
|
+
"can_start": False,
|
|
126
|
+
"can_stop": False,
|
|
127
|
+
"reason": str(error)[:150],
|
|
128
|
+
"run": None,
|
|
129
|
+
}
|
|
130
|
+
return snapshot
|
|
131
|
+
if method == "launch_profile":
|
|
132
|
+
return self.processes.profile(str(params["bot_id"]))
|
|
133
|
+
if method == "save_launch_profile":
|
|
134
|
+
return self.processes.save_profile(str(params["bot_id"]), params["profile"])
|
|
135
|
+
if method == "control_bot":
|
|
136
|
+
return self.processes.control(
|
|
137
|
+
str(params["bot_id"]), params["action"], force=params.get("force", False)
|
|
138
|
+
)
|
|
139
|
+
if method == "preview_retention":
|
|
140
|
+
return self.preview(validate_settings(params["settings"]))
|
|
141
|
+
if method == "save_settings":
|
|
142
|
+
settings = validate_settings(params["settings"])
|
|
143
|
+
current = load_settings(self.directory)
|
|
144
|
+
reducing = any(
|
|
145
|
+
settings[key] is not None and (current[key] is None or settings[key] < current[key])
|
|
146
|
+
for key in (
|
|
147
|
+
"statistics_days",
|
|
148
|
+
"errors_days",
|
|
149
|
+
"diagnostics_days",
|
|
150
|
+
"logs_days",
|
|
151
|
+
"resources_days",
|
|
152
|
+
)
|
|
153
|
+
)
|
|
154
|
+
if reducing and params.get("confirm_cleanup") is not True:
|
|
155
|
+
raise ValueError("Reducing retention requires confirm_cleanup")
|
|
156
|
+
save_settings(self.directory, settings)
|
|
157
|
+
return {"settings": settings, "deleted": self.cleanup(settings)}
|
|
158
|
+
raise ValueError("Unknown method")
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def serve(directory: Path) -> int:
|
|
162
|
+
if hasattr(sys.stdout, "reconfigure"):
|
|
163
|
+
sys.stdout.reconfigure(encoding="utf-8")
|
|
164
|
+
sys.stdin.reconfigure(encoding="utf-8")
|
|
165
|
+
bridge = Bridge(directory)
|
|
166
|
+
while True:
|
|
167
|
+
line = sys.stdin.readline(MAX_LINE + 1)
|
|
168
|
+
if not line:
|
|
169
|
+
bridge.close()
|
|
170
|
+
return 0
|
|
171
|
+
request_id = None
|
|
172
|
+
try:
|
|
173
|
+
if len(line) > MAX_LINE:
|
|
174
|
+
raise ValueError("Request too large")
|
|
175
|
+
request = json.loads(line)
|
|
176
|
+
if not isinstance(request, dict):
|
|
177
|
+
raise ValueError("Expected an object")
|
|
178
|
+
request_id = request.get("id")
|
|
179
|
+
params = request.get("params", {})
|
|
180
|
+
if not isinstance(params, dict):
|
|
181
|
+
raise ValueError("Expected object params")
|
|
182
|
+
result = bridge.call(request["method"], params)
|
|
183
|
+
response = {"id": request_id, "result": result}
|
|
184
|
+
except Exception as error:
|
|
185
|
+
from .privacy import redact
|
|
186
|
+
|
|
187
|
+
response = {
|
|
188
|
+
"id": request_id,
|
|
189
|
+
"error": {"type": type(error).__name__, "message": redact(str(error))[:500]},
|
|
190
|
+
}
|
|
191
|
+
sys.stdout.write(json.dumps(response, ensure_ascii=False, allow_nan=False) + "\n")
|
|
192
|
+
sys.stdout.flush()
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def main(argv: list[str] | None = None) -> int:
|
|
196
|
+
parser = argparse.ArgumentParser(description="Bot Control stdio bridge (no listening port)")
|
|
197
|
+
parser.add_argument("--stdio", action="store_true", required=True)
|
|
198
|
+
parser.add_argument("--data-dir")
|
|
199
|
+
args = parser.parse_args(argv)
|
|
200
|
+
return serve(data_directory(args.data_dir))
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
if __name__ == "__main__":
|
|
204
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
"""Command-line launchers for the desktop, stdio bridge and offline aiogram bot."""
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import asyncio
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
import shutil
|
|
8
|
+
import subprocess
|
|
9
|
+
import sys
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
from . import __version__
|
|
13
|
+
from .config import data_directory
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def launch_desktop(directory: Path, *, force_local: bool = False) -> int:
|
|
17
|
+
root = Path(__file__).resolve().parents[2]
|
|
18
|
+
desktop = root / "desktop"
|
|
19
|
+
override = os.environ.get("BOTCONTROL_DESKTOP")
|
|
20
|
+
installed = override or shutil.which("telegram-bot-control-desktop")
|
|
21
|
+
if installed:
|
|
22
|
+
command = [installed]
|
|
23
|
+
else:
|
|
24
|
+
binary = (
|
|
25
|
+
desktop
|
|
26
|
+
/ "node_modules/electron/dist"
|
|
27
|
+
/ ("electron.exe" if os.name == "nt" else "electron")
|
|
28
|
+
)
|
|
29
|
+
if not binary.exists() or not (desktop / "dist/index.html").exists():
|
|
30
|
+
print(
|
|
31
|
+
"Install/build the desktop first: pnpm install --dir desktop; pnpm --dir desktop run build",
|
|
32
|
+
file=sys.stderr,
|
|
33
|
+
)
|
|
34
|
+
print(
|
|
35
|
+
"Or set BOTCONTROL_DESKTOP to the installed application executable.",
|
|
36
|
+
file=sys.stderr,
|
|
37
|
+
)
|
|
38
|
+
return 1
|
|
39
|
+
command = [str(binary), str(desktop)]
|
|
40
|
+
env = dict(
|
|
41
|
+
os.environ, BOTCONTROL_PYTHON=sys.executable, BOTCONTROL_HOME=str(directory), PYTHONUTF8="1"
|
|
42
|
+
)
|
|
43
|
+
if force_local:
|
|
44
|
+
env["BOTCONTROL_FORCE_LOCAL"] = "1"
|
|
45
|
+
flags = subprocess.CREATE_NO_WINDOW if os.name == "nt" else 0
|
|
46
|
+
subprocess.Popen(
|
|
47
|
+
command,
|
|
48
|
+
env=env,
|
|
49
|
+
stdin=subprocess.DEVNULL,
|
|
50
|
+
stdout=subprocess.DEVNULL,
|
|
51
|
+
stderr=subprocess.DEVNULL,
|
|
52
|
+
creationflags=flags,
|
|
53
|
+
start_new_session=os.name != "nt",
|
|
54
|
+
)
|
|
55
|
+
print("Telegram Bot Control opened")
|
|
56
|
+
return 0
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def main(argv: list[str] | None = None) -> int:
|
|
60
|
+
if hasattr(sys.stdout, "reconfigure"):
|
|
61
|
+
sys.stdout.reconfigure(encoding="utf-8")
|
|
62
|
+
parser = argparse.ArgumentParser(
|
|
63
|
+
prog="tgbots", description="Telegram Bot Control — embedded telemetry, no local server."
|
|
64
|
+
)
|
|
65
|
+
parser.add_argument("--version", action="version", version=f"%(prog)s {__version__}")
|
|
66
|
+
sub = parser.add_subparsers(dest="command")
|
|
67
|
+
for name, help_text in (
|
|
68
|
+
("open", "Open the desktop application"),
|
|
69
|
+
("status", "Read recorded bot status"),
|
|
70
|
+
("bridge", "Serve JSON lines on stdin/stdout"),
|
|
71
|
+
("demo", "Run an offline aiogram fixture"),
|
|
72
|
+
("web", "Open the optional authenticated web dashboard"),
|
|
73
|
+
):
|
|
74
|
+
child = sub.add_parser(name, help=help_text)
|
|
75
|
+
child.add_argument("--data-dir", help="Shared local directory for this owner's bots")
|
|
76
|
+
if name == "web":
|
|
77
|
+
child.add_argument("--port", type=int, default=8765)
|
|
78
|
+
child.add_argument("--host", choices=("127.0.0.1", "0.0.0.0"), default="127.0.0.1")
|
|
79
|
+
if name == "status":
|
|
80
|
+
child.add_argument("--json", action="store_true")
|
|
81
|
+
if name == "demo":
|
|
82
|
+
child.add_argument(
|
|
83
|
+
"--duration", type=float, default=0, help="Seconds; 0 runs until Ctrl+C"
|
|
84
|
+
)
|
|
85
|
+
child.add_argument("--interval", type=float, default=0.8)
|
|
86
|
+
child.add_argument("--bots", type=int, default=1)
|
|
87
|
+
args = parser.parse_args(argv)
|
|
88
|
+
if args.command is None:
|
|
89
|
+
parser.print_help()
|
|
90
|
+
return 0
|
|
91
|
+
directory = data_directory(args.data_dir)
|
|
92
|
+
if args.command == "web":
|
|
93
|
+
from .web import run
|
|
94
|
+
|
|
95
|
+
if not 1 <= args.port <= 65535:
|
|
96
|
+
parser.error("port must be between 1 and 65535")
|
|
97
|
+
return run(directory, args.host, args.port)
|
|
98
|
+
if args.command == "open":
|
|
99
|
+
return launch_desktop(directory, force_local=args.data_dir is not None)
|
|
100
|
+
if args.command == "bridge":
|
|
101
|
+
from .bridge import serve
|
|
102
|
+
|
|
103
|
+
return serve(directory)
|
|
104
|
+
if args.command == "status":
|
|
105
|
+
from .store import read_snapshot
|
|
106
|
+
|
|
107
|
+
snapshot = read_snapshot(directory, {})
|
|
108
|
+
if args.json:
|
|
109
|
+
print(json.dumps(snapshot, ensure_ascii=False))
|
|
110
|
+
else:
|
|
111
|
+
print(f"Data: {directory}")
|
|
112
|
+
for bot in snapshot["bots"]:
|
|
113
|
+
print(
|
|
114
|
+
f"{bot['bot_id']:>12} {bot['state']:<14} {bot['name']} events={bot['metrics']['events']} errors={bot['metrics']['errors']}"
|
|
115
|
+
)
|
|
116
|
+
if not snapshot["bots"]:
|
|
117
|
+
print("No connected bots. Try: tgbots demo --data-dir <directory>")
|
|
118
|
+
return 0
|
|
119
|
+
if args.command == "demo":
|
|
120
|
+
if not 1 <= args.bots <= 1000 or args.interval <= 0 or args.duration < 0:
|
|
121
|
+
parser.error("bots: 1–1000, interval > 0, duration >= 0")
|
|
122
|
+
from .testing import run_offline
|
|
123
|
+
|
|
124
|
+
async def run() -> None:
|
|
125
|
+
await asyncio.gather(
|
|
126
|
+
*(
|
|
127
|
+
run_offline(
|
|
128
|
+
directory,
|
|
129
|
+
duration=args.duration,
|
|
130
|
+
interval=args.interval,
|
|
131
|
+
bot_id=900000001 + index,
|
|
132
|
+
)
|
|
133
|
+
for index in range(args.bots)
|
|
134
|
+
)
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
try:
|
|
138
|
+
asyncio.run(run())
|
|
139
|
+
except KeyboardInterrupt:
|
|
140
|
+
pass
|
|
141
|
+
return 0
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
"""Shared on-disk configuration. No ports, services or registration server."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
import tempfile
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
DEFAULTS: dict[str, Any] = {
|
|
12
|
+
"statistics_days": 365,
|
|
13
|
+
"errors_days": 30,
|
|
14
|
+
"diagnostics_days": 7,
|
|
15
|
+
"logs_days": 7,
|
|
16
|
+
"resources_days": 7,
|
|
17
|
+
"resource_interval_seconds": 5,
|
|
18
|
+
"heartbeat_seconds": 10,
|
|
19
|
+
"connection_timeout_seconds": 30,
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def data_directory(value: str | Path | None = None) -> Path:
|
|
24
|
+
if value:
|
|
25
|
+
return Path(value).expanduser().resolve()
|
|
26
|
+
if os.environ.get("BOTCONTROL_HOME"):
|
|
27
|
+
return Path(os.environ["BOTCONTROL_HOME"]).expanduser().resolve()
|
|
28
|
+
if os.name == "nt":
|
|
29
|
+
base = Path(os.environ.get("LOCALAPPDATA", Path.home() / "AppData/Local"))
|
|
30
|
+
return base / "TelegramBotControl"
|
|
31
|
+
return (
|
|
32
|
+
Path(os.environ.get("XDG_STATE_HOME", Path.home() / ".local/state"))
|
|
33
|
+
/ "telegram-bot-control"
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def ensure_private_directory(path: Path) -> None:
|
|
38
|
+
path.mkdir(parents=True, exist_ok=True, mode=0o700)
|
|
39
|
+
if os.name != "nt":
|
|
40
|
+
path.chmod(0o700)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def validate_settings(settings: dict[str, Any]) -> dict[str, Any]:
|
|
44
|
+
if not isinstance(settings, dict) or set(settings) - set(DEFAULTS):
|
|
45
|
+
raise ValueError("Unknown settings")
|
|
46
|
+
result = DEFAULTS | settings
|
|
47
|
+
for key in (
|
|
48
|
+
"statistics_days",
|
|
49
|
+
"errors_days",
|
|
50
|
+
"diagnostics_days",
|
|
51
|
+
"logs_days",
|
|
52
|
+
"resources_days",
|
|
53
|
+
):
|
|
54
|
+
value = result[key]
|
|
55
|
+
if value is not None and (type(value) is not int or not 1 <= value <= 36500):
|
|
56
|
+
raise ValueError(f"{key}: expected 1–36500 or null")
|
|
57
|
+
for key in ("heartbeat_seconds", "connection_timeout_seconds", "resource_interval_seconds"):
|
|
58
|
+
if type(result[key]) is not int or not 1 <= result[key] <= 86400:
|
|
59
|
+
raise ValueError(f"{key}: expected integer 1–86400")
|
|
60
|
+
if result["connection_timeout_seconds"] < result["heartbeat_seconds"] * 3:
|
|
61
|
+
raise ValueError("connection_timeout_seconds must be at least 3 × heartbeat_seconds")
|
|
62
|
+
return result
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def load_settings(directory: Path) -> dict[str, Any]:
|
|
66
|
+
path = directory / "settings.json"
|
|
67
|
+
if not path.exists():
|
|
68
|
+
return dict(DEFAULTS)
|
|
69
|
+
return validate_settings(json.loads(path.read_text(encoding="utf-8")))
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def save_settings(directory: Path, settings: dict[str, Any]) -> dict[str, Any]:
|
|
73
|
+
validated = validate_settings(settings)
|
|
74
|
+
ensure_private_directory(directory)
|
|
75
|
+
# Atomic replacement: running bots always see a complete configuration.
|
|
76
|
+
fd, temporary = tempfile.mkstemp(prefix="settings-", suffix=".tmp", dir=directory)
|
|
77
|
+
try:
|
|
78
|
+
with os.fdopen(fd, "w", encoding="utf-8") as stream:
|
|
79
|
+
json.dump(validated, stream, ensure_ascii=False, indent=2)
|
|
80
|
+
stream.flush()
|
|
81
|
+
os.fsync(stream.fileno())
|
|
82
|
+
os.replace(temporary, directory / "settings.json")
|
|
83
|
+
finally:
|
|
84
|
+
Path(temporary).unlink(missing_ok=True)
|
|
85
|
+
return validated
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
"""Bounded, redacted stdout/stderr capture, including failures before SDK startup."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import codecs
|
|
6
|
+
import queue
|
|
7
|
+
import re
|
|
8
|
+
import subprocess
|
|
9
|
+
import threading
|
|
10
|
+
|
|
11
|
+
from .privacy import redact
|
|
12
|
+
|
|
13
|
+
MAX_LINE = 8192
|
|
14
|
+
MAX_OUTPUT = 32768
|
|
15
|
+
ANSI = re.compile(r"\x1b\[[0-?]*[ -/]*[@-~]")
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class LaunchOutput:
|
|
19
|
+
def __init__(self, child: subprocess.Popen, secrets: list[str], phase: str):
|
|
20
|
+
self.lines: queue.Queue[str] = queue.Queue(maxsize=128)
|
|
21
|
+
self.dropped = 0
|
|
22
|
+
self.phase = phase
|
|
23
|
+
self.secrets = secrets
|
|
24
|
+
self.streams = [child.stdout, child.stderr]
|
|
25
|
+
self.threads = [
|
|
26
|
+
threading.Thread(target=self._read, args=(stream, name), daemon=True)
|
|
27
|
+
for stream, name in zip(self.streams, ("stdout", "stderr"))
|
|
28
|
+
]
|
|
29
|
+
for thread in self.threads:
|
|
30
|
+
thread.start()
|
|
31
|
+
|
|
32
|
+
def _offer(self, text: str, source: str) -> None:
|
|
33
|
+
text = redact(ANSI.sub("", text), self.secrets).replace("\0", "")
|
|
34
|
+
try:
|
|
35
|
+
self.lines.put_nowait(f"[{self.phase} / {source}] {text}\n")
|
|
36
|
+
except queue.Full:
|
|
37
|
+
self.dropped += 1
|
|
38
|
+
|
|
39
|
+
def _read(self, stream, source: str) -> None:
|
|
40
|
+
decoder = codecs.getincrementaldecoder("utf-8")("replace")
|
|
41
|
+
pending, discarding = "", False
|
|
42
|
+
try:
|
|
43
|
+
while True:
|
|
44
|
+
block = stream.read1(4096)
|
|
45
|
+
pending += decoder.decode(block, final=not block)
|
|
46
|
+
while "\n" in pending:
|
|
47
|
+
line, _, pending = pending.partition("\n")
|
|
48
|
+
if not discarding:
|
|
49
|
+
self._offer(
|
|
50
|
+
line.rstrip("\r")
|
|
51
|
+
if len(line) <= MAX_LINE
|
|
52
|
+
else "[long output line omitted]",
|
|
53
|
+
source,
|
|
54
|
+
)
|
|
55
|
+
discarding = False
|
|
56
|
+
if len(pending) > MAX_LINE:
|
|
57
|
+
if not discarding:
|
|
58
|
+
self._offer("[long output line omitted]", source)
|
|
59
|
+
pending, discarding = "", True
|
|
60
|
+
if not block:
|
|
61
|
+
if pending and not discarding:
|
|
62
|
+
self._offer(pending.rstrip("\r"), source)
|
|
63
|
+
return
|
|
64
|
+
except (OSError, ValueError):
|
|
65
|
+
return
|
|
66
|
+
finally:
|
|
67
|
+
stream.close()
|
|
68
|
+
|
|
69
|
+
def drain(self, connection, launch_id: str) -> None:
|
|
70
|
+
batch = []
|
|
71
|
+
for _ in range(128):
|
|
72
|
+
try:
|
|
73
|
+
batch.append(self.lines.get_nowait())
|
|
74
|
+
except queue.Empty:
|
|
75
|
+
break
|
|
76
|
+
if self.dropped:
|
|
77
|
+
batch.append(f"[capture] {self.dropped} output lines omitted: queue full\n")
|
|
78
|
+
self.dropped = 0
|
|
79
|
+
if batch:
|
|
80
|
+
with connection:
|
|
81
|
+
connection.execute(
|
|
82
|
+
"INSERT INTO run_output(id,output) VALUES (?,?) ON CONFLICT(id) "
|
|
83
|
+
"DO UPDATE SET output=substr(run_output.output || excluded.output,-?)",
|
|
84
|
+
(launch_id, "".join(batch)[-MAX_OUTPUT:], MAX_OUTPUT),
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
def finish(self, connection, launch_id: str) -> None:
|
|
88
|
+
# A background descendant may still hold a pipe; never wait indefinitely.
|
|
89
|
+
for thread in self.threads:
|
|
90
|
+
thread.join(timeout=0.3)
|
|
91
|
+
for _ in range(3):
|
|
92
|
+
self.drain(connection, launch_id)
|
|
93
|
+
if self.lines.empty():
|
|
94
|
+
break
|