diff --git a/.env.example b/.env.example index 6e7b0a8..b5fba6c 100644 --- a/.env.example +++ b/.env.example @@ -1,6 +1,7 @@ # Telegram TELEGRAM_BOT_TOKEN=put-your-token-here ALLOWED_TELEGRAM_USER_IDS= +WORK_TELEGRAM_CHAT_IDS= # Bot storage and workspace BOT_STATE_PATH=/app/data/state.json @@ -17,14 +18,15 @@ 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_ASSISTANT_SANDBOX=read-only CODEX_READONLY_SANDBOX=danger-full-access CODEX_WRITE_SANDBOX=danger-full-access CODEX_RESUME_JSON_FLAG_STYLE=before_resume -# Optional: set this only if you want plain messages to behave like /ask. +# Set false if you only want slash commands to reach Codex. ALLOW_PLAIN_TEXT=true -# Optional Gitea integration for repository, issue, pull request, and git auth workflows. +# Optional Gitea integration for repository, commit, issue, pull request, and git auth workflows. GITEA_BASE_URL=https://gitea.example.com GITEA_USERNAME= GITEA_EMAIL= diff --git a/README.md b/README.md index 7713d0f..ff2bae7 100644 --- a/README.md +++ b/README.md @@ -35,14 +35,18 @@ only keeps connection-management commands. /unarchive_session ``` -`ALLOW_PLAIN_TEXT=true` makes regular Telegram messages run as full-access Codex -turns. Use `/work ` when you want to be explicit, but regular messages -and `/ask` use the same write-capable sandbox by default. +`ALLOW_PLAIN_TEXT=true` forwards regular Telegram messages to Codex. Set +`WORK_TELEGRAM_CHAT_IDS` to one or more group chat ids to split the bot into +profiles: listed chats run as `work` chats with full-access repository turns, +and every other chat runs as a personal `assistant` chat for schedules, +reminders, saved records, briefings, and lightweight Gitea/status lookup. In +Telegram groups, disable bot privacy with BotFather if you want the bot to +receive normal non-command messages. -On Synology/NAS Docker deployments, keep `CODEX_READONLY_SANDBOX` and -`CODEX_WRITE_SANDBOX` set to `danger-full-access`. The stricter -`workspace-write` sandbox may fail with bubblewrap namespace errors inside the -container. +On Synology/NAS Docker deployments, keep `CODEX_WRITE_SANDBOX` set to +`danger-full-access`. The stricter `workspace-write` sandbox may fail with +bubblewrap namespace errors inside the container. Personal assistant chats use +`CODEX_ASSISTANT_SANDBOX`, which defaults to `read-only`. If no `/repo` is set, the bridge tries to auto-select a workspace folder when a Telegram prompt mentions an exact folder name such as `card_game` or diff --git a/app/bot.py b/app/bot.py index 3276c37..ad44ff6 100644 --- a/app/bot.py +++ b/app/bot.py @@ -254,11 +254,7 @@ class TelegramCodexBot: if not prompt: await self._reply(update, "사용법: /ask <요청 내용>") return - await self._run_codex_from_update( - update, - prompt, - sandbox=self.config.write_sandbox, - ) + await self._run_codex_from_update(update, prompt) async def work(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: if not await self._guard(update): @@ -330,6 +326,7 @@ class TelegramCodexBot: update, "\n".join( [ + f"채팅프로필={self._chat_mode(chat_id)}", f"작업폴더={chat_state.repo_path or '(설정 안 됨)'}", f"백엔드={chat_state.session_backend or '(새 세션)'}", f"세션ID={chat_state.session_id or '(새 세션)'}", @@ -973,8 +970,9 @@ class TelegramCodexBot: ) return - selected_sandbox = sandbox or self.config.write_sandbox - auto_repo_path = self._auto_select_repo_for_prompt(chat_id, prompt) + chat_mode = self._chat_mode(chat_id) + selected_sandbox = sandbox or self._default_sandbox_for_chat(chat_id) + auto_repo_path = self._auto_select_repo_for_prompt(chat_id, prompt) if chat_mode == "work" else None if auto_repo_path is not None: await self._close_client_for_chat(chat_id) preview = _preview_prompt(prompt) @@ -992,7 +990,7 @@ class TelegramCodexBot: prompt_preview=preview, sandbox=selected_sandbox, ) - mode = _sandbox_mode_label(selected_sandbox, self.config) + mode = f"{chat_mode}/{_sandbox_mode_label(selected_sandbox, self.config)}" repo_note = f"\n자동 작업폴더: {auto_repo_path}" if auto_repo_path else "" await self._reply( update, @@ -1209,7 +1207,7 @@ class TelegramCodexBot: result, fallback_note = await self._run_codex_for_chat( chat_id=chat_id, prompt=prompt, - sandbox=self.config.write_sandbox, + sandbox=self._default_sandbox_for_chat(chat_id), update_chat_state=False, session_id_override=schedule.get("thread_id") or None, ) @@ -1297,7 +1295,7 @@ class TelegramCodexBot: return "현재작업=없음" elapsed = int(time.monotonic() - job.started_monotonic) minutes, seconds = divmod(elapsed, 60) - mode = _sandbox_mode_label(job.sandbox, self.config) + mode = f"{self._chat_mode(chat_id)}/{_sandbox_mode_label(job.sandbox, self.config)}" return ( "현재작업=진행 중\n" f"작업모드={mode}\n" @@ -1327,6 +1325,7 @@ class TelegramCodexBot: return ( "[Telegram bridge context]\n" "You are connected to the user through a private Telegram bridge.\n" + f"Chat profile: {self._chat_mode(chat_id)}\n" f"Default Telegram chat_id for replies, schedules, and notifications: {chat_id}\n" f"Current Codex app-server thread_id: {thread_id}\n" f"Current working directory: {cwd}\n" @@ -1339,9 +1338,7 @@ class TelegramCodexBot: "use the gitea_* MCP tools. Never reveal GITEA_TOKEN or print commands that embed it. " "For HTTPS git clone/fetch/push, the container may provide GIT_ASKPASS from the " "configured Gitea environment.\n" - "This bridge is configured for full-access turns by default. You may edit files " - "when the user asks for work that requires changes. Commit or push only when the " - "user explicitly asks for commit/push or when the request clearly includes publishing.\n" + f"{self._chat_mode_instructions(chat_id)}\n" "If the user asks to schedule, remind, monitor, or notify later, use the " "telegram_create_schedule MCP tool. Use the default chat_id above and do not ask " "for a destination unless the user explicitly wants a different chat. The schedule " @@ -1382,6 +1379,30 @@ class TelegramCodexBot: return False return True + def _chat_mode(self, chat_id: int) -> str: + return "work" if chat_id in self.config.work_chat_ids else "assistant" + + def _default_sandbox_for_chat(self, chat_id: int) -> str: + if self._chat_mode(chat_id) == "work": + return self.config.write_sandbox + return self.config.assistant_sandbox + + def _chat_mode_instructions(self, chat_id: int) -> str: + if self._chat_mode(chat_id) == "work": + return ( + "This is the user's work chat. You may perform repository work, edit files, " + "run builds/tests, and prepare commits when the user asks. Commit or push only " + "when the user explicitly asks for commit/push or when the request clearly " + "includes publishing." + ) + return ( + "This is the user's personal assistant chat. Focus on schedules, reminders, saved " + "personal records, weather/news briefings, and lightweight Gitea/status lookup. " + "Do not edit repository files, run coding tasks, commit, or push from this chat. " + "When the user describes development work here, summarize it, save it as a todo or " + "create a Gitea issue when useful, and direct execution to the work chat or Codex app." + ) + def _auto_select_repo_for_prompt(self, chat_id: int, prompt: str) -> Path | None: chat_state = self.state.get_chat(chat_id) if chat_state.repo_path: diff --git a/app/config.py b/app/config.py index 4bdf2fe..7721a40 100644 --- a/app/config.py +++ b/app/config.py @@ -45,10 +45,12 @@ class Config: app_server_model: str | None app_server_reasoning_effort: str | None app_server_speed_mode: str + assistant_sandbox: str readonly_sandbox: str write_sandbox: str resume_json_flag_style: str allow_plain_text: bool + work_chat_ids: set[int] timezone: str schedule_poll_seconds: int persistent_ws_idle_seconds: int @@ -90,12 +92,14 @@ class Config: app_server_model=app_server_model, app_server_reasoning_effort=app_server_reasoning_effort, app_server_speed_mode=app_server_speed_mode, + assistant_sandbox=os.getenv("CODEX_ASSISTANT_SANDBOX", "read-only"), readonly_sandbox=os.getenv("CODEX_READONLY_SANDBOX", "danger-full-access"), write_sandbox=os.getenv("CODEX_WRITE_SANDBOX", "danger-full-access"), resume_json_flag_style=os.getenv( "CODEX_RESUME_JSON_FLAG_STYLE", "before_resume" ), allow_plain_text=_bool_env("ALLOW_PLAIN_TEXT", True), + work_chat_ids=_id_set("WORK_TELEGRAM_CHAT_IDS"), 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),