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.
@@ -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,6 @@
1
+ """Allow launching the CLI with python -m telegram_bot_control."""
2
+
3
+ from .cli import main
4
+
5
+ if __name__ == "__main__":
6
+ raise SystemExit(main())
@@ -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