remote-coder 0.4.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.
- app/__init__.py +3 -0
- app/admin/__init__.py +0 -0
- app/admin/advanced_settings.py +88 -0
- app/admin/database_browser.py +301 -0
- app/admin/router.py +528 -0
- app/admin/static/i18n.js +401 -0
- app/admin/static/icons/advanced.svg +8 -0
- app/admin/static/icons/database.svg +5 -0
- app/admin/static/icons/download.svg +3 -0
- app/admin/static/icons/home.svg +4 -0
- app/admin/static/icons/logs.svg +3 -0
- app/admin/static/icons/projects.svg +5 -0
- app/admin/static/summary.js +73 -0
- app/admin/templates/admin.html +511 -0
- app/admin/templates/advanced.html +635 -0
- app/admin/templates/database.html +880 -0
- app/admin/templates/logs.html +686 -0
- app/admin/templates/projects.html +878 -0
- app/ai/__init__.py +0 -0
- app/ai/base.py +129 -0
- app/ai/claude.py +20 -0
- app/ai/codex.py +34 -0
- app/ai/factory.py +27 -0
- app/ai/gemini.py +20 -0
- app/ai/model_catalog.py +47 -0
- app/ai/usage.py +134 -0
- app/cli.py +238 -0
- app/config.py +130 -0
- app/git/__init__.py +0 -0
- app/git/ai_commit.py +88 -0
- app/git/branch_naming.py +21 -0
- app/git/commit_message.py +279 -0
- app/git/service.py +669 -0
- app/jobs/__init__.py +0 -0
- app/jobs/manager.py +770 -0
- app/jobs/schemas.py +116 -0
- app/jobs/store.py +334 -0
- app/main.py +265 -0
- app/models.py +20 -0
- app/monitoring/__init__.py +10 -0
- app/monitoring/code.py +161 -0
- app/monitoring/events.py +33 -0
- app/monitoring/git.py +103 -0
- app/monitoring/log_buffer.py +245 -0
- app/monitoring/memory.py +19 -0
- app/monitoring/model.py +598 -0
- app/projects/__init__.py +19 -0
- app/projects/registry.py +384 -0
- app/security/__init__.py +0 -0
- app/security/auth.py +19 -0
- app/system_startup.py +34 -0
- app/telegram/__init__.py +0 -0
- app/telegram/bot_instances.py +67 -0
- app/telegram/commands/__init__.py +64 -0
- app/telegram/commands/base.py +222 -0
- app/telegram/commands/branch.py +366 -0
- app/telegram/commands/clear_stop.py +221 -0
- app/telegram/commands/fix.py +219 -0
- app/telegram/commands/model.py +93 -0
- app/telegram/commands/monitor.py +185 -0
- app/telegram/commands/registry.py +110 -0
- app/telegram/commands/status.py +243 -0
- app/telegram/commands/system.py +201 -0
- app/telegram/confirmations.py +36 -0
- app/telegram/conversation.py +789 -0
- app/telegram/i18n.py +742 -0
- app/telegram/model_preferences.py +53 -0
- app/telegram/notifier.py +387 -0
- app/telegram/parser.py +267 -0
- app/telegram/webhook.py +988 -0
- app/telegram/webhook_registration.py +172 -0
- app/tunnel.py +104 -0
- remote_coder-0.4.1.dist-info/METADATA +520 -0
- remote_coder-0.4.1.dist-info/RECORD +78 -0
- remote_coder-0.4.1.dist-info/WHEEL +5 -0
- remote_coder-0.4.1.dist-info/entry_points.txt +2 -0
- remote_coder-0.4.1.dist-info/licenses/LICENSE +201 -0
- remote_coder-0.4.1.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import TYPE_CHECKING
|
|
4
|
+
|
|
5
|
+
import httpx
|
|
6
|
+
|
|
7
|
+
from app.monitoring.events import EventLogger
|
|
8
|
+
from app.projects.registry import ProjectRecord, build_public_webhook_url
|
|
9
|
+
|
|
10
|
+
if TYPE_CHECKING:
|
|
11
|
+
from app.config import Settings
|
|
12
|
+
|
|
13
|
+
_webhooklog = EventLogger("app.telegram.webhook_registration", "telegram.webhook")
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def build_bot_command_payloads(
|
|
17
|
+
commands: list[dict[str, str]],
|
|
18
|
+
chat_ids: list[int] | None = None,
|
|
19
|
+
) -> list[dict[str, object]]:
|
|
20
|
+
if not commands:
|
|
21
|
+
return []
|
|
22
|
+
|
|
23
|
+
payloads: list[dict[str, object]] = [
|
|
24
|
+
{"commands": commands},
|
|
25
|
+
{
|
|
26
|
+
"commands": commands,
|
|
27
|
+
"scope": {"type": "all_private_chats"},
|
|
28
|
+
},
|
|
29
|
+
]
|
|
30
|
+
for chat_id in dict.fromkeys(chat_ids or []):
|
|
31
|
+
payloads.append(
|
|
32
|
+
{
|
|
33
|
+
"commands": commands,
|
|
34
|
+
"scope": {"type": "chat", "chat_id": chat_id},
|
|
35
|
+
}
|
|
36
|
+
)
|
|
37
|
+
return payloads
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class TelegramWebhookRegistrar:
|
|
41
|
+
def __init__(
|
|
42
|
+
self,
|
|
43
|
+
public_base_url: str,
|
|
44
|
+
timeout_seconds: float = 10.0,
|
|
45
|
+
bot_commands: list[dict[str, str]] | None = None,
|
|
46
|
+
) -> None:
|
|
47
|
+
self._public_base_url = public_base_url.strip().rstrip("/")
|
|
48
|
+
self._timeout_seconds = timeout_seconds
|
|
49
|
+
self._bot_commands = bot_commands or []
|
|
50
|
+
|
|
51
|
+
def set_bot_commands(self, bot_commands: list[dict[str, str]]) -> None:
|
|
52
|
+
self._bot_commands = bot_commands
|
|
53
|
+
|
|
54
|
+
def sync_project_commands(self, record: ProjectRecord) -> bool:
|
|
55
|
+
if not record.enabled or not self._bot_commands:
|
|
56
|
+
return False
|
|
57
|
+
token = record.bot_token.get_secret_value().strip()
|
|
58
|
+
return self._sync_bot_commands(record.name, token, record.allowed_chat_ids)
|
|
59
|
+
|
|
60
|
+
def sync_project(self, record: ProjectRecord) -> bool:
|
|
61
|
+
if not record.enabled:
|
|
62
|
+
return False
|
|
63
|
+
|
|
64
|
+
token = record.bot_token.get_secret_value().strip()
|
|
65
|
+
webhook_url = build_public_webhook_url(self._public_base_url, token)
|
|
66
|
+
payload: dict[str, object] = {
|
|
67
|
+
"url": webhook_url,
|
|
68
|
+
"drop_pending_updates": True,
|
|
69
|
+
}
|
|
70
|
+
secret = (
|
|
71
|
+
record.webhook_secret.get_secret_value().strip()
|
|
72
|
+
if record.webhook_secret
|
|
73
|
+
else ""
|
|
74
|
+
)
|
|
75
|
+
if secret:
|
|
76
|
+
payload["secret_token"] = secret
|
|
77
|
+
|
|
78
|
+
api_url = f"https://api.telegram.org/bot{token}/setWebhook"
|
|
79
|
+
try:
|
|
80
|
+
response = httpx.post(api_url, json=payload, timeout=self._timeout_seconds)
|
|
81
|
+
response.raise_for_status()
|
|
82
|
+
result = response.json()
|
|
83
|
+
except Exception as exc:
|
|
84
|
+
_webhooklog.warning(
|
|
85
|
+
"setWebhook request failed project=%s err=%s",
|
|
86
|
+
record.name,
|
|
87
|
+
exc,
|
|
88
|
+
project=record.name,
|
|
89
|
+
)
|
|
90
|
+
return False
|
|
91
|
+
|
|
92
|
+
if not result.get("ok"):
|
|
93
|
+
_webhooklog.warning(
|
|
94
|
+
"setWebhook rejected project=%s response=%s",
|
|
95
|
+
record.name,
|
|
96
|
+
result,
|
|
97
|
+
project=record.name,
|
|
98
|
+
)
|
|
99
|
+
return False
|
|
100
|
+
|
|
101
|
+
_webhooklog.info("setWebhook synced project=%s", record.name, project=record.name)
|
|
102
|
+
if self._bot_commands and not self._sync_bot_commands(record.name, token, record.allowed_chat_ids):
|
|
103
|
+
return False
|
|
104
|
+
return True
|
|
105
|
+
|
|
106
|
+
def _sync_bot_commands(self, project_name: str, token: str, chat_ids: list[int]) -> bool:
|
|
107
|
+
api_url = f"https://api.telegram.org/bot{token}/setMyCommands"
|
|
108
|
+
for payload in build_bot_command_payloads(self._bot_commands, chat_ids):
|
|
109
|
+
try:
|
|
110
|
+
response = httpx.post(
|
|
111
|
+
api_url,
|
|
112
|
+
json=payload,
|
|
113
|
+
timeout=self._timeout_seconds,
|
|
114
|
+
)
|
|
115
|
+
response.raise_for_status()
|
|
116
|
+
result = response.json()
|
|
117
|
+
except Exception as exc:
|
|
118
|
+
_webhooklog.warning(
|
|
119
|
+
"setMyCommands request failed project=%s err=%s",
|
|
120
|
+
project_name,
|
|
121
|
+
exc,
|
|
122
|
+
project=project_name,
|
|
123
|
+
)
|
|
124
|
+
return False
|
|
125
|
+
|
|
126
|
+
if not result.get("ok"):
|
|
127
|
+
_webhooklog.warning(
|
|
128
|
+
"setMyCommands rejected project=%s response=%s",
|
|
129
|
+
project_name,
|
|
130
|
+
result,
|
|
131
|
+
project=project_name,
|
|
132
|
+
)
|
|
133
|
+
return False
|
|
134
|
+
|
|
135
|
+
_webhooklog.info("setMyCommands synced project=%s", project_name, project=project_name)
|
|
136
|
+
return True
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def register_all_enabled_projects(public_base_url: str, settings: "Settings") -> bool:
|
|
140
|
+
"""Refresh Telegram webhook + command menu for every enabled registry project.
|
|
141
|
+
|
|
142
|
+
Shared by `remote-coder up` and `scripts/set_webhook.py`. Returns True only
|
|
143
|
+
when every enabled project synced successfully; missing/empty registry is a failure.
|
|
144
|
+
"""
|
|
145
|
+
from app.projects.registry import ProjectRegistry, projects_config_path_for_settings
|
|
146
|
+
from app.telegram.commands import default_telegram_bot_commands
|
|
147
|
+
|
|
148
|
+
config_path = projects_config_path_for_settings(
|
|
149
|
+
settings.project_root,
|
|
150
|
+
settings.projects_config_path,
|
|
151
|
+
)
|
|
152
|
+
registry = ProjectRegistry(config_path)
|
|
153
|
+
registry.load()
|
|
154
|
+
|
|
155
|
+
enabled = [record for record in registry.list_projects() if record.enabled]
|
|
156
|
+
if not enabled:
|
|
157
|
+
_webhooklog.warning("no enabled projects to register config_path=%s", config_path)
|
|
158
|
+
return False
|
|
159
|
+
|
|
160
|
+
registrar = TelegramWebhookRegistrar(
|
|
161
|
+
public_base_url,
|
|
162
|
+
bot_commands=default_telegram_bot_commands(),
|
|
163
|
+
)
|
|
164
|
+
all_succeeded = True
|
|
165
|
+
for record in enabled:
|
|
166
|
+
if not record.bot_token.get_secret_value().strip():
|
|
167
|
+
_webhooklog.warning("empty bot_token project=%s", record.name, project=record.name)
|
|
168
|
+
all_succeeded = False
|
|
169
|
+
continue
|
|
170
|
+
if not registrar.sync_project(record):
|
|
171
|
+
all_succeeded = False
|
|
172
|
+
return all_succeeded
|
app/tunnel.py
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import shutil
|
|
4
|
+
import subprocess
|
|
5
|
+
import time
|
|
6
|
+
|
|
7
|
+
import httpx
|
|
8
|
+
|
|
9
|
+
from app.monitoring.events import EventLogger
|
|
10
|
+
|
|
11
|
+
_tunnellog = EventLogger("app.tunnel", "tunnel")
|
|
12
|
+
|
|
13
|
+
NGROK_LOCAL_API = "http://127.0.0.1:4040/api/tunnels"
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class TunnelError(RuntimeError):
|
|
17
|
+
pass
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def ensure_ngrok_available() -> str:
|
|
21
|
+
path = shutil.which("ngrok")
|
|
22
|
+
if path is None:
|
|
23
|
+
raise TunnelError(
|
|
24
|
+
"ngrok 실행 파일을 찾을 수 없습니다. https://ngrok.com/download 에서 설치하세요."
|
|
25
|
+
)
|
|
26
|
+
return path
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def ensure_ngrok_configured() -> None:
|
|
30
|
+
try:
|
|
31
|
+
result = subprocess.run(
|
|
32
|
+
["ngrok", "config", "check"],
|
|
33
|
+
capture_output=True,
|
|
34
|
+
text=True,
|
|
35
|
+
timeout=15,
|
|
36
|
+
)
|
|
37
|
+
except (subprocess.SubprocessError, OSError) as exc:
|
|
38
|
+
raise TunnelError(f"ngrok 설정 확인에 실패했습니다: {exc}") from exc
|
|
39
|
+
|
|
40
|
+
combined = f"{result.stdout}\n{result.stderr}"
|
|
41
|
+
if "Valid configuration" not in combined:
|
|
42
|
+
raise TunnelError(
|
|
43
|
+
"ngrok AuthToken이 설정되지 않았습니다. https://dashboard.ngrok.com 에서 토큰을 발급받아 "
|
|
44
|
+
"`ngrok config add-authtoken <token>` 을 실행하세요."
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def fetch_public_url() -> str | None:
|
|
49
|
+
try:
|
|
50
|
+
response = httpx.get(NGROK_LOCAL_API, timeout=2.0)
|
|
51
|
+
response.raise_for_status()
|
|
52
|
+
data = response.json()
|
|
53
|
+
except (httpx.HTTPError, ValueError):
|
|
54
|
+
return None
|
|
55
|
+
for tunnel in data.get("tunnels", []):
|
|
56
|
+
public_url = tunnel.get("public_url", "")
|
|
57
|
+
if public_url.startswith("https"):
|
|
58
|
+
return public_url
|
|
59
|
+
return None
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class NgrokTunnel:
|
|
63
|
+
def __init__(self, port: int = 8000) -> None:
|
|
64
|
+
self._port = port
|
|
65
|
+
self._process: subprocess.Popen | None = None
|
|
66
|
+
self.public_url: str | None = None
|
|
67
|
+
|
|
68
|
+
def start(self, *, startup_timeout: float = 15.0) -> str:
|
|
69
|
+
ensure_ngrok_available()
|
|
70
|
+
ensure_ngrok_configured()
|
|
71
|
+
self._process = subprocess.Popen(
|
|
72
|
+
["ngrok", "http", str(self._port)],
|
|
73
|
+
stdout=subprocess.DEVNULL,
|
|
74
|
+
stderr=subprocess.DEVNULL,
|
|
75
|
+
)
|
|
76
|
+
url = self._wait_for_public_url(startup_timeout)
|
|
77
|
+
if url is None:
|
|
78
|
+
self.stop()
|
|
79
|
+
raise TunnelError(
|
|
80
|
+
"ngrok 공개 URL을 가져오지 못했습니다. 다른 ngrok 세션이 실행 중인지 확인하세요."
|
|
81
|
+
)
|
|
82
|
+
self.public_url = url
|
|
83
|
+
_tunnellog.info("ngrok tunnel started url=%s port=%d", url, self._port)
|
|
84
|
+
return url
|
|
85
|
+
|
|
86
|
+
def _wait_for_public_url(self, timeout: float) -> str | None:
|
|
87
|
+
deadline = time.monotonic() + timeout
|
|
88
|
+
while time.monotonic() < deadline:
|
|
89
|
+
url = fetch_public_url()
|
|
90
|
+
if url:
|
|
91
|
+
return url
|
|
92
|
+
time.sleep(0.5)
|
|
93
|
+
return None
|
|
94
|
+
|
|
95
|
+
def stop(self) -> None:
|
|
96
|
+
if self._process is None:
|
|
97
|
+
return
|
|
98
|
+
self._process.terminate()
|
|
99
|
+
try:
|
|
100
|
+
self._process.wait(timeout=5)
|
|
101
|
+
except subprocess.TimeoutExpired:
|
|
102
|
+
self._process.kill()
|
|
103
|
+
self._process = None
|
|
104
|
+
_tunnellog.info("ngrok tunnel stopped")
|