Split Telegram assistant and work chats
This commit is contained in:
@@ -1,6 +1,7 @@
|
|||||||
# Telegram
|
# Telegram
|
||||||
TELEGRAM_BOT_TOKEN=put-your-token-here
|
TELEGRAM_BOT_TOKEN=put-your-token-here
|
||||||
ALLOWED_TELEGRAM_USER_IDS=
|
ALLOWED_TELEGRAM_USER_IDS=
|
||||||
|
WORK_TELEGRAM_CHAT_IDS=
|
||||||
|
|
||||||
# Bot storage and workspace
|
# Bot storage and workspace
|
||||||
BOT_STATE_PATH=/app/data/state.json
|
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_APP_SERVER_MODEL=gpt-5.5
|
||||||
CODEX_MODEL_REASONING_EFFORT=high
|
CODEX_MODEL_REASONING_EFFORT=high
|
||||||
CODEX_SPEED_MODE=standard
|
CODEX_SPEED_MODE=standard
|
||||||
|
CODEX_ASSISTANT_SANDBOX=read-only
|
||||||
CODEX_READONLY_SANDBOX=danger-full-access
|
CODEX_READONLY_SANDBOX=danger-full-access
|
||||||
CODEX_WRITE_SANDBOX=danger-full-access
|
CODEX_WRITE_SANDBOX=danger-full-access
|
||||||
CODEX_RESUME_JSON_FLAG_STYLE=before_resume
|
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
|
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_BASE_URL=https://gitea.example.com
|
||||||
GITEA_USERNAME=
|
GITEA_USERNAME=
|
||||||
GITEA_EMAIL=
|
GITEA_EMAIL=
|
||||||
|
|||||||
18
README.md
18
README.md
@@ -35,14 +35,18 @@ only keeps connection-management commands.
|
|||||||
/unarchive_session <thread_id>
|
/unarchive_session <thread_id>
|
||||||
```
|
```
|
||||||
|
|
||||||
`ALLOW_PLAIN_TEXT=true` makes regular Telegram messages run as full-access Codex
|
`ALLOW_PLAIN_TEXT=true` forwards regular Telegram messages to Codex. Set
|
||||||
turns. Use `/work <prompt>` when you want to be explicit, but regular messages
|
`WORK_TELEGRAM_CHAT_IDS` to one or more group chat ids to split the bot into
|
||||||
and `/ask` use the same write-capable sandbox by default.
|
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
|
On Synology/NAS Docker deployments, keep `CODEX_WRITE_SANDBOX` set to
|
||||||
`CODEX_WRITE_SANDBOX` set to `danger-full-access`. The stricter
|
`danger-full-access`. The stricter `workspace-write` sandbox may fail with
|
||||||
`workspace-write` sandbox may fail with bubblewrap namespace errors inside the
|
bubblewrap namespace errors inside the container. Personal assistant chats use
|
||||||
container.
|
`CODEX_ASSISTANT_SANDBOX`, which defaults to `read-only`.
|
||||||
|
|
||||||
If no `/repo` is set, the bridge tries to auto-select a workspace folder when a
|
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
|
Telegram prompt mentions an exact folder name such as `card_game` or
|
||||||
|
|||||||
47
app/bot.py
47
app/bot.py
@@ -254,11 +254,7 @@ class TelegramCodexBot:
|
|||||||
if not prompt:
|
if not prompt:
|
||||||
await self._reply(update, "사용법: /ask <요청 내용>")
|
await self._reply(update, "사용법: /ask <요청 내용>")
|
||||||
return
|
return
|
||||||
await self._run_codex_from_update(
|
await self._run_codex_from_update(update, prompt)
|
||||||
update,
|
|
||||||
prompt,
|
|
||||||
sandbox=self.config.write_sandbox,
|
|
||||||
)
|
|
||||||
|
|
||||||
async def work(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
|
async def work(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
|
||||||
if not await self._guard(update):
|
if not await self._guard(update):
|
||||||
@@ -330,6 +326,7 @@ class TelegramCodexBot:
|
|||||||
update,
|
update,
|
||||||
"\n".join(
|
"\n".join(
|
||||||
[
|
[
|
||||||
|
f"채팅프로필={self._chat_mode(chat_id)}",
|
||||||
f"작업폴더={chat_state.repo_path or '(설정 안 됨)'}",
|
f"작업폴더={chat_state.repo_path or '(설정 안 됨)'}",
|
||||||
f"백엔드={chat_state.session_backend or '(새 세션)'}",
|
f"백엔드={chat_state.session_backend or '(새 세션)'}",
|
||||||
f"세션ID={chat_state.session_id or '(새 세션)'}",
|
f"세션ID={chat_state.session_id or '(새 세션)'}",
|
||||||
@@ -973,8 +970,9 @@ class TelegramCodexBot:
|
|||||||
)
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
selected_sandbox = sandbox or self.config.write_sandbox
|
chat_mode = self._chat_mode(chat_id)
|
||||||
auto_repo_path = self._auto_select_repo_for_prompt(chat_id, prompt)
|
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:
|
if auto_repo_path is not None:
|
||||||
await self._close_client_for_chat(chat_id)
|
await self._close_client_for_chat(chat_id)
|
||||||
preview = _preview_prompt(prompt)
|
preview = _preview_prompt(prompt)
|
||||||
@@ -992,7 +990,7 @@ class TelegramCodexBot:
|
|||||||
prompt_preview=preview,
|
prompt_preview=preview,
|
||||||
sandbox=selected_sandbox,
|
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 ""
|
repo_note = f"\n자동 작업폴더: {auto_repo_path}" if auto_repo_path else ""
|
||||||
await self._reply(
|
await self._reply(
|
||||||
update,
|
update,
|
||||||
@@ -1209,7 +1207,7 @@ class TelegramCodexBot:
|
|||||||
result, fallback_note = await self._run_codex_for_chat(
|
result, fallback_note = await self._run_codex_for_chat(
|
||||||
chat_id=chat_id,
|
chat_id=chat_id,
|
||||||
prompt=prompt,
|
prompt=prompt,
|
||||||
sandbox=self.config.write_sandbox,
|
sandbox=self._default_sandbox_for_chat(chat_id),
|
||||||
update_chat_state=False,
|
update_chat_state=False,
|
||||||
session_id_override=schedule.get("thread_id") or None,
|
session_id_override=schedule.get("thread_id") or None,
|
||||||
)
|
)
|
||||||
@@ -1297,7 +1295,7 @@ class TelegramCodexBot:
|
|||||||
return "현재작업=없음"
|
return "현재작업=없음"
|
||||||
elapsed = int(time.monotonic() - job.started_monotonic)
|
elapsed = int(time.monotonic() - job.started_monotonic)
|
||||||
minutes, seconds = divmod(elapsed, 60)
|
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 (
|
return (
|
||||||
"현재작업=진행 중\n"
|
"현재작업=진행 중\n"
|
||||||
f"작업모드={mode}\n"
|
f"작업모드={mode}\n"
|
||||||
@@ -1327,6 +1325,7 @@ class TelegramCodexBot:
|
|||||||
return (
|
return (
|
||||||
"[Telegram bridge context]\n"
|
"[Telegram bridge context]\n"
|
||||||
"You are connected to the user through a private Telegram bridge.\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"Default Telegram chat_id for replies, schedules, and notifications: {chat_id}\n"
|
||||||
f"Current Codex app-server thread_id: {thread_id}\n"
|
f"Current Codex app-server thread_id: {thread_id}\n"
|
||||||
f"Current working directory: {cwd}\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. "
|
"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 "
|
"For HTTPS git clone/fetch/push, the container may provide GIT_ASKPASS from the "
|
||||||
"configured Gitea environment.\n"
|
"configured Gitea environment.\n"
|
||||||
"This bridge is configured for full-access turns by default. You may edit files "
|
f"{self._chat_mode_instructions(chat_id)}\n"
|
||||||
"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"
|
|
||||||
"If the user asks to schedule, remind, monitor, or notify later, use the "
|
"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 "
|
"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 "
|
"for a destination unless the user explicitly wants a different chat. The schedule "
|
||||||
@@ -1382,6 +1379,30 @@ class TelegramCodexBot:
|
|||||||
return False
|
return False
|
||||||
return True
|
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:
|
def _auto_select_repo_for_prompt(self, chat_id: int, prompt: str) -> Path | None:
|
||||||
chat_state = self.state.get_chat(chat_id)
|
chat_state = self.state.get_chat(chat_id)
|
||||||
if chat_state.repo_path:
|
if chat_state.repo_path:
|
||||||
|
|||||||
@@ -45,10 +45,12 @@ class Config:
|
|||||||
app_server_model: str | None
|
app_server_model: str | None
|
||||||
app_server_reasoning_effort: str | None
|
app_server_reasoning_effort: str | None
|
||||||
app_server_speed_mode: str
|
app_server_speed_mode: str
|
||||||
|
assistant_sandbox: str
|
||||||
readonly_sandbox: str
|
readonly_sandbox: str
|
||||||
write_sandbox: str
|
write_sandbox: str
|
||||||
resume_json_flag_style: str
|
resume_json_flag_style: str
|
||||||
allow_plain_text: bool
|
allow_plain_text: bool
|
||||||
|
work_chat_ids: set[int]
|
||||||
timezone: str
|
timezone: str
|
||||||
schedule_poll_seconds: int
|
schedule_poll_seconds: int
|
||||||
persistent_ws_idle_seconds: int
|
persistent_ws_idle_seconds: int
|
||||||
@@ -90,12 +92,14 @@ class Config:
|
|||||||
app_server_model=app_server_model,
|
app_server_model=app_server_model,
|
||||||
app_server_reasoning_effort=app_server_reasoning_effort,
|
app_server_reasoning_effort=app_server_reasoning_effort,
|
||||||
app_server_speed_mode=app_server_speed_mode,
|
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"),
|
readonly_sandbox=os.getenv("CODEX_READONLY_SANDBOX", "danger-full-access"),
|
||||||
write_sandbox=os.getenv("CODEX_WRITE_SANDBOX", "danger-full-access"),
|
write_sandbox=os.getenv("CODEX_WRITE_SANDBOX", "danger-full-access"),
|
||||||
resume_json_flag_style=os.getenv(
|
resume_json_flag_style=os.getenv(
|
||||||
"CODEX_RESUME_JSON_FLAG_STYLE", "before_resume"
|
"CODEX_RESUME_JSON_FLAG_STYLE", "before_resume"
|
||||||
),
|
),
|
||||||
allow_plain_text=_bool_env("ALLOW_PLAIN_TEXT", True),
|
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"),
|
timezone=os.getenv("BOT_TIMEZONE", "Asia/Seoul"),
|
||||||
schedule_poll_seconds=_int_env("SCHEDULE_POLL_SECONDS", 30),
|
schedule_poll_seconds=_int_env("SCHEDULE_POLL_SECONDS", 30),
|
||||||
persistent_ws_idle_seconds=_int_env("PERSISTENT_WS_IDLE_SECONDS", 600),
|
persistent_ws_idle_seconds=_int_env("PERSISTENT_WS_IDLE_SECONDS", 600),
|
||||||
|
|||||||
Reference in New Issue
Block a user