Initial commit
This commit is contained in:
8
.dockerignore
Normal file
8
.dockerignore
Normal file
@@ -0,0 +1,8 @@
|
||||
.env
|
||||
.venv
|
||||
__pycache__
|
||||
.pytest_cache
|
||||
data
|
||||
codex-home
|
||||
workspaces
|
||||
*.log
|
||||
25
.env.example
Normal file
25
.env.example
Normal file
@@ -0,0 +1,25 @@
|
||||
# Telegram
|
||||
TELEGRAM_BOT_TOKEN=put-your-token-here
|
||||
ALLOWED_TELEGRAM_USER_IDS=
|
||||
|
||||
# Bot storage and workspace
|
||||
BOT_STATE_PATH=/app/data/state.json
|
||||
BOT_WORKSPACE_ROOT=/workspaces
|
||||
BOT_TIMEZONE=Asia/Seoul
|
||||
SCHEDULE_POLL_SECONDS=30
|
||||
PERSISTENT_WS_IDLE_SECONDS=600
|
||||
|
||||
# Codex execution
|
||||
CODEX_BIN=codex
|
||||
CODEX_TIMEOUT_SECONDS=1800
|
||||
CODEX_BACKEND=auto
|
||||
CODEX_APP_SERVER_URL=ws://127.0.0.1:4500
|
||||
CODEX_APP_SERVER_MODEL=gpt-5.5
|
||||
CODEX_MODEL_REASONING_EFFORT=high
|
||||
CODEX_SPEED_MODE=standard
|
||||
CODEX_READONLY_SANDBOX=read-only
|
||||
CODEX_WRITE_SANDBOX=workspace-write
|
||||
CODEX_RESUME_JSON_FLAG_STYLE=before_resume
|
||||
|
||||
# Optional: set this only if you want plain messages to behave like /ask.
|
||||
ALLOW_PLAIN_TEXT=true
|
||||
9
.gitignore
vendored
Normal file
9
.gitignore
vendored
Normal file
@@ -0,0 +1,9 @@
|
||||
.env
|
||||
.venv/
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
.pytest_cache/
|
||||
data/
|
||||
deploy/.env
|
||||
*.log
|
||||
|
||||
36
Dockerfile
Normal file
36
Dockerfile
Normal file
@@ -0,0 +1,36 @@
|
||||
FROM python:3.12-slim
|
||||
|
||||
ARG CODEX_NPM_PACKAGE=@openai/codex
|
||||
|
||||
ENV PYTHONDONTWRITEBYTECODE=1 \
|
||||
PYTHONUNBUFFERED=1 \
|
||||
CODEX_HOME=/root/.codex \
|
||||
BOT_STATE_PATH=/app/data/state.json \
|
||||
BOT_WORKSPACE_ROOT=/workspaces \
|
||||
CODEX_APP_SERVER_URL=ws://127.0.0.1:4500
|
||||
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends \
|
||||
ca-certificates \
|
||||
curl \
|
||||
git \
|
||||
nodejs \
|
||||
npm \
|
||||
openssh-client \
|
||||
&& npm install -g "${CODEX_NPM_PACKAGE}" \
|
||||
&& apt-get clean \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY requirements.txt ./
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
COPY app ./app
|
||||
COPY scripts ./scripts
|
||||
COPY README.md ./
|
||||
|
||||
RUN mkdir -p /app/data /workspaces /root/.codex \
|
||||
&& chmod +x /app/scripts/start.sh
|
||||
|
||||
CMD ["/app/scripts/start.sh"]
|
||||
110
README.md
Normal file
110
README.md
Normal file
@@ -0,0 +1,110 @@
|
||||
# NAS Telegram Codex Bridge
|
||||
|
||||
Telegram bridge for a NAS-hosted Codex app-server.
|
||||
|
||||
The bot does not interpret normal chat messages. It forwards them to Codex and
|
||||
only keeps connection-management commands.
|
||||
|
||||
## Commands
|
||||
|
||||
```text
|
||||
/start
|
||||
/help
|
||||
/whoami
|
||||
/repo <path-under-workspace-root>
|
||||
/ask <prompt>
|
||||
/new
|
||||
/status
|
||||
/settings
|
||||
/set_model <model|latest|default>
|
||||
/set_effort <minimal|low|medium|high|xhigh|default>
|
||||
/set_speed <standard|fast|보통|고속|default>
|
||||
/fast <on|off|status>
|
||||
/models
|
||||
/schedules
|
||||
/records
|
||||
/todos
|
||||
/purchases
|
||||
/birthdays
|
||||
/sessions
|
||||
/sessions archived
|
||||
/use_session <thread_id>
|
||||
/archive_session [thread_id]
|
||||
/unarchive_session <thread_id>
|
||||
```
|
||||
|
||||
`ALLOW_PLAIN_TEXT=true` makes regular Telegram messages behave like `/ask`.
|
||||
|
||||
`/settings`, `/models`, `/schedules`, `/records`, and `/sessions` return inline
|
||||
buttons, so you can change speed/model/reasoning settings, delete schedules,
|
||||
manage personal records, and switch or archive sessions without copying ids.
|
||||
|
||||
## Runtime settings
|
||||
|
||||
Runtime settings are stored in `BOT_STATE_PATH` and applied to new turns:
|
||||
|
||||
- `model`, for example `gpt-5.5`
|
||||
- `reasoning_effort`: `minimal`, `low`, `medium`, `high`, or `xhigh`
|
||||
- `speed_mode`: `standard` or `fast`
|
||||
|
||||
Changing these settings closes existing persistent WebSocket clients. The next
|
||||
Telegram message reconnects with the updated settings. Fast mode requires a
|
||||
supported model/account; if the local Codex app-server rejects an override, the
|
||||
bridge retries with narrower settings rather than failing the whole turn.
|
||||
|
||||
The model picker first tries `codex debug models` inside the NAS container and
|
||||
builds buttons from the model catalog Codex sees. If that fails, it falls back
|
||||
to a short built-in list.
|
||||
|
||||
## Scheduling
|
||||
|
||||
The bridge registers a local MCP server named `telegram_bridge` in
|
||||
`$CODEX_HOME/config.toml`. Codex can call these tools when the user asks for
|
||||
scheduled Telegram notifications:
|
||||
|
||||
- `telegram_create_schedule`
|
||||
- `telegram_list_schedules`
|
||||
- `telegram_delete_schedule`
|
||||
|
||||
## Personal assistant records
|
||||
|
||||
Codex can persist personal assistant records to the same NAS-backed state file.
|
||||
Use natural language in Telegram, for example:
|
||||
|
||||
```text
|
||||
이건 업무 이력으로 저장해줘. 2025-09-15부터 2026-04-15까지 전국통합데이터 개방확대 사업에 참여했어.
|
||||
이건 할 일로 기억해줘. 다음 주까지 신세계 TV쇼핑 채팅 연동 QA 정리.
|
||||
구매 후보로 저장해줘. 모니터암 가격 8만원 이하가 되면 사고 싶어.
|
||||
어머니 생일은 5월 12일이야. 기억해줘.
|
||||
```
|
||||
|
||||
Codex uses these MCP tools:
|
||||
|
||||
- `telegram_save_personal_record`
|
||||
- `telegram_list_personal_records`
|
||||
- `telegram_update_personal_record`
|
||||
- `telegram_delete_personal_record`
|
||||
|
||||
Record categories are `work_log`, `todo`, `purchase`, `birthday`, `contact`,
|
||||
`note`, and `preference`. Telegram commands `/records`, `/todos`, `/purchases`,
|
||||
and `/birthdays` show records with management buttons.
|
||||
|
||||
The bridge scheduler runs inside the Telegram bot process. When a schedule is
|
||||
due, it calls Codex app-server and sends the result back to the stored Telegram
|
||||
chat id.
|
||||
|
||||
## Deployment
|
||||
|
||||
1. Copy `.env.example` to `.env`.
|
||||
2. Fill `TELEGRAM_BOT_TOKEN`.
|
||||
3. Use `/whoami` to find your Telegram user id and set
|
||||
`ALLOWED_TELEGRAM_USER_IDS`.
|
||||
4. Run `docker compose up -d --build`, or use `deploy/deploy.ps1` for the NAS.
|
||||
|
||||
## Notes
|
||||
|
||||
- Codex state and auth live under the mounted `codex-home` volume.
|
||||
- The app-server listens on `ws://127.0.0.1:4500` inside the container.
|
||||
- Normal chat messages use a persistent app-server WebSocket per Telegram chat.
|
||||
- Idle persistent WebSockets are closed after `PERSISTENT_WS_IDLE_SECONDS`.
|
||||
- Session commands call app-server thread APIs.
|
||||
2
app/__init__.py
Normal file
2
app/__init__.py
Normal file
@@ -0,0 +1,2 @@
|
||||
"""Telegram Codex bot package."""
|
||||
|
||||
525
app/app_server_runner.py
Normal file
525
app/app_server_runner.py
Normal file
@@ -0,0 +1,525 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import time
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import websockets
|
||||
|
||||
from app.codex_runner import CodexResult
|
||||
|
||||
|
||||
class AppServerProtocolError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
@dataclass
|
||||
class _TurnCollector:
|
||||
agent_deltas: list[str] = field(default_factory=list)
|
||||
final_messages: list[str] = field(default_factory=list)
|
||||
commands: list[str] = field(default_factory=list)
|
||||
errors: list[str] = field(default_factory=list)
|
||||
done: bool = False
|
||||
status: str | None = None
|
||||
|
||||
def handle(self, message: dict[str, Any]) -> None:
|
||||
method = message.get("method")
|
||||
params = message.get("params") or {}
|
||||
if not isinstance(params, dict):
|
||||
return
|
||||
|
||||
if method == "item/agentMessage/delta":
|
||||
delta = params.get("delta")
|
||||
if isinstance(delta, str):
|
||||
self.agent_deltas.append(delta)
|
||||
return
|
||||
|
||||
if method == "item/completed":
|
||||
item = params.get("item")
|
||||
if isinstance(item, dict):
|
||||
self._handle_completed_item(item)
|
||||
return
|
||||
|
||||
if method == "turn/completed":
|
||||
self.done = True
|
||||
turn = params.get("turn")
|
||||
if isinstance(turn, dict):
|
||||
self.status = turn.get("status")
|
||||
error = turn.get("error")
|
||||
if isinstance(error, dict) and error.get("message"):
|
||||
self.errors.append(str(error["message"]))
|
||||
return
|
||||
|
||||
def _handle_completed_item(self, item: dict[str, Any]) -> None:
|
||||
item_type = item.get("type")
|
||||
if item_type in {"agentMessage", "agent_message"} and item.get("text"):
|
||||
self.final_messages.append(str(item["text"]))
|
||||
return
|
||||
|
||||
if item_type in {"commandExecution", "command_execution"}:
|
||||
command = item.get("command")
|
||||
if isinstance(command, list):
|
||||
self.commands.append(" ".join(str(part) for part in command))
|
||||
elif command:
|
||||
self.commands.append(str(command))
|
||||
|
||||
def final_text(self) -> str:
|
||||
if self.final_messages:
|
||||
return self.final_messages[-1]
|
||||
if self.agent_deltas:
|
||||
return "".join(self.agent_deltas).strip()
|
||||
if self.errors:
|
||||
return "\n".join(self.errors)
|
||||
return "(Codex finished without text output.)"
|
||||
|
||||
|
||||
class AppServerRunner:
|
||||
def __init__(
|
||||
self,
|
||||
url: str,
|
||||
timeout_seconds: int,
|
||||
model: str | None = None,
|
||||
effort: str | None = None,
|
||||
service_tier: str | None = None,
|
||||
) -> None:
|
||||
self.url = url
|
||||
self.timeout_seconds = timeout_seconds
|
||||
self.model = model
|
||||
self.effort = effort
|
||||
self.service_tier = service_tier
|
||||
self._next_id = 1
|
||||
|
||||
async def run(
|
||||
self,
|
||||
prompt: str,
|
||||
cwd: Path,
|
||||
sandbox: str,
|
||||
session_id: str | None,
|
||||
skip_git_repo_check: bool = False,
|
||||
) -> CodexResult:
|
||||
del skip_git_repo_check
|
||||
try:
|
||||
return await asyncio.wait_for(
|
||||
self._run_once(prompt, cwd, sandbox, session_id),
|
||||
timeout=self.timeout_seconds,
|
||||
)
|
||||
except asyncio.TimeoutError:
|
||||
return CodexResult(
|
||||
ok=False,
|
||||
final_message=f"Codex app-server timed out after {self.timeout_seconds} seconds.",
|
||||
session_id=session_id,
|
||||
)
|
||||
except Exception as exc:
|
||||
return CodexResult(
|
||||
ok=False,
|
||||
final_message=f"Codex app-server failed: {exc}",
|
||||
session_id=session_id,
|
||||
)
|
||||
|
||||
async def call(self, method: str, params: dict[str, Any] | None = None) -> Any:
|
||||
collector = _TurnCollector()
|
||||
async with websockets.connect(
|
||||
self.url,
|
||||
open_timeout=15,
|
||||
ping_interval=20,
|
||||
max_size=16 * 1024 * 1024,
|
||||
) as ws:
|
||||
await self._initialize(ws, collector)
|
||||
return await self._request(ws, method, params or {}, collector)
|
||||
|
||||
async def list_threads(
|
||||
self,
|
||||
limit: int = 10,
|
||||
archived: bool = False,
|
||||
cursor: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
params: dict[str, Any] = {
|
||||
"limit": limit,
|
||||
"archived": archived,
|
||||
}
|
||||
if cursor:
|
||||
params["cursor"] = cursor
|
||||
result = await self.call("thread/list", params)
|
||||
return result if isinstance(result, dict) else {}
|
||||
|
||||
async def loaded_threads(self) -> dict[str, Any]:
|
||||
result = await self.call("thread/loaded/list", {})
|
||||
return result if isinstance(result, dict) else {}
|
||||
|
||||
async def archive_thread(self, thread_id: str) -> None:
|
||||
await self.call("thread/archive", {"threadId": thread_id})
|
||||
|
||||
async def unarchive_thread(self, thread_id: str) -> None:
|
||||
await self.call("thread/unarchive", {"threadId": thread_id})
|
||||
|
||||
async def resume_thread(self, thread_id: str) -> str:
|
||||
result = await self.call("thread/resume", {"threadId": thread_id})
|
||||
return _extract_thread_id(result) or thread_id
|
||||
|
||||
async def _run_once(
|
||||
self,
|
||||
prompt: str,
|
||||
cwd: Path,
|
||||
sandbox: str,
|
||||
session_id: str | None,
|
||||
) -> CodexResult:
|
||||
collector = _TurnCollector()
|
||||
async with websockets.connect(
|
||||
self.url,
|
||||
open_timeout=15,
|
||||
ping_interval=20,
|
||||
max_size=16 * 1024 * 1024,
|
||||
) as ws:
|
||||
await self._initialize(ws, collector)
|
||||
|
||||
thread_id = await self._ensure_thread(ws, cwd, sandbox, session_id, collector)
|
||||
turn_params: dict[str, Any] = {
|
||||
"threadId": thread_id,
|
||||
"input": [{"type": "text", "text": prompt}],
|
||||
}
|
||||
|
||||
await self._request_with_settings_fallback(
|
||||
ws,
|
||||
"turn/start",
|
||||
turn_params,
|
||||
collector,
|
||||
)
|
||||
while not collector.done:
|
||||
message = await self._recv(ws)
|
||||
await self._handle_non_response(ws, message, collector)
|
||||
|
||||
ok = collector.status not in {"failed", "interrupted"}
|
||||
return CodexResult(
|
||||
ok=ok,
|
||||
final_message=collector.final_text(),
|
||||
session_id=thread_id,
|
||||
commands=collector.commands,
|
||||
stderr="\n".join(collector.errors),
|
||||
returncode=0 if ok else 1,
|
||||
)
|
||||
|
||||
async def _ensure_thread(
|
||||
self,
|
||||
ws: Any,
|
||||
cwd: Path,
|
||||
sandbox: str,
|
||||
session_id: str | None,
|
||||
collector: _TurnCollector,
|
||||
) -> str:
|
||||
if session_id:
|
||||
try:
|
||||
result = await self._request_with_settings_fallback(
|
||||
ws,
|
||||
"thread/resume",
|
||||
{"threadId": session_id},
|
||||
collector,
|
||||
)
|
||||
return _extract_thread_id(result) or session_id
|
||||
except AppServerProtocolError:
|
||||
pass
|
||||
|
||||
result = await self._request_with_settings_fallback(
|
||||
ws,
|
||||
"thread/start",
|
||||
{},
|
||||
collector,
|
||||
)
|
||||
thread_id = _extract_thread_id(result)
|
||||
if not thread_id:
|
||||
raise AppServerProtocolError("thread/start did not return a thread id")
|
||||
return thread_id
|
||||
|
||||
async def _initialize(self, ws: Any, collector: _TurnCollector) -> None:
|
||||
await self._request(
|
||||
ws,
|
||||
"initialize",
|
||||
{
|
||||
"clientInfo": {
|
||||
"name": "telegram_codex_bot",
|
||||
"title": "Telegram Codex Bot",
|
||||
"version": "0.1.0",
|
||||
},
|
||||
"capabilities": {"experimentalApi": True},
|
||||
},
|
||||
collector,
|
||||
)
|
||||
await self._notify(ws, "initialized", {})
|
||||
|
||||
def _settings_params(self) -> dict[str, Any]:
|
||||
params: dict[str, Any] = {}
|
||||
if self.model:
|
||||
params["model"] = self.model
|
||||
if self.effort:
|
||||
params["effort"] = self.effort
|
||||
if self.service_tier:
|
||||
params["serviceTier"] = self.service_tier
|
||||
return params
|
||||
|
||||
def _settings_variants(self) -> list[tuple[str, dict[str, Any]]]:
|
||||
full = self._settings_params()
|
||||
variants: list[tuple[str, dict[str, Any]]] = []
|
||||
seen: set[str] = set()
|
||||
|
||||
def add(label: str, params: dict[str, Any]) -> None:
|
||||
key = json.dumps(params, sort_keys=True, ensure_ascii=True)
|
||||
if key in seen:
|
||||
return
|
||||
seen.add(key)
|
||||
variants.append((label, params))
|
||||
|
||||
add("model/effort/speed", full)
|
||||
if "serviceTier" in full:
|
||||
add("model/effort", {k: v for k, v in full.items() if k != "serviceTier"})
|
||||
if "effort" in full:
|
||||
add("model/speed", {k: v for k, v in full.items() if k != "effort"})
|
||||
if "model" in full:
|
||||
add("model", {"model": full["model"]})
|
||||
add("default settings", {})
|
||||
return variants
|
||||
|
||||
async def _request_with_settings_fallback(
|
||||
self,
|
||||
ws: Any,
|
||||
method: str,
|
||||
base_params: dict[str, Any],
|
||||
collector: _TurnCollector,
|
||||
) -> Any:
|
||||
errors: list[str] = []
|
||||
last_error: AppServerProtocolError | None = None
|
||||
for label, settings in self._settings_variants():
|
||||
params = dict(base_params)
|
||||
params.update(settings)
|
||||
try:
|
||||
result = await self._request(ws, method, params, collector)
|
||||
if errors:
|
||||
collector.errors.append(
|
||||
"Codex app-server rejected one or more runtime setting "
|
||||
f"overrides; continued with {label}. Last error: {errors[-1]}"
|
||||
)
|
||||
return result
|
||||
except AppServerProtocolError as exc:
|
||||
last_error = exc
|
||||
errors.append(f"{label}: {exc}")
|
||||
if not settings:
|
||||
break
|
||||
raise last_error or AppServerProtocolError(f"{method} failed")
|
||||
|
||||
async def _request(
|
||||
self,
|
||||
ws: Any,
|
||||
method: str,
|
||||
params: dict[str, Any],
|
||||
collector: _TurnCollector,
|
||||
) -> Any:
|
||||
request_id = self._next_request_id()
|
||||
await ws.send(json.dumps({"method": method, "id": request_id, "params": params}))
|
||||
while True:
|
||||
message = await self._recv(ws)
|
||||
if message.get("id") == request_id:
|
||||
if message.get("error"):
|
||||
error = message["error"]
|
||||
if isinstance(error, dict):
|
||||
raise AppServerProtocolError(error.get("message") or str(error))
|
||||
raise AppServerProtocolError(str(error))
|
||||
return message.get("result")
|
||||
await self._handle_non_response(ws, message, collector)
|
||||
|
||||
async def _notify(self, ws: Any, method: str, params: dict[str, Any]) -> None:
|
||||
await ws.send(json.dumps({"method": method, "params": params}))
|
||||
|
||||
async def _recv(self, ws: Any) -> dict[str, Any]:
|
||||
raw = await ws.recv()
|
||||
if isinstance(raw, bytes):
|
||||
raw = raw.decode("utf-8", errors="replace")
|
||||
try:
|
||||
message = json.loads(raw)
|
||||
except json.JSONDecodeError as exc:
|
||||
raise AppServerProtocolError(f"invalid JSON from app-server: {exc}") from exc
|
||||
if not isinstance(message, dict):
|
||||
raise AppServerProtocolError("app-server returned a non-object message")
|
||||
return message
|
||||
|
||||
async def _handle_non_response(
|
||||
self,
|
||||
ws: Any,
|
||||
message: dict[str, Any],
|
||||
collector: _TurnCollector,
|
||||
) -> None:
|
||||
if "id" in message and "method" in message:
|
||||
await ws.send(
|
||||
json.dumps(
|
||||
{
|
||||
"id": message["id"],
|
||||
"error": {
|
||||
"code": -32601,
|
||||
"message": "Telegram bot client cannot handle this app-server request.",
|
||||
},
|
||||
}
|
||||
)
|
||||
)
|
||||
return
|
||||
collector.handle(message)
|
||||
|
||||
def _next_request_id(self) -> int:
|
||||
request_id = self._next_id
|
||||
self._next_id += 1
|
||||
return request_id
|
||||
|
||||
|
||||
def _extract_thread_id(result: Any) -> str | None:
|
||||
if not isinstance(result, dict):
|
||||
return None
|
||||
thread = result.get("thread")
|
||||
if isinstance(thread, dict) and thread.get("id"):
|
||||
return str(thread["id"])
|
||||
if result.get("threadId"):
|
||||
return str(result["threadId"])
|
||||
return None
|
||||
|
||||
|
||||
class PersistentAppServerRunner(AppServerRunner):
|
||||
def __init__(
|
||||
self,
|
||||
url: str,
|
||||
timeout_seconds: int,
|
||||
model: str | None = None,
|
||||
effort: str | None = None,
|
||||
service_tier: str | None = None,
|
||||
) -> None:
|
||||
super().__init__(
|
||||
url=url,
|
||||
timeout_seconds=timeout_seconds,
|
||||
model=model,
|
||||
effort=effort,
|
||||
service_tier=service_tier,
|
||||
)
|
||||
self._ws: Any | None = None
|
||||
self._lock = asyncio.Lock()
|
||||
self.last_used_monotonic = time.monotonic()
|
||||
|
||||
@property
|
||||
def is_connected(self) -> bool:
|
||||
return self._ws is not None
|
||||
|
||||
def idle_seconds(self) -> float:
|
||||
return time.monotonic() - self.last_used_monotonic
|
||||
|
||||
async def close(self) -> None:
|
||||
ws = self._ws
|
||||
self._ws = None
|
||||
if ws is not None:
|
||||
await ws.close()
|
||||
|
||||
async def close_if_idle(self, idle_seconds: int) -> bool:
|
||||
if self._ws is None:
|
||||
return False
|
||||
if self.idle_seconds() < idle_seconds:
|
||||
return False
|
||||
async with self._lock:
|
||||
if self._ws is None or self.idle_seconds() < idle_seconds:
|
||||
return False
|
||||
await self.close()
|
||||
return True
|
||||
|
||||
async def run(
|
||||
self,
|
||||
prompt: str,
|
||||
cwd: Path,
|
||||
sandbox: str,
|
||||
session_id: str | None,
|
||||
skip_git_repo_check: bool = False,
|
||||
) -> CodexResult:
|
||||
del skip_git_repo_check
|
||||
async with self._lock:
|
||||
try:
|
||||
return await asyncio.wait_for(
|
||||
self._run_persistent(prompt, cwd, sandbox, session_id),
|
||||
timeout=self.timeout_seconds,
|
||||
)
|
||||
except asyncio.TimeoutError:
|
||||
await self.close()
|
||||
return CodexResult(
|
||||
ok=False,
|
||||
final_message=f"Codex app-server timed out after {self.timeout_seconds} seconds.",
|
||||
session_id=session_id,
|
||||
)
|
||||
except Exception as exc:
|
||||
await self.close()
|
||||
return CodexResult(
|
||||
ok=False,
|
||||
final_message=f"Codex app-server failed: {exc}",
|
||||
session_id=session_id,
|
||||
)
|
||||
|
||||
async def _run_persistent(
|
||||
self,
|
||||
prompt: str,
|
||||
cwd: Path,
|
||||
sandbox: str,
|
||||
session_id: str | None,
|
||||
) -> CodexResult:
|
||||
try:
|
||||
return await self._run_on_connection(prompt, cwd, sandbox, session_id)
|
||||
except Exception:
|
||||
await self.close()
|
||||
return await self._run_on_connection(prompt, cwd, sandbox, session_id)
|
||||
|
||||
async def _run_on_connection(
|
||||
self,
|
||||
prompt: str,
|
||||
cwd: Path,
|
||||
sandbox: str,
|
||||
session_id: str | None,
|
||||
) -> CodexResult:
|
||||
self.last_used_monotonic = time.monotonic()
|
||||
collector = _TurnCollector()
|
||||
ws = await self._ensure_connection(collector)
|
||||
thread_id = await self._ensure_thread(ws, cwd, sandbox, session_id, collector)
|
||||
turn_params: dict[str, Any] = {
|
||||
"threadId": thread_id,
|
||||
"input": [{"type": "text", "text": prompt}],
|
||||
}
|
||||
|
||||
await self._request_with_settings_fallback(
|
||||
ws,
|
||||
"turn/start",
|
||||
turn_params,
|
||||
collector,
|
||||
)
|
||||
while not collector.done:
|
||||
message = await self._recv(ws)
|
||||
await self._handle_non_response(ws, message, collector)
|
||||
|
||||
ok = collector.status not in {"failed", "interrupted"}
|
||||
self.last_used_monotonic = time.monotonic()
|
||||
return CodexResult(
|
||||
ok=ok,
|
||||
final_message=collector.final_text(),
|
||||
session_id=thread_id,
|
||||
commands=collector.commands,
|
||||
stderr="\n".join(collector.errors),
|
||||
returncode=0 if ok else 1,
|
||||
)
|
||||
|
||||
async def _ensure_connection(self, collector: _TurnCollector) -> Any:
|
||||
if self._ws is not None:
|
||||
return self._ws
|
||||
self._ws = await websockets.connect(
|
||||
self.url,
|
||||
open_timeout=15,
|
||||
ping_interval=20,
|
||||
max_size=16 * 1024 * 1024,
|
||||
)
|
||||
await self._initialize(self._ws, collector)
|
||||
return self._ws
|
||||
|
||||
|
||||
def _thread_sandbox(sandbox: str) -> str:
|
||||
return {
|
||||
"read-only": "readOnly",
|
||||
"workspace-write": "workspaceWrite",
|
||||
"danger-full-access": "dangerFullAccess",
|
||||
}.get(sandbox, "readOnly")
|
||||
1421
app/bot.py
Normal file
1421
app/bot.py
Normal file
File diff suppressed because it is too large
Load Diff
476
app/bridge_mcp.py
Normal file
476
app/bridge_mcp.py
Normal file
@@ -0,0 +1,476 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from app.state import StateStore, format_personal_record, format_schedule
|
||||
|
||||
|
||||
PROTOCOL_VERSION = "2024-11-05"
|
||||
|
||||
|
||||
def main() -> None:
|
||||
server = BridgeMcpServer(
|
||||
StateStore(Path(os.getenv("BOT_STATE_PATH", "/app/data/state.json"))),
|
||||
default_timezone=os.getenv("BOT_TIMEZONE", "Asia/Seoul"),
|
||||
)
|
||||
server.run()
|
||||
|
||||
|
||||
class BridgeMcpServer:
|
||||
def __init__(self, state: StateStore, default_timezone: str) -> None:
|
||||
self.state = state
|
||||
self.default_timezone = default_timezone
|
||||
|
||||
def run(self) -> None:
|
||||
for line in sys.stdin:
|
||||
line = line.strip()
|
||||
if not line:
|
||||
continue
|
||||
try:
|
||||
message = json.loads(line)
|
||||
response = self.handle(message)
|
||||
except Exception as exc:
|
||||
response = _error(None, -32603, str(exc))
|
||||
if response is not None:
|
||||
sys.stdout.write(json.dumps(response, ensure_ascii=False) + "\n")
|
||||
sys.stdout.flush()
|
||||
|
||||
def handle(self, message: dict[str, Any]) -> dict[str, Any] | None:
|
||||
method = message.get("method")
|
||||
request_id = message.get("id")
|
||||
|
||||
if method == "initialize":
|
||||
params = message.get("params") or {}
|
||||
return _result(
|
||||
request_id,
|
||||
{
|
||||
"protocolVersion": params.get("protocolVersion") or PROTOCOL_VERSION,
|
||||
"capabilities": {"tools": {}},
|
||||
"serverInfo": {
|
||||
"name": "telegram-codex-bridge",
|
||||
"version": "0.1.0",
|
||||
},
|
||||
"instructions": (
|
||||
"Use these tools when the user asks to schedule recurring Telegram "
|
||||
"notifications, manage bridge schedules, or persist personal assistant "
|
||||
"records such as work history, todos, purchase candidates, birthdays, "
|
||||
"contacts, and notes. The Telegram chat_id and "
|
||||
"current thread_id are provided in the bridge context inside each user turn. "
|
||||
"Use that chat_id as the default notification target and do not ask for a "
|
||||
"destination unless the user explicitly wants a different chat."
|
||||
),
|
||||
},
|
||||
)
|
||||
|
||||
if method == "notifications/initialized":
|
||||
return None
|
||||
|
||||
if method == "tools/list":
|
||||
return _result(request_id, {"tools": _tools()})
|
||||
|
||||
if method == "tools/call":
|
||||
params = message.get("params") or {}
|
||||
name = params.get("name")
|
||||
arguments = params.get("arguments") or {}
|
||||
try:
|
||||
return _result(request_id, self.call_tool(str(name), arguments))
|
||||
except Exception as exc:
|
||||
return _result(
|
||||
request_id,
|
||||
{
|
||||
"content": [{"type": "text", "text": f"Tool failed: {exc}"}],
|
||||
"isError": True,
|
||||
},
|
||||
)
|
||||
|
||||
if request_id is None:
|
||||
return None
|
||||
return _error(request_id, -32601, f"Unknown method: {method}")
|
||||
|
||||
def call_tool(self, name: str, arguments: dict[str, Any]) -> dict[str, Any]:
|
||||
if name == "telegram_create_schedule":
|
||||
return self.create_schedule(arguments)
|
||||
if name == "telegram_list_schedules":
|
||||
return self.list_schedules(arguments)
|
||||
if name == "telegram_delete_schedule":
|
||||
return self.delete_schedule(arguments)
|
||||
if name == "telegram_save_personal_record":
|
||||
return self.save_personal_record(arguments)
|
||||
if name == "telegram_list_personal_records":
|
||||
return self.list_personal_records(arguments)
|
||||
if name == "telegram_update_personal_record":
|
||||
return self.update_personal_record(arguments)
|
||||
if name == "telegram_delete_personal_record":
|
||||
return self.delete_personal_record(arguments)
|
||||
raise ValueError(f"Unknown tool: {name}")
|
||||
|
||||
def create_schedule(self, arguments: dict[str, Any]) -> dict[str, Any]:
|
||||
chat_id = int(arguments["chat_id"])
|
||||
schedule_type = str(arguments.get("schedule_type") or "daily")
|
||||
if schedule_type not in {"daily", "interval"}:
|
||||
raise ValueError("schedule_type must be daily or interval")
|
||||
|
||||
schedule: dict[str, Any] = {
|
||||
"chat_id": chat_id,
|
||||
"thread_id": arguments.get("thread_id") or None,
|
||||
"name": str(arguments.get("name") or "Scheduled Codex task"),
|
||||
"prompt": str(arguments["prompt"]),
|
||||
"schedule_type": schedule_type,
|
||||
"timezone": str(arguments.get("timezone") or self.default_timezone),
|
||||
"status": "active",
|
||||
}
|
||||
if schedule_type == "interval":
|
||||
schedule["interval_minutes"] = max(1, int(arguments.get("interval_minutes") or 60))
|
||||
else:
|
||||
schedule["hour"] = int(arguments.get("hour") if arguments.get("hour") is not None else 8)
|
||||
schedule["minute"] = int(arguments.get("minute") if arguments.get("minute") is not None else 0)
|
||||
|
||||
created = self.state.add_schedule(schedule)
|
||||
text = f"Created Telegram schedule: {format_schedule(created)}"
|
||||
return {
|
||||
"content": [{"type": "text", "text": text}],
|
||||
"structuredContent": {"schedule": created},
|
||||
}
|
||||
|
||||
def list_schedules(self, arguments: dict[str, Any]) -> dict[str, Any]:
|
||||
chat_id = arguments.get("chat_id")
|
||||
schedules = self.state.list_schedules(
|
||||
chat_id=int(chat_id) if chat_id is not None else None,
|
||||
include_paused=bool(arguments.get("include_paused", True)),
|
||||
)
|
||||
if not schedules:
|
||||
text = "No Telegram bridge schedules are registered."
|
||||
else:
|
||||
text = "\n".join(format_schedule(schedule) for schedule in schedules)
|
||||
return {
|
||||
"content": [{"type": "text", "text": text}],
|
||||
"structuredContent": {"schedules": schedules},
|
||||
}
|
||||
|
||||
def delete_schedule(self, arguments: dict[str, Any]) -> dict[str, Any]:
|
||||
schedule_id = int(arguments["schedule_id"])
|
||||
chat_id = arguments.get("chat_id")
|
||||
removed = self.state.remove_schedule(
|
||||
schedule_id,
|
||||
chat_id=int(chat_id) if chat_id is not None else None,
|
||||
)
|
||||
if removed is None:
|
||||
raise ValueError(f"schedule not found: {schedule_id}")
|
||||
text = f"Deleted Telegram schedule: {format_schedule(removed)}"
|
||||
return {
|
||||
"content": [{"type": "text", "text": text}],
|
||||
"structuredContent": {"deleted": removed},
|
||||
}
|
||||
|
||||
def save_personal_record(self, arguments: dict[str, Any]) -> dict[str, Any]:
|
||||
chat_id = int(arguments["chat_id"])
|
||||
category = str(arguments.get("category") or "note")
|
||||
if category not in _record_categories():
|
||||
raise ValueError(f"category must be one of: {', '.join(_record_categories())}")
|
||||
tags = arguments.get("tags") or []
|
||||
if not isinstance(tags, list):
|
||||
tags = [str(tags)]
|
||||
metadata = arguments.get("metadata") or {}
|
||||
if not isinstance(metadata, dict):
|
||||
metadata = {}
|
||||
record = {
|
||||
"chat_id": chat_id,
|
||||
"category": category,
|
||||
"title": str(arguments.get("title") or "Untitled"),
|
||||
"content": str(arguments.get("content") or ""),
|
||||
"status": str(arguments.get("status") or "active"),
|
||||
"date": arguments.get("date") or None,
|
||||
"start_date": arguments.get("start_date") or None,
|
||||
"end_date": arguments.get("end_date") or None,
|
||||
"due_date": arguments.get("due_date") or None,
|
||||
"tags": [str(tag) for tag in tags],
|
||||
"metadata": metadata,
|
||||
}
|
||||
created = self.state.add_personal_record(record)
|
||||
text = f"Saved personal record: {format_personal_record(created)}"
|
||||
return {
|
||||
"content": [{"type": "text", "text": text}],
|
||||
"structuredContent": {"record": created},
|
||||
}
|
||||
|
||||
def list_personal_records(self, arguments: dict[str, Any]) -> dict[str, Any]:
|
||||
chat_id = arguments.get("chat_id")
|
||||
records = self.state.list_personal_records(
|
||||
chat_id=int(chat_id) if chat_id is not None else None,
|
||||
category=arguments.get("category") or None,
|
||||
status=arguments.get("status") or None,
|
||||
query=arguments.get("query") or None,
|
||||
limit=int(arguments.get("limit") or 30),
|
||||
)
|
||||
if not records:
|
||||
text = "No personal records found."
|
||||
else:
|
||||
text = "\n".join(format_personal_record(record) for record in records)
|
||||
return {
|
||||
"content": [{"type": "text", "text": text}],
|
||||
"structuredContent": {"records": records},
|
||||
}
|
||||
|
||||
def update_personal_record(self, arguments: dict[str, Any]) -> dict[str, Any]:
|
||||
record_id = int(arguments["record_id"])
|
||||
chat_id = arguments.get("chat_id")
|
||||
updates = dict(arguments.get("updates") or {})
|
||||
if "category" in updates and updates["category"] not in _record_categories():
|
||||
raise ValueError(f"category must be one of: {', '.join(_record_categories())}")
|
||||
if "tags" in updates and not isinstance(updates["tags"], list):
|
||||
updates["tags"] = [str(updates["tags"])]
|
||||
updated = self.state.update_personal_record(
|
||||
record_id,
|
||||
updates,
|
||||
chat_id=int(chat_id) if chat_id is not None else None,
|
||||
)
|
||||
if updated is None:
|
||||
raise ValueError(f"personal record not found: {record_id}")
|
||||
text = f"Updated personal record: {format_personal_record(updated)}"
|
||||
return {
|
||||
"content": [{"type": "text", "text": text}],
|
||||
"structuredContent": {"record": updated},
|
||||
}
|
||||
|
||||
def delete_personal_record(self, arguments: dict[str, Any]) -> dict[str, Any]:
|
||||
record_id = int(arguments["record_id"])
|
||||
chat_id = arguments.get("chat_id")
|
||||
removed = self.state.remove_personal_record(
|
||||
record_id,
|
||||
chat_id=int(chat_id) if chat_id is not None else None,
|
||||
)
|
||||
if removed is None:
|
||||
raise ValueError(f"personal record not found: {record_id}")
|
||||
text = f"Deleted personal record: {format_personal_record(removed)}"
|
||||
return {
|
||||
"content": [{"type": "text", "text": text}],
|
||||
"structuredContent": {"deleted": removed},
|
||||
}
|
||||
|
||||
|
||||
def _tools() -> list[dict[str, Any]]:
|
||||
return [
|
||||
{
|
||||
"name": "telegram_create_schedule",
|
||||
"description": (
|
||||
"Create a recurring Telegram notification schedule. Use this when the user asks "
|
||||
"Codex to remind, notify, monitor, or run a recurring task through Telegram. "
|
||||
"The prompt should describe only the work to run at schedule time, not the cadence."
|
||||
),
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"chat_id": {
|
||||
"type": "integer",
|
||||
"description": "Telegram chat id that should receive the scheduled result.",
|
||||
},
|
||||
"thread_id": {
|
||||
"type": "string",
|
||||
"description": "Current Codex app-server thread id, if available.",
|
||||
},
|
||||
"name": {"type": "string", "description": "Short schedule name."},
|
||||
"prompt": {
|
||||
"type": "string",
|
||||
"description": "Task prompt to run at the scheduled time.",
|
||||
},
|
||||
"schedule_type": {
|
||||
"type": "string",
|
||||
"enum": ["daily", "interval"],
|
||||
"description": "daily for wall-clock time, interval for every N minutes.",
|
||||
},
|
||||
"hour": {
|
||||
"type": "integer",
|
||||
"minimum": 0,
|
||||
"maximum": 23,
|
||||
"description": "Daily run hour in the selected timezone.",
|
||||
},
|
||||
"minute": {
|
||||
"type": "integer",
|
||||
"minimum": 0,
|
||||
"maximum": 59,
|
||||
"description": "Daily run minute in the selected timezone.",
|
||||
},
|
||||
"interval_minutes": {
|
||||
"type": "integer",
|
||||
"minimum": 1,
|
||||
"description": "Interval cadence in minutes.",
|
||||
},
|
||||
"timezone": {
|
||||
"type": "string",
|
||||
"description": "IANA timezone, usually Asia/Seoul.",
|
||||
},
|
||||
},
|
||||
"required": ["chat_id", "name", "prompt", "schedule_type"],
|
||||
},
|
||||
"annotations": {
|
||||
"title": "Create Telegram schedule",
|
||||
"readOnlyHint": False,
|
||||
"destructiveHint": False,
|
||||
"idempotentHint": False,
|
||||
"openWorldHint": False,
|
||||
},
|
||||
},
|
||||
{
|
||||
"name": "telegram_list_schedules",
|
||||
"description": "List Telegram bridge schedules, optionally scoped to a chat_id.",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"chat_id": {"type": "integer"},
|
||||
"include_paused": {"type": "boolean"},
|
||||
},
|
||||
},
|
||||
"annotations": {
|
||||
"title": "List Telegram schedules",
|
||||
"readOnlyHint": True,
|
||||
"destructiveHint": False,
|
||||
"idempotentHint": True,
|
||||
"openWorldHint": False,
|
||||
},
|
||||
},
|
||||
{
|
||||
"name": "telegram_delete_schedule",
|
||||
"description": "Delete a Telegram bridge schedule by id.",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"schedule_id": {"type": "integer"},
|
||||
"chat_id": {
|
||||
"type": "integer",
|
||||
"description": "Optional Telegram chat id safety check.",
|
||||
},
|
||||
},
|
||||
"required": ["schedule_id"],
|
||||
},
|
||||
"annotations": {
|
||||
"title": "Delete Telegram schedule",
|
||||
"readOnlyHint": False,
|
||||
"destructiveHint": True,
|
||||
"idempotentHint": False,
|
||||
"openWorldHint": False,
|
||||
},
|
||||
},
|
||||
{
|
||||
"name": "telegram_save_personal_record",
|
||||
"description": (
|
||||
"Save a durable personal assistant record to NAS-backed Telegram bridge storage. "
|
||||
"Use for work history, todos, purchase plans, birthdays, contacts, personal notes, "
|
||||
"preferences, and other facts the user explicitly wants remembered later."
|
||||
),
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"chat_id": {"type": "integer"},
|
||||
"category": {
|
||||
"type": "string",
|
||||
"enum": _record_categories(),
|
||||
"description": "Record category.",
|
||||
},
|
||||
"title": {"type": "string", "description": "Short title."},
|
||||
"content": {"type": "string", "description": "Full details to remember."},
|
||||
"status": {
|
||||
"type": "string",
|
||||
"description": "active, done, cancelled, archived, or another short status.",
|
||||
},
|
||||
"date": {"type": "string", "description": "Primary date, ISO YYYY-MM-DD if possible."},
|
||||
"start_date": {"type": "string", "description": "Start date, ISO YYYY-MM-DD if possible."},
|
||||
"end_date": {"type": "string", "description": "End date, ISO YYYY-MM-DD if possible."},
|
||||
"due_date": {"type": "string", "description": "Due date, ISO YYYY-MM-DD if possible."},
|
||||
"tags": {"type": "array", "items": {"type": "string"}},
|
||||
"metadata": {"type": "object", "additionalProperties": True},
|
||||
},
|
||||
"required": ["chat_id", "category", "title", "content"],
|
||||
},
|
||||
"annotations": {
|
||||
"title": "Save personal record",
|
||||
"readOnlyHint": False,
|
||||
"destructiveHint": False,
|
||||
"idempotentHint": False,
|
||||
"openWorldHint": False,
|
||||
},
|
||||
},
|
||||
{
|
||||
"name": "telegram_list_personal_records",
|
||||
"description": "List or search personal assistant records stored by the Telegram bridge.",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"chat_id": {"type": "integer"},
|
||||
"category": {"type": "string", "enum": _record_categories()},
|
||||
"status": {"type": "string"},
|
||||
"query": {"type": "string"},
|
||||
"limit": {"type": "integer", "minimum": 1, "maximum": 100},
|
||||
},
|
||||
},
|
||||
"annotations": {
|
||||
"title": "List personal records",
|
||||
"readOnlyHint": True,
|
||||
"destructiveHint": False,
|
||||
"idempotentHint": True,
|
||||
"openWorldHint": False,
|
||||
},
|
||||
},
|
||||
{
|
||||
"name": "telegram_update_personal_record",
|
||||
"description": "Update a personal assistant record by id.",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"record_id": {"type": "integer"},
|
||||
"chat_id": {"type": "integer"},
|
||||
"updates": {"type": "object", "additionalProperties": True},
|
||||
},
|
||||
"required": ["record_id", "updates"],
|
||||
},
|
||||
"annotations": {
|
||||
"title": "Update personal record",
|
||||
"readOnlyHint": False,
|
||||
"destructiveHint": False,
|
||||
"idempotentHint": False,
|
||||
"openWorldHint": False,
|
||||
},
|
||||
},
|
||||
{
|
||||
"name": "telegram_delete_personal_record",
|
||||
"description": "Delete a personal assistant record by id.",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"record_id": {"type": "integer"},
|
||||
"chat_id": {"type": "integer"},
|
||||
},
|
||||
"required": ["record_id"],
|
||||
},
|
||||
"annotations": {
|
||||
"title": "Delete personal record",
|
||||
"readOnlyHint": False,
|
||||
"destructiveHint": True,
|
||||
"idempotentHint": False,
|
||||
"openWorldHint": False,
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
def _record_categories() -> list[str]:
|
||||
return ["work_log", "todo", "purchase", "birthday", "contact", "note", "preference"]
|
||||
|
||||
|
||||
def _result(request_id: Any, result: dict[str, Any]) -> dict[str, Any]:
|
||||
return {"jsonrpc": "2.0", "id": request_id, "result": result}
|
||||
|
||||
|
||||
def _error(request_id: Any, code: int, message: str) -> dict[str, Any]:
|
||||
return {
|
||||
"jsonrpc": "2.0",
|
||||
"id": request_id,
|
||||
"error": {"code": code, "message": message},
|
||||
}
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
148
app/codex_runner.py
Normal file
148
app/codex_runner.py
Normal file
@@ -0,0 +1,148 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
@dataclass
|
||||
class CodexResult:
|
||||
ok: bool
|
||||
final_message: str
|
||||
session_id: str | None
|
||||
commands: list[str] = field(default_factory=list)
|
||||
stderr: str = ""
|
||||
returncode: int | None = None
|
||||
|
||||
|
||||
class CodexRunner:
|
||||
def __init__(
|
||||
self,
|
||||
codex_bin: str,
|
||||
timeout_seconds: int,
|
||||
resume_json_flag_style: str,
|
||||
) -> None:
|
||||
self.codex_bin = codex_bin
|
||||
self.timeout_seconds = timeout_seconds
|
||||
self.resume_json_flag_style = resume_json_flag_style
|
||||
|
||||
def _command(
|
||||
self,
|
||||
prompt: str,
|
||||
sandbox: str,
|
||||
session_id: str | None,
|
||||
skip_git_repo_check: bool,
|
||||
) -> list[str]:
|
||||
base = [self.codex_bin, "exec", "--json", "--sandbox", sandbox]
|
||||
if skip_git_repo_check:
|
||||
base.append("--skip-git-repo-check")
|
||||
if not session_id:
|
||||
return [*base, prompt]
|
||||
|
||||
if self.resume_json_flag_style == "after_resume":
|
||||
return [
|
||||
self.codex_bin,
|
||||
"exec",
|
||||
"resume",
|
||||
session_id,
|
||||
"--json",
|
||||
"--sandbox",
|
||||
sandbox,
|
||||
*(["--skip-git-repo-check"] if skip_git_repo_check else []),
|
||||
prompt,
|
||||
]
|
||||
return [*base, "resume", session_id, prompt]
|
||||
|
||||
async def run(
|
||||
self,
|
||||
prompt: str,
|
||||
cwd: Path,
|
||||
sandbox: str,
|
||||
session_id: str | None,
|
||||
skip_git_repo_check: bool = False,
|
||||
) -> CodexResult:
|
||||
cmd = self._command(
|
||||
prompt=prompt,
|
||||
sandbox=sandbox,
|
||||
session_id=session_id,
|
||||
skip_git_repo_check=skip_git_repo_check,
|
||||
)
|
||||
proc = await asyncio.create_subprocess_exec(
|
||||
*cmd,
|
||||
cwd=str(cwd),
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
)
|
||||
|
||||
try:
|
||||
stdout, stderr = await asyncio.wait_for(
|
||||
proc.communicate(),
|
||||
timeout=self.timeout_seconds,
|
||||
)
|
||||
except asyncio.TimeoutError:
|
||||
proc.kill()
|
||||
await proc.wait()
|
||||
return CodexResult(
|
||||
ok=False,
|
||||
final_message=f"Codex timed out after {self.timeout_seconds} seconds.",
|
||||
session_id=session_id,
|
||||
)
|
||||
|
||||
parsed = self._parse_jsonl(stdout.decode("utf-8", errors="replace"))
|
||||
stderr_text = stderr.decode("utf-8", errors="replace").strip()
|
||||
if proc.returncode != 0:
|
||||
message = parsed.final_message or stderr_text or "Codex command failed."
|
||||
return CodexResult(
|
||||
ok=False,
|
||||
final_message=message,
|
||||
session_id=parsed.session_id or session_id,
|
||||
commands=parsed.commands,
|
||||
stderr=stderr_text,
|
||||
returncode=proc.returncode,
|
||||
)
|
||||
|
||||
return CodexResult(
|
||||
ok=True,
|
||||
final_message=parsed.final_message or "(Codex finished without text output.)",
|
||||
session_id=parsed.session_id or session_id,
|
||||
commands=parsed.commands,
|
||||
stderr=stderr_text,
|
||||
returncode=proc.returncode,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _parse_jsonl(text: str) -> CodexResult:
|
||||
session_id: str | None = None
|
||||
final_messages: list[str] = []
|
||||
commands: list[str] = []
|
||||
fallback_lines: list[str] = []
|
||||
|
||||
for line in text.splitlines():
|
||||
line = line.strip()
|
||||
if not line:
|
||||
continue
|
||||
try:
|
||||
event = json.loads(line)
|
||||
except json.JSONDecodeError:
|
||||
fallback_lines.append(line)
|
||||
continue
|
||||
|
||||
if event.get("type") == "thread.started":
|
||||
session_id = event.get("thread_id") or session_id
|
||||
continue
|
||||
|
||||
item = event.get("item")
|
||||
if isinstance(item, dict):
|
||||
if item.get("type") == "agent_message" and item.get("text"):
|
||||
final_messages.append(str(item["text"]))
|
||||
if item.get("type") == "command_execution" and item.get("command"):
|
||||
commands.append(str(item["command"]))
|
||||
|
||||
final_message = final_messages[-1] if final_messages else "\n".join(fallback_lines)
|
||||
return CodexResult(
|
||||
ok=True,
|
||||
final_message=final_message,
|
||||
session_id=session_id,
|
||||
commands=commands,
|
||||
)
|
||||
96
app/config.py
Normal file
96
app/config.py
Normal file
@@ -0,0 +1,96 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
from dotenv import load_dotenv
|
||||
|
||||
|
||||
def _bool_env(name: str, default: bool) -> bool:
|
||||
raw = os.getenv(name)
|
||||
if raw is None:
|
||||
return default
|
||||
return raw.strip().lower() in {"1", "true", "yes", "y", "on"}
|
||||
|
||||
|
||||
def _int_env(name: str, default: int) -> int:
|
||||
raw = os.getenv(name)
|
||||
if not raw:
|
||||
return default
|
||||
return int(raw)
|
||||
|
||||
|
||||
def _id_set(name: str) -> set[int]:
|
||||
raw = os.getenv(name, "")
|
||||
ids: set[int] = set()
|
||||
for part in raw.replace(";", ",").split(","):
|
||||
part = part.strip()
|
||||
if not part:
|
||||
continue
|
||||
ids.add(int(part))
|
||||
return ids
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Config:
|
||||
telegram_bot_token: str
|
||||
allowed_user_ids: set[int]
|
||||
state_path: Path
|
||||
workspace_root: Path
|
||||
codex_bin: str
|
||||
codex_timeout_seconds: int
|
||||
codex_backend: str
|
||||
app_server_url: str
|
||||
app_server_model: str | None
|
||||
app_server_reasoning_effort: str | None
|
||||
app_server_speed_mode: str
|
||||
readonly_sandbox: str
|
||||
write_sandbox: str
|
||||
resume_json_flag_style: str
|
||||
allow_plain_text: bool
|
||||
timezone: str
|
||||
schedule_poll_seconds: int
|
||||
persistent_ws_idle_seconds: int
|
||||
|
||||
@classmethod
|
||||
def load(cls) -> "Config":
|
||||
load_dotenv()
|
||||
token = os.getenv("TELEGRAM_BOT_TOKEN", "").strip()
|
||||
if not token:
|
||||
raise RuntimeError("TELEGRAM_BOT_TOKEN is required")
|
||||
|
||||
backend = os.getenv("CODEX_BACKEND", "auto").strip().lower()
|
||||
if backend not in {"auto", "app-server", "exec"}:
|
||||
raise RuntimeError("CODEX_BACKEND must be one of: auto, app-server, exec")
|
||||
|
||||
app_server_model = os.getenv("CODEX_APP_SERVER_MODEL", "").strip() or "gpt-5.5"
|
||||
app_server_reasoning_effort = (
|
||||
os.getenv("CODEX_MODEL_REASONING_EFFORT", "").strip() or "high"
|
||||
)
|
||||
app_server_speed_mode = os.getenv("CODEX_SPEED_MODE", "standard").strip().lower()
|
||||
if app_server_speed_mode not in {"standard", "fast"}:
|
||||
raise RuntimeError("CODEX_SPEED_MODE must be one of: standard, fast")
|
||||
|
||||
return cls(
|
||||
telegram_bot_token=token,
|
||||
allowed_user_ids=_id_set("ALLOWED_TELEGRAM_USER_IDS"),
|
||||
state_path=Path(os.getenv("BOT_STATE_PATH", "/app/data/state.json")),
|
||||
workspace_root=Path(os.getenv("BOT_WORKSPACE_ROOT", "/workspaces")),
|
||||
codex_bin=os.getenv("CODEX_BIN", "codex"),
|
||||
codex_timeout_seconds=_int_env("CODEX_TIMEOUT_SECONDS", 1800),
|
||||
codex_backend=backend,
|
||||
app_server_url=os.getenv("CODEX_APP_SERVER_URL", "ws://127.0.0.1:4500"),
|
||||
app_server_model=app_server_model,
|
||||
app_server_reasoning_effort=app_server_reasoning_effort,
|
||||
app_server_speed_mode=app_server_speed_mode,
|
||||
readonly_sandbox=os.getenv("CODEX_READONLY_SANDBOX", "read-only"),
|
||||
write_sandbox=os.getenv("CODEX_WRITE_SANDBOX", "workspace-write"),
|
||||
resume_json_flag_style=os.getenv(
|
||||
"CODEX_RESUME_JSON_FLAG_STYLE", "before_resume"
|
||||
),
|
||||
allow_plain_text=_bool_env("ALLOW_PLAIN_TEXT", True),
|
||||
timezone=os.getenv("BOT_TIMEZONE", "Asia/Seoul"),
|
||||
schedule_poll_seconds=_int_env("SCHEDULE_POLL_SECONDS", 30),
|
||||
persistent_ws_idle_seconds=_int_env("PERSISTENT_WS_IDLE_SECONDS", 600),
|
||||
)
|
||||
50
app/ensure_codex_config.py
Normal file
50
app/ensure_codex_config.py
Normal file
@@ -0,0 +1,50 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
BEGIN = "# BEGIN telegram-codex-bridge managed MCP"
|
||||
END = "# END telegram-codex-bridge managed MCP"
|
||||
|
||||
|
||||
def main() -> None:
|
||||
codex_home = Path(os.getenv("CODEX_HOME", "/root/.codex"))
|
||||
codex_home.mkdir(parents=True, exist_ok=True)
|
||||
config_path = codex_home / "config.toml"
|
||||
state_path = os.getenv("BOT_STATE_PATH", "/app/data/state.json")
|
||||
timezone = os.getenv("BOT_TIMEZONE", "Asia/Seoul")
|
||||
|
||||
block = f"""{BEGIN}
|
||||
[mcp_servers.telegram_bridge]
|
||||
command = "python"
|
||||
args = ["-m", "app.bridge_mcp"]
|
||||
tool_timeout_sec = 30.0
|
||||
default_tools_approval_mode = "auto"
|
||||
|
||||
[mcp_servers.telegram_bridge.env]
|
||||
PYTHONPATH = "/app"
|
||||
BOT_STATE_PATH = "{_toml_string(state_path)}"
|
||||
BOT_TIMEZONE = "{_toml_string(timezone)}"
|
||||
{END}
|
||||
"""
|
||||
|
||||
existing = config_path.read_text(encoding="utf-8") if config_path.exists() else ""
|
||||
pattern = re.compile(
|
||||
rf"{re.escape(BEGIN)}.*?{re.escape(END)}\s*",
|
||||
flags=re.DOTALL,
|
||||
)
|
||||
if pattern.search(existing):
|
||||
updated = pattern.sub(block, existing)
|
||||
else:
|
||||
updated = existing.rstrip() + "\n\n" + block if existing.strip() else block
|
||||
config_path.write_text(updated, encoding="utf-8")
|
||||
|
||||
|
||||
def _toml_string(value: str) -> str:
|
||||
return value.replace("\\", "\\\\").replace('"', '\\"')
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
15
app/main.py
Normal file
15
app/main.py
Normal file
@@ -0,0 +1,15 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from app.bot import TelegramCodexBot
|
||||
from app.config import Config
|
||||
|
||||
|
||||
def main() -> None:
|
||||
config = Config.load()
|
||||
bot = TelegramCodexBot(config)
|
||||
app = bot.build_app()
|
||||
app.run_polling(allowed_updates=["message", "callback_query"])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
337
app/state.py
Normal file
337
app/state.py
Normal file
@@ -0,0 +1,337 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
|
||||
@dataclass
|
||||
class ChatState:
|
||||
repo_path: str | None = None
|
||||
session_id: str | None = None
|
||||
session_backend: str | None = None
|
||||
|
||||
|
||||
class StateStore:
|
||||
def __init__(self, path: Path) -> None:
|
||||
self.path = path
|
||||
self.path.parent.mkdir(parents=True, exist_ok=True)
|
||||
self._data: dict[str, Any] = {
|
||||
"chats": {},
|
||||
"schedules": [],
|
||||
"next_schedule_id": 1,
|
||||
"personal_records": [],
|
||||
"next_record_id": 1,
|
||||
"runtime_settings": {},
|
||||
}
|
||||
self.load()
|
||||
|
||||
def load(self) -> None:
|
||||
if not self.path.exists():
|
||||
self._ensure_defaults()
|
||||
return
|
||||
self._data = json.loads(self.path.read_text(encoding="utf-8"))
|
||||
self._ensure_defaults()
|
||||
|
||||
def _ensure_defaults(self) -> None:
|
||||
self._data.setdefault("chats", {})
|
||||
self._data.setdefault("schedules", [])
|
||||
self._data.setdefault("next_schedule_id", 1)
|
||||
self._data.setdefault("personal_records", [])
|
||||
self._data.setdefault("next_record_id", 1)
|
||||
self._data.setdefault("runtime_settings", {})
|
||||
|
||||
def save(self) -> None:
|
||||
tmp_path = self.path.with_suffix(self.path.suffix + ".tmp")
|
||||
tmp_path.write_text(
|
||||
json.dumps(self._data, ensure_ascii=False, indent=2),
|
||||
encoding="utf-8",
|
||||
)
|
||||
tmp_path.replace(self.path)
|
||||
|
||||
def get_chat(self, chat_id: int) -> ChatState:
|
||||
self.load()
|
||||
raw = self._data.setdefault("chats", {}).setdefault(str(chat_id), {})
|
||||
return ChatState(
|
||||
repo_path=raw.get("repo_path"),
|
||||
session_id=raw.get("session_id"),
|
||||
session_backend=raw.get("session_backend"),
|
||||
)
|
||||
|
||||
def update_chat(self, chat_id: int, chat_state: ChatState) -> None:
|
||||
self.load()
|
||||
self._data.setdefault("chats", {})[str(chat_id)] = {
|
||||
"repo_path": chat_state.repo_path,
|
||||
"session_id": chat_state.session_id,
|
||||
"session_backend": chat_state.session_backend,
|
||||
}
|
||||
self.save()
|
||||
|
||||
def clear_session(self, chat_id: int) -> None:
|
||||
chat = self.get_chat(chat_id)
|
||||
chat.session_id = None
|
||||
chat.session_backend = None
|
||||
self.update_chat(chat_id, chat)
|
||||
|
||||
def set_repo(self, chat_id: int, repo_path: Path) -> None:
|
||||
chat = self.get_chat(chat_id)
|
||||
chat.repo_path = str(repo_path)
|
||||
chat.session_id = None
|
||||
chat.session_backend = None
|
||||
self.update_chat(chat_id, chat)
|
||||
|
||||
def get_runtime_settings(self, defaults: dict[str, Any]) -> dict[str, Any]:
|
||||
self.load()
|
||||
settings = dict(defaults)
|
||||
settings.update(self._data.setdefault("runtime_settings", {}))
|
||||
return settings
|
||||
|
||||
def update_runtime_settings(self, updates: dict[str, Any]) -> dict[str, Any]:
|
||||
self.load()
|
||||
settings = self._data.setdefault("runtime_settings", {})
|
||||
for key, value in updates.items():
|
||||
if value is None:
|
||||
settings.pop(key, None)
|
||||
else:
|
||||
settings[key] = value
|
||||
self.save()
|
||||
return dict(settings)
|
||||
|
||||
def add_schedule(self, schedule: dict[str, Any]) -> dict[str, Any]:
|
||||
self.load()
|
||||
schedule = dict(schedule)
|
||||
schedule["id"] = int(self._data.setdefault("next_schedule_id", 1))
|
||||
self._data["next_schedule_id"] = schedule["id"] + 1
|
||||
schedule.setdefault("status", "active")
|
||||
schedule.setdefault("created_at", _utc_now().isoformat())
|
||||
schedule.setdefault("next_run_at", compute_next_run(schedule, _utc_now()).isoformat())
|
||||
self._data.setdefault("schedules", []).append(schedule)
|
||||
self.save()
|
||||
return schedule
|
||||
|
||||
def list_schedules(self, chat_id: int | None = None, include_paused: bool = True) -> list[dict[str, Any]]:
|
||||
self.load()
|
||||
schedules = list(self._data.setdefault("schedules", []))
|
||||
if chat_id is not None:
|
||||
schedules = [s for s in schedules if int(s.get("chat_id", 0)) == chat_id]
|
||||
if not include_paused:
|
||||
schedules = [s for s in schedules if s.get("status", "active") == "active"]
|
||||
return schedules
|
||||
|
||||
def get_schedule(self, schedule_id: int) -> dict[str, Any] | None:
|
||||
self.load()
|
||||
for schedule in self._data.setdefault("schedules", []):
|
||||
if int(schedule.get("id", 0)) == schedule_id:
|
||||
return dict(schedule)
|
||||
return None
|
||||
|
||||
def update_schedule(self, schedule: dict[str, Any]) -> None:
|
||||
self.load()
|
||||
schedule_id = int(schedule["id"])
|
||||
schedules = self._data.setdefault("schedules", [])
|
||||
for index, existing in enumerate(schedules):
|
||||
if int(existing.get("id", 0)) == schedule_id:
|
||||
schedules[index] = dict(schedule)
|
||||
self.save()
|
||||
return
|
||||
raise KeyError(f"schedule not found: {schedule_id}")
|
||||
|
||||
def remove_schedule(self, schedule_id: int, chat_id: int | None = None) -> dict[str, Any] | None:
|
||||
self.load()
|
||||
schedules = self._data.setdefault("schedules", [])
|
||||
for index, schedule in enumerate(schedules):
|
||||
if int(schedule.get("id", 0)) != schedule_id:
|
||||
continue
|
||||
if chat_id is not None and int(schedule.get("chat_id", 0)) != chat_id:
|
||||
return None
|
||||
removed = schedules.pop(index)
|
||||
self.save()
|
||||
return dict(removed)
|
||||
return None
|
||||
|
||||
def claim_due_schedules(self, now: datetime | None = None) -> list[dict[str, Any]]:
|
||||
now = now or _utc_now()
|
||||
self.load()
|
||||
claimed: list[dict[str, Any]] = []
|
||||
schedules = self._data.setdefault("schedules", [])
|
||||
for schedule in schedules:
|
||||
if schedule.get("status", "active") != "active":
|
||||
continue
|
||||
next_run_at = _parse_datetime(schedule.get("next_run_at"))
|
||||
if next_run_at is None or next_run_at > now:
|
||||
continue
|
||||
claimed.append(dict(schedule))
|
||||
schedule["last_started_at"] = now.isoformat()
|
||||
schedule["next_run_at"] = compute_next_run(schedule, now).isoformat()
|
||||
if claimed:
|
||||
self.save()
|
||||
return claimed
|
||||
|
||||
def add_personal_record(self, record: dict[str, Any]) -> dict[str, Any]:
|
||||
self.load()
|
||||
record = dict(record)
|
||||
record["id"] = int(self._data.setdefault("next_record_id", 1))
|
||||
self._data["next_record_id"] = record["id"] + 1
|
||||
record.setdefault("status", "active")
|
||||
record.setdefault("created_at", _utc_now().isoformat())
|
||||
record.setdefault("updated_at", record["created_at"])
|
||||
record.setdefault("tags", [])
|
||||
self._data.setdefault("personal_records", []).append(record)
|
||||
self.save()
|
||||
return record
|
||||
|
||||
def list_personal_records(
|
||||
self,
|
||||
chat_id: int | None = None,
|
||||
category: str | None = None,
|
||||
status: str | None = None,
|
||||
query: str | None = None,
|
||||
limit: int = 50,
|
||||
) -> list[dict[str, Any]]:
|
||||
self.load()
|
||||
records = list(self._data.setdefault("personal_records", []))
|
||||
if chat_id is not None:
|
||||
records = [r for r in records if int(r.get("chat_id", 0)) == chat_id]
|
||||
if category:
|
||||
records = [r for r in records if str(r.get("category") or "") == category]
|
||||
if status:
|
||||
records = [r for r in records if str(r.get("status") or "") == status]
|
||||
if query:
|
||||
needle = query.casefold()
|
||||
records = [r for r in records if needle in _record_search_text(r).casefold()]
|
||||
records.sort(key=lambda r: str(r.get("updated_at") or r.get("created_at") or ""), reverse=True)
|
||||
return [dict(r) for r in records[: max(1, limit)]]
|
||||
|
||||
def get_personal_record(self, record_id: int) -> dict[str, Any] | None:
|
||||
self.load()
|
||||
for record in self._data.setdefault("personal_records", []):
|
||||
if int(record.get("id", 0)) == record_id:
|
||||
return dict(record)
|
||||
return None
|
||||
|
||||
def update_personal_record(self, record_id: int, updates: dict[str, Any], chat_id: int | None = None) -> dict[str, Any] | None:
|
||||
self.load()
|
||||
records = self._data.setdefault("personal_records", [])
|
||||
for index, record in enumerate(records):
|
||||
if int(record.get("id", 0)) != record_id:
|
||||
continue
|
||||
if chat_id is not None and int(record.get("chat_id", 0)) != chat_id:
|
||||
return None
|
||||
updated = dict(record)
|
||||
for key, value in updates.items():
|
||||
if value is None:
|
||||
updated.pop(key, None)
|
||||
else:
|
||||
updated[key] = value
|
||||
updated["updated_at"] = _utc_now().isoformat()
|
||||
records[index] = updated
|
||||
self.save()
|
||||
return dict(updated)
|
||||
return None
|
||||
|
||||
def remove_personal_record(self, record_id: int, chat_id: int | None = None) -> dict[str, Any] | None:
|
||||
self.load()
|
||||
records = self._data.setdefault("personal_records", [])
|
||||
for index, record in enumerate(records):
|
||||
if int(record.get("id", 0)) != record_id:
|
||||
continue
|
||||
if chat_id is not None and int(record.get("chat_id", 0)) != chat_id:
|
||||
return None
|
||||
removed = records.pop(index)
|
||||
self.save()
|
||||
return dict(removed)
|
||||
return None
|
||||
|
||||
|
||||
def compute_next_run(schedule: dict[str, Any], after: datetime | None = None) -> datetime:
|
||||
after = after or _utc_now()
|
||||
if after.tzinfo is None:
|
||||
after = after.replace(tzinfo=timezone.utc)
|
||||
|
||||
schedule_type = schedule.get("schedule_type", "daily")
|
||||
if schedule_type == "interval":
|
||||
interval = max(1, int(schedule.get("interval_minutes") or 60))
|
||||
return after + timedelta(minutes=interval)
|
||||
|
||||
tz_name = str(schedule.get("timezone") or "Asia/Seoul")
|
||||
try:
|
||||
tz = ZoneInfo(tz_name)
|
||||
except Exception:
|
||||
tz = ZoneInfo("Asia/Seoul")
|
||||
|
||||
local_after = after.astimezone(tz)
|
||||
hour = int(schedule.get("hour") or 8)
|
||||
minute = int(schedule.get("minute") or 0)
|
||||
candidate = local_after.replace(hour=hour, minute=minute, second=0, microsecond=0)
|
||||
if candidate <= local_after:
|
||||
candidate += timedelta(days=1)
|
||||
return candidate.astimezone(timezone.utc)
|
||||
|
||||
|
||||
def format_schedule(schedule: dict[str, Any]) -> str:
|
||||
schedule_type = schedule.get("schedule_type", "daily")
|
||||
if schedule_type == "interval":
|
||||
cadence = f"every {int(schedule.get('interval_minutes') or 60)} minutes"
|
||||
else:
|
||||
cadence = (
|
||||
f"daily {int(schedule.get('hour') or 8):02}:"
|
||||
f"{int(schedule.get('minute') or 0):02} {schedule.get('timezone') or 'Asia/Seoul'}"
|
||||
)
|
||||
name = schedule.get("name") or "scheduled task"
|
||||
return f"#{schedule.get('id')} {name} - {cadence} - {schedule.get('status', 'active')}"
|
||||
|
||||
|
||||
def format_personal_record(record: dict[str, Any]) -> str:
|
||||
category = record.get("category") or "note"
|
||||
title = record.get("title") or "(untitled)"
|
||||
status = record.get("status") or "active"
|
||||
date_bits = []
|
||||
for key in ("date", "start_date", "end_date", "due_date"):
|
||||
if record.get(key):
|
||||
date_bits.append(f"{key}={record[key]}")
|
||||
tags = record.get("tags") or []
|
||||
tag_text = f" tags={','.join(str(tag) for tag in tags)}" if tags else ""
|
||||
date_text = f" {' '.join(date_bits)}" if date_bits else ""
|
||||
return f"#{record.get('id')} [{category}] {title} - {status}{date_text}{tag_text}"
|
||||
|
||||
|
||||
def _record_search_text(record: dict[str, Any]) -> str:
|
||||
parts: list[str] = []
|
||||
for key in (
|
||||
"category",
|
||||
"title",
|
||||
"content",
|
||||
"status",
|
||||
"date",
|
||||
"start_date",
|
||||
"end_date",
|
||||
"due_date",
|
||||
):
|
||||
if record.get(key):
|
||||
parts.append(str(record[key]))
|
||||
tags = record.get("tags")
|
||||
if isinstance(tags, list):
|
||||
parts.extend(str(tag) for tag in tags)
|
||||
metadata = record.get("metadata")
|
||||
if isinstance(metadata, dict):
|
||||
parts.extend(str(value) for value in metadata.values())
|
||||
return "\n".join(parts)
|
||||
|
||||
|
||||
def _parse_datetime(value: Any) -> datetime | None:
|
||||
if not value:
|
||||
return None
|
||||
try:
|
||||
parsed = datetime.fromisoformat(str(value))
|
||||
except ValueError:
|
||||
return None
|
||||
if parsed.tzinfo is None:
|
||||
parsed = parsed.replace(tzinfo=timezone.utc)
|
||||
return parsed.astimezone(timezone.utc)
|
||||
|
||||
|
||||
def _utc_now() -> datetime:
|
||||
return datetime.now(timezone.utc)
|
||||
31
deploy/README.md
Normal file
31
deploy/README.md
Normal file
@@ -0,0 +1,31 @@
|
||||
# Deployment Notes
|
||||
|
||||
Default NAS target:
|
||||
|
||||
```text
|
||||
host: comtropy.synology.me
|
||||
port: 50022
|
||||
remote dir: /volume1/docker/telegram-codex-bot
|
||||
```
|
||||
|
||||
PowerShell deployment:
|
||||
|
||||
```powershell
|
||||
.\deploy\make-env.ps1
|
||||
.\deploy\deploy.ps1 -NasUser your-nas-admin-user
|
||||
```
|
||||
|
||||
The script uploads files over legacy SCP, then runs:
|
||||
|
||||
```bash
|
||||
docker compose up -d --build
|
||||
```
|
||||
|
||||
If the first Codex command fails inside Telegram, open an SSH session and run:
|
||||
|
||||
```bash
|
||||
cd /volume1/docker/telegram-codex-bot
|
||||
docker compose exec telegram-codex-bot codex login --device-auth
|
||||
```
|
||||
|
||||
The `codex-home` directory is mounted to `/root/.codex` so login state persists.
|
||||
24
deploy/check-status.ps1
Normal file
24
deploy/check-status.ps1
Normal file
@@ -0,0 +1,24 @@
|
||||
param(
|
||||
[string]$NasUser = "y2keui",
|
||||
[string]$NasHost = "comtropy.synology.me",
|
||||
[int]$NasPort = 50022,
|
||||
[string]$RemoteDir = "/volume1/docker/telegram-codex-bot",
|
||||
[string]$SshKeyPath = "$env:USERPROFILE\.ssh\nas_codex_ed25519"
|
||||
)
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
$remote = "${NasUser}@${NasHost}"
|
||||
$sshAuthArgs = @()
|
||||
if ($SshKeyPath -and (Test-Path $SshKeyPath)) {
|
||||
$sshAuthArgs = @("-i", $SshKeyPath, "-o", "IdentitiesOnly=yes")
|
||||
}
|
||||
$remoteCommand = @"
|
||||
export PATH=/usr/local/bin:/var/packages/ContainerManager/target/usr/bin:/usr/bin:/bin:/usr/sbin:/sbin
|
||||
cd '$RemoteDir'
|
||||
sudo /usr/local/bin/docker-compose ps
|
||||
echo '--- logs ---'
|
||||
sudo /usr/local/bin/docker-compose logs --tail=160 telegram-codex-bot
|
||||
"@
|
||||
|
||||
ssh @sshAuthArgs -tt -p $NasPort $remote $remoteCommand
|
||||
69
deploy/deploy.ps1
Normal file
69
deploy/deploy.ps1
Normal file
@@ -0,0 +1,69 @@
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$NasUser,
|
||||
|
||||
[string]$NasHost = "comtropy.synology.me",
|
||||
[int]$NasPort = 50022,
|
||||
[string]$RemoteDir = "/volume1/docker/telegram-codex-bot",
|
||||
[string]$SshKeyPath = "$env:USERPROFILE\.ssh\nas_codex_ed25519"
|
||||
)
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
$root = Split-Path -Parent (Split-Path -Parent $MyInvocation.MyCommand.Path)
|
||||
$remote = "${NasUser}@${NasHost}"
|
||||
$remotePath = "/usr/local/bin:/var/packages/ContainerManager/target/usr/bin:/usr/bin:/bin:/usr/sbin:/sbin"
|
||||
$sshAuthArgs = @()
|
||||
if ($SshKeyPath -and (Test-Path $SshKeyPath)) {
|
||||
$sshAuthArgs = @("-i", $SshKeyPath, "-o", "IdentitiesOnly=yes")
|
||||
}
|
||||
|
||||
if (-not (Test-Path (Join-Path $root ".env"))) {
|
||||
Write-Host "Missing .env. Copy .env.example to .env and fill secrets first." -ForegroundColor Yellow
|
||||
exit 1
|
||||
}
|
||||
|
||||
function Invoke-Checked {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
[scriptblock]$Script,
|
||||
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$Description
|
||||
)
|
||||
|
||||
& $Script
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
throw "$Description failed with exit code $LASTEXITCODE"
|
||||
}
|
||||
}
|
||||
|
||||
Invoke-Checked -Description "Creating remote directory" -Script {
|
||||
ssh @sshAuthArgs -p $NasPort $remote "export PATH='$remotePath'; mkdir -p '$RemoteDir'"
|
||||
}
|
||||
|
||||
Invoke-Checked -Description "Cleaning old uploaded source files" -Script {
|
||||
ssh @sshAuthArgs -p $NasPort $remote "export PATH='$remotePath'; rm -rf '$RemoteDir/app' '$RemoteDir/scripts'"
|
||||
}
|
||||
|
||||
$uploadPaths = @(
|
||||
(Join-Path $root "app"),
|
||||
(Join-Path $root "scripts"),
|
||||
(Join-Path $root ".dockerignore"),
|
||||
(Join-Path $root ".env"),
|
||||
(Join-Path $root "Dockerfile"),
|
||||
(Join-Path $root "README.md"),
|
||||
(Join-Path $root "docker-compose.yml"),
|
||||
(Join-Path $root "requirements.txt")
|
||||
)
|
||||
|
||||
Invoke-Checked -Description "Uploading project files" -Script {
|
||||
$scpArgs = @("-O") + $sshAuthArgs + @("-P", "$NasPort", "-r") + $uploadPaths + @("${remote}:$RemoteDir/")
|
||||
& scp @scpArgs
|
||||
}
|
||||
|
||||
Invoke-Checked -Description "Building and starting container" -Script {
|
||||
ssh @sshAuthArgs -tt -p $NasPort $remote "export PATH='$remotePath'; cd '$RemoteDir' && mkdir -p data workspaces codex-home && (sudo /usr/local/bin/docker-compose up -d --build || sudo /var/packages/ContainerManager/target/usr/bin/docker-compose up -d --build || sudo docker compose up -d --build)"
|
||||
}
|
||||
|
||||
Write-Host "Deployment finished: $RemoteDir"
|
||||
36
deploy/make-env.ps1
Normal file
36
deploy/make-env.ps1
Normal file
@@ -0,0 +1,36 @@
|
||||
param(
|
||||
[string]$WorkspaceRoot = "/workspaces",
|
||||
[string]$StatePath = "/app/data/state.json"
|
||||
)
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
$root = Split-Path -Parent (Split-Path -Parent $MyInvocation.MyCommand.Path)
|
||||
$envPath = Join-Path $root ".env"
|
||||
|
||||
$secureToken = Read-Host "Telegram bot token" -AsSecureString
|
||||
$tokenPtr = [Runtime.InteropServices.Marshal]::SecureStringToBSTR($secureToken)
|
||||
try {
|
||||
$token = [Runtime.InteropServices.Marshal]::PtrToStringBSTR($tokenPtr)
|
||||
}
|
||||
finally {
|
||||
if ($tokenPtr -ne [IntPtr]::Zero) {
|
||||
[Runtime.InteropServices.Marshal]::ZeroFreeBSTR($tokenPtr)
|
||||
}
|
||||
}
|
||||
$allowedIds = Read-Host "Allowed Telegram user ids (comma-separated; leave blank for /whoami only)"
|
||||
|
||||
@"
|
||||
TELEGRAM_BOT_TOKEN=$token
|
||||
ALLOWED_TELEGRAM_USER_IDS=$allowedIds
|
||||
BOT_STATE_PATH=$StatePath
|
||||
BOT_WORKSPACE_ROOT=$WorkspaceRoot
|
||||
CODEX_BIN=codex
|
||||
CODEX_TIMEOUT_SECONDS=1800
|
||||
CODEX_READONLY_SANDBOX=read-only
|
||||
CODEX_WRITE_SANDBOX=workspace-write
|
||||
CODEX_RESUME_JSON_FLAG_STYLE=before_resume
|
||||
ALLOW_PLAIN_TEXT=true
|
||||
"@ | Set-Content -Path $envPath -Encoding UTF8
|
||||
|
||||
Write-Host "Wrote $envPath"
|
||||
31
deploy/register-ssh-key.ps1
Normal file
31
deploy/register-ssh-key.ps1
Normal file
@@ -0,0 +1,31 @@
|
||||
param(
|
||||
[string]$NasUser = "y2keui",
|
||||
[string]$NasHost = "comtropy.synology.me",
|
||||
[int]$NasPort = 50022,
|
||||
[string]$SshKeyPath = "$env:USERPROFILE\.ssh\nas_codex_ed25519"
|
||||
)
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
$publicKeyPath = "$SshKeyPath.pub"
|
||||
if (-not (Test-Path $publicKeyPath)) {
|
||||
Write-Host "Missing public key: $publicKeyPath" -ForegroundColor Yellow
|
||||
exit 1
|
||||
}
|
||||
|
||||
$remote = "${NasUser}@${NasHost}"
|
||||
$remoteCommand = @'
|
||||
export PATH=/usr/local/bin:/var/packages/ContainerManager/target/usr/bin:/usr/bin:/bin:/usr/sbin:/sbin
|
||||
umask 077
|
||||
mkdir -p ~/.ssh
|
||||
cat >> ~/.ssh/authorized_keys
|
||||
chmod 700 ~/.ssh
|
||||
chmod 600 ~/.ssh/authorized_keys
|
||||
'@
|
||||
|
||||
Get-Content -Raw $publicKeyPath | ssh -p $NasPort $remote $remoteCommand
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
throw "Registering SSH public key failed with exit code $LASTEXITCODE"
|
||||
}
|
||||
|
||||
Write-Host "Public key registered: $publicKeyPath"
|
||||
14
docker-compose.yml
Normal file
14
docker-compose.yml
Normal file
@@ -0,0 +1,14 @@
|
||||
services:
|
||||
telegram-codex-bot:
|
||||
build:
|
||||
context: .
|
||||
image: telegram-codex-bot:local
|
||||
container_name: telegram-codex-bot
|
||||
restart: unless-stopped
|
||||
env_file:
|
||||
- .env
|
||||
volumes:
|
||||
- ./data:/app/data
|
||||
- ./workspaces:/workspaces
|
||||
- ./codex-home:/root/.codex
|
||||
|
||||
3
requirements.txt
Normal file
3
requirements.txt
Normal file
@@ -0,0 +1,3 @@
|
||||
python-dotenv>=1.0,<2
|
||||
python-telegram-bot>=21,<23
|
||||
websockets>=13,<16
|
||||
54
scripts/start.sh
Normal file
54
scripts/start.sh
Normal file
@@ -0,0 +1,54 @@
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
|
||||
CODEX_BIN="${CODEX_BIN:-codex}"
|
||||
BOT_WORKSPACE_ROOT="${BOT_WORKSPACE_ROOT:-/workspaces}"
|
||||
CODEX_APP_SERVER_URL="${CODEX_APP_SERVER_URL:-ws://127.0.0.1:4500}"
|
||||
CODEX_APP_SERVER_LISTEN="${CODEX_APP_SERVER_LISTEN:-$CODEX_APP_SERVER_URL}"
|
||||
CODEX_APP_SERVER_ENABLED="${CODEX_APP_SERVER_ENABLED:-true}"
|
||||
CODEX_APP_SERVER_CWD="${CODEX_APP_SERVER_CWD:-$BOT_WORKSPACE_ROOT}"
|
||||
CODEX_APP_SERVER_SANDBOX="${CODEX_APP_SERVER_SANDBOX:-${CODEX_READONLY_SANDBOX:-read-only}}"
|
||||
CODEX_APP_SERVER_APPROVAL_POLICY="${CODEX_APP_SERVER_APPROVAL_POLICY:-never}"
|
||||
|
||||
APP_SERVER_PID=""
|
||||
|
||||
cleanup() {
|
||||
if [ -n "$APP_SERVER_PID" ] && kill -0 "$APP_SERVER_PID" 2>/dev/null; then
|
||||
kill "$APP_SERVER_PID" 2>/dev/null || true
|
||||
wait "$APP_SERVER_PID" 2>/dev/null || true
|
||||
fi
|
||||
}
|
||||
|
||||
trap cleanup INT TERM EXIT
|
||||
|
||||
mkdir -p /app/data "$BOT_WORKSPACE_ROOT" "${CODEX_HOME:-/root/.codex}"
|
||||
python -m app.ensure_codex_config
|
||||
|
||||
if [ "$CODEX_APP_SERVER_ENABLED" = "true" ]; then
|
||||
echo "Starting Codex app-server at $CODEX_APP_SERVER_LISTEN"
|
||||
"$CODEX_BIN" \
|
||||
--cd "$CODEX_APP_SERVER_CWD" \
|
||||
--sandbox "$CODEX_APP_SERVER_SANDBOX" \
|
||||
--ask-for-approval "$CODEX_APP_SERVER_APPROVAL_POLICY" \
|
||||
app-server \
|
||||
--listen "$CODEX_APP_SERVER_LISTEN" \
|
||||
&
|
||||
APP_SERVER_PID="$!"
|
||||
|
||||
if echo "$CODEX_APP_SERVER_LISTEN" | grep -q '^ws://'; then
|
||||
HEALTH_URL="$(echo "$CODEX_APP_SERVER_LISTEN" | sed 's#^ws://#http://#')/readyz"
|
||||
for _ in $(seq 1 30); do
|
||||
if curl -fsS "$HEALTH_URL" >/dev/null 2>&1; then
|
||||
echo "Codex app-server is ready"
|
||||
break
|
||||
fi
|
||||
if ! kill -0 "$APP_SERVER_PID" 2>/dev/null; then
|
||||
echo "Codex app-server exited before becoming ready"
|
||||
break
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
fi
|
||||
fi
|
||||
|
||||
python -m app.main
|
||||
Reference in New Issue
Block a user