Initial commit
This commit is contained in:
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)
|
||||
Reference in New Issue
Block a user