Initial commit

This commit is contained in:
2026-06-08 22:19:08 +09:00
commit 5a2c5df041
22 changed files with 3520 additions and 0 deletions

8
.dockerignore Normal file
View File

@@ -0,0 +1,8 @@
.env
.venv
__pycache__
.pytest_cache
data
codex-home
workspaces
*.log

25
.env.example Normal file
View 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
View File

@@ -0,0 +1,9 @@
.env
.venv/
__pycache__/
*.py[cod]
.pytest_cache/
data/
deploy/.env
*.log

36
Dockerfile Normal file
View 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
View 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
View File

@@ -0,0 +1,2 @@
"""Telegram Codex bot package."""

525
app/app_server_runner.py Normal file
View 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

File diff suppressed because it is too large Load Diff

476
app/bridge_mcp.py Normal file
View 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
View 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
View 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),
)

View 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
View 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
View 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
View 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
View 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
View 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
View 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"

View 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
View 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
View File

@@ -0,0 +1,3 @@
python-dotenv>=1.0,<2
python-telegram-bot>=21,<23
websockets>=13,<16

54
scripts/start.sh Normal file
View 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