Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 0 additions & 14 deletions bot/codex/screenshot_ui.py

This file was deleted.

2 changes: 2 additions & 0 deletions bot/handlers/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,13 @@
from .help import router as help_router
from .history import router as history_router
from .messages import router as messages_router
from .screenshot import router as screenshot_router
from .send import router as send_router
from .toolbar import router as toolbar_router

__all__ = [
"messages_router",
"screenshot_router",
"callbacks_router",
"codex_router",
"help_router",
Expand Down
14 changes: 2 additions & 12 deletions bot/handlers/codex.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,15 +12,14 @@
from typing import Any

from aiogram import F, Router
from aiogram.dispatcher.event.bases import SkipHandler
from aiogram.filters import Command
from aiogram.types import CallbackQuery, Message
from aiogram.dispatcher.event.bases import SkipHandler

from bot.codex import (
command_flow,
model_ui,
reply_ui,
screenshot_ui,
session_ui,
shell_ui,
stop_ui,
Expand Down Expand Up @@ -48,8 +47,8 @@
from core.orchestrator import Orchestrator
from core.session import SessionKey
from extensions.codex.daemon import codex_daemon
from storage.context_manager import ContextManager
from i18n import resolve_locale, tr
from storage.context_manager import ContextManager

router = Router(name="codex")

Expand Down Expand Up @@ -375,12 +374,3 @@ async def handle_shell_approve_callback(
orchestrator: Orchestrator,
) -> None:
await shell_ui.handle_shell_approve_callback(callback_query, orchestrator, execute_shell=_execute_shell_telegram)

# ---------------------------------------------------------------------------
# /screenshot command handler
# ---------------------------------------------------------------------------


@router.message(Command("screenshot"))
async def cmd_screenshot(message: Message) -> None:
await screenshot_ui.handle_screenshot_command(message)
179 changes: 179 additions & 0 deletions bot/handlers/screenshot.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,179 @@
"""Telegram-native desktop screenshot commands and monitor picker."""

from __future__ import annotations

import html
from contextlib import suppress

from aiogram import F, Router
from aiogram.filters import Command
from aiogram.types import (
BufferedInputFile,
CallbackQuery,
InlineKeyboardButton,
InlineKeyboardMarkup,
Message,
)
from aiogram.types import (
User as TelegramUser,
)
from loguru import logger
from sqlalchemy import select

from bot.utils.routing import TelegramRoute
from i18n import resolve_locale, tr
from storage.context_manager import ContextManager
from storage.models import User
from utils.screenshot import (
DisplayMonitor,
capture_desktop_screenshot,
list_display_monitors,
)

router = Router(name="screenshot")

_CALLBACK_PREFIX = "screenshot:"
_CALLBACK_DATA_LIMIT = 64


async def _resolve_user_locale(
context_manager: ContextManager | None,
user: TelegramUser | None,
) -> str:
fallback_code = user.language_code if user else None
if context_manager is None or user is None:
return resolve_locale(None, fallback_code)

result = await context_manager.session.execute(select(User).where(User.id == user.id))
stored_user = result.scalar_one_or_none()
return resolve_locale(
getattr(stored_user, "ui_language", None) if stored_user else None,
fallback_code,
)


def _monitor_callback_data(user_id: int, monitor: DisplayMonitor) -> str:
prefix = f"{_CALLBACK_PREFIX}{user_id}:"
available = _CALLBACK_DATA_LIMIT - len(prefix.encode("utf-8"))
identifier = monitor.identifier.encode("utf-8")[:available].decode("utf-8", errors="ignore")
return f"{prefix}{identifier}"


def build_monitor_keyboard(
monitors: list[DisplayMonitor],
user_id: int,
locale: str,
) -> InlineKeyboardMarkup:
"""Build one full-width row for each available display."""
rows: list[list[InlineKeyboardButton]] = []
for monitor in monitors:
key = "bot.screenshot.monitor_button_primary" if monitor.is_primary else "bot.screenshot.monitor_button"
rows.append(
[
InlineKeyboardButton(
text=tr(
key,
locale,
name=monitor.name,
width=monitor.width,
height=monitor.height,
),
callback_data=_monitor_callback_data(user_id, monitor),
)
]
)
return InlineKeyboardMarkup(inline_keyboard=rows)


async def _send_monitor_screenshot(
message: Message,
route: TelegramRoute,
locale: str,
monitor: DisplayMonitor | None,
) -> bool:
png_bytes = await capture_desktop_screenshot(monitor)
if png_bytes is None:
await message.answer(
tr("bot.screenshot.capture_failed", locale),
**route.send_kwargs(),
)
return False

monitor_label = monitor.name if monitor else tr("bot.screenshot.default_monitor", locale)
try:
await message.answer_photo(
photo=BufferedInputFile(png_bytes, filename="screenshot.png"),
caption=tr("bot.screenshot.caption", locale, monitor=monitor_label),
**route.send_kwargs(),
)
except Exception as exc:
logger.warning(f"Failed to send desktop screenshot: {exc}")
await message.answer(
tr("bot.screenshot.send_failed", locale, error=html.escape(str(exc))),
**route.send_kwargs(),
)
return False
return True


@router.message(Command("screenshot"))
async def cmd_screenshot(
message: Message,
context_manager: ContextManager | None = None,
) -> None:
"""Capture the only display or ask the user which display to capture."""
route = TelegramRoute.from_message(message)
locale = await _resolve_user_locale(context_manager, message.from_user)
monitors = list_display_monitors()

if len(monitors) > 1:
user_id = message.from_user.id if message.from_user else 0
await message.answer(
tr("bot.screenshot.select_monitor", locale),
reply_markup=build_monitor_keyboard(monitors, user_id, locale),
**route.send_kwargs(),
)
return

await _send_monitor_screenshot(
message,
route,
locale,
monitors[0] if monitors else None,
)


@router.callback_query(F.data.startswith(_CALLBACK_PREFIX))
async def handle_screenshot_callback(
callback: CallbackQuery,
context_manager: ContextManager | None = None,
) -> None:
"""Capture the display selected by the user who opened the picker."""
locale = await _resolve_user_locale(context_manager, callback.from_user)
data = callback.data or ""
parts = data.split(":", maxsplit=2)
if len(parts) != 3 or not parts[1].isdigit():
await callback.answer(tr("bot.screenshot.monitor_unavailable", locale), show_alert=True)
return

owner_id = int(parts[1])
if callback.from_user.id != owner_id:
await callback.answer(tr("bot.screenshot.picker_owner_mismatch", locale), show_alert=True)
return

monitor = next(
(candidate for candidate in list_display_monitors() if _monitor_callback_data(owner_id, candidate) == data),
None,
)
if monitor is None or not isinstance(callback.message, Message):
await callback.answer(tr("bot.screenshot.monitor_unavailable", locale), show_alert=True)
return

await callback.answer(tr("bot.screenshot.capturing", locale))
with suppress(Exception):
await callback.message.edit_reply_markup(reply_markup=None)

route = TelegramRoute.from_message(callback.message)
if await _send_monitor_screenshot(callback.message, route, locale, monitor):
with suppress(Exception):
await callback.message.delete()
2 changes: 1 addition & 1 deletion bot/startup.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ async def send_message(self, chat_id: int, text: str, **kwargs: Any) -> Any:
BotCommand(command="stop", description="Interrupt the active Codex or shell task"),
BotCommand(command="live", description="Toggle live session status"),
BotCommand(command="last", description="Resend the last assistant reply"),
BotCommand(command="screenshot", description="Capture the current desktop"),
BotCommand(command="screenshot", description="Capture a desktop display"),
)


Expand Down
3 changes: 2 additions & 1 deletion docs/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
---
title: Changelog
category: changelog
last_updated: 2026-07-03
last_updated: 2026-07-12
relevance: medium
summary: Human-maintained release notes for Telegodex
related: [PRODUCT_EXPERIENCE.md, RICH_MESSAGES.md, STARTUP.md]
Expand All @@ -11,6 +11,7 @@ related: [PRODUCT_EXPERIENCE.md, RICH_MESSAGES.md, STARTUP.md]

## Unreleased

- Moved `/screenshot` to the Bot-native supervision surface and added a Windows multi-display picker with one full-width name-and-resolution button per monitor.
- Replaced the single-text `/help` reply with a paginated ebook system: `/help` now opens a table-of-contents inline keyboard (max 6 chapters per page), chapter content is rendered as Rich Message Markdown sourced from per-language `.md` files under `i18n/help/<locale>/`, with YAML frontmatter, page breaks, JSONL error logging to `i18n/help/log/`, and English as the base fallback language.
- Improved Telegram inline keyboard layout so settings and action menus use compact rows with full-width footer buttons.
- Added i18n system.
Expand Down
4 changes: 2 additions & 2 deletions docs/USAGE.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ Telegodex runs as a Telegram Workbench for local CLI AI workflows. Its primary r
| `/stop` | Interrupt the active Codex turn or shell process |
| `/live` | Toggle live session status |
| `/last` | Resend the last assistant reply |
| `/screenshot` | Capture the current desktop |
| `/screenshot` | Capture a desktop display; choose one when several are connected |

Telegodex syncs this command menu with Telegram on startup through `setMyCommands`. BotFather is still where the bot token and platform-level bot settings live, but users should not need to maintain the slash-command menu manually.

Expand Down Expand Up @@ -146,4 +146,4 @@ Keep `.env`, local `provider.toml`, database files, and logs out of commits. `.e

If a provider call fails, confirm the API key, base URL, model name, and network path. For custom providers, test the endpoint with a minimal OpenAI-compatible chat-completions request.

If `/screenshot` fails with an empty-image warning even though Pillow is installed, focus or unminimize the terminal/window and retry. Telegodex falls back from terminal-window capture to full-screen capture, but Windows can still return an empty image for minimized, protected, or unavailable capture surfaces.
If `/screenshot` cannot capture a display, make sure the Windows desktop session is unlocked and Pillow or pyautogui is installed. When a selected display was disconnected or rearranged, run `/screenshot` again to refresh the display list.
9 changes: 0 additions & 9 deletions i18n/help/en/codex.md
Original file line number Diff line number Diff line change
Expand Up @@ -104,15 +104,6 @@ This is the fastest path from "I want this done" to "it is running".

---

## Screenshots

`/screenshot` captures the current terminal window where Codex is running
and sends it as a photo. Use it when you want to verify what Codex is
looking at right now — file listings, editor state, build output — without
leaving Telegram.

---

## Configuration

Codex Bridge auto-detects the `codex` binary from `PATH` and common install
Expand Down
2 changes: 1 addition & 1 deletion i18n/help/en/getting-started.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ bottom of the chat. This chapter lists every entry point.
| `/history` | View conversation history |
| `/codex` | Drive Codex CLI as a subprocess (see 🛠️ Codex Bridge) |
| `/shell` | Propose or run a shell command through Codex |
| `/screenshot` | Capture the current terminal window |
| `/screenshot` | Capture a desktop display; choose one when several are connected |

Commands are case-insensitive and can be typed anywhere in the chat.

Expand Down
8 changes: 0 additions & 8 deletions i18n/help/zh-cn/codex.md
Original file line number Diff line number Diff line change
Expand Up @@ -97,14 +97,6 @@ Codex 执行:

---

## 截图

`/screenshot` 捕获 Codex 当前所在终端窗口,并以图片形式发送。当你想
核对 Codex 此刻正在看什么——文件列表、编辑器状态、构建输出——而不
离开 Telegram 时使用它。

---

## 配置

Codex 桥接会从 `PATH` 与常见安装路径自动探测 `codex` 可执行文件。仅
Expand Down
2 changes: 1 addition & 1 deletion i18n/help/zh-cn/getting-started.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ Telegodex 通过 Telegram 命令与聊天窗口底部的菜单按钮进行操作
| `/history` | 查看对话历史 |
| `/codex` | 以子进程方式驱动 Codex CLI(见 🛠️ Codex 桥接) |
| `/shell` | 通过 Codex 提议或执行 Shell 命令 |
| `/screenshot` | 捕获当前终端窗口 |
| `/screenshot` | 截取桌面显示器;多屏时可先选择 |

命令不区分大小写,可以在对话任意位置输入。

Expand Down
12 changes: 12 additions & 0 deletions i18n/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,18 @@
"codex_command_unhandled": "Codex commands open or control dedicated Codex topics. Use <code>/codex new</code> or <code>/codex resume &lt;thread-id&gt;</code> to open one.",
"codex_topic_command_unsupported": "This is a Codex topic. Use plain messages to continue the Codex thread, or use <code>/codex status</code> for Codex-specific controls. Open general Bot settings and AI chat commands from All or a non-Codex topic."
},
"screenshot": {
"select_monitor": "Choose a display to capture:",
"monitor_button": "{name} · {width}x{height}",
"monitor_button_primary": "{name} · {width}x{height} · Primary",
"capturing": "Capturing display...",
"capture_failed": "Could not capture this display. Make sure the desktop session is unlocked and Pillow or pyautogui is available.",
"send_failed": "Failed to send screenshot: {error}",
"caption": "Desktop screenshot · {monitor}",
"default_monitor": "default display",
"monitor_unavailable": "That display is no longer available. Run /screenshot again.",
"picker_owner_mismatch": "This display picker belongs to another user."
},
"common": {
"back": "« Back",
"close": "« Close",
Expand Down
12 changes: 12 additions & 0 deletions i18n/locales/zh-cn.json
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,18 @@
"codex_command_unhandled": "Codex 指令用于打开或控制专属 Codex Topic。请使用 <code>/codex new</code> 或 <code>/codex resume &lt;thread-id&gt;</code> 打开一个 Codex Topic。",
"codex_topic_command_unsupported": "这里是 Codex Topic。请直接发送普通消息继续当前 Codex thread,或使用 <code>/codex status</code> 等 Codex 专属控制。通用 Bot 设置和普通 AI Chat 指令请在 All 或非 Codex Topic 中使用。"
},
"screenshot": {
"select_monitor": "请选择要截图的显示器:",
"monitor_button": "{name} · {width}x{height}",
"monitor_button_primary": "{name} · {width}x{height} · 主屏幕",
"capturing": "正在截取显示器...",
"capture_failed": "无法截取该显示器。请确认桌面会话未锁定,并已安装 Pillow 或 pyautogui。",
"send_failed": "截图发送失败:{error}",
"caption": "桌面截图 · {monitor}",
"default_monitor": "默认显示器",
"monitor_unavailable": "该显示器已不可用,请重新发送 /screenshot。",
"picker_owner_mismatch": "这个显示器选择器属于其他用户。"
},
"common": {
"back": "« 返回",
"close": "« 关闭",
Expand Down
Loading
Loading