From 246680a62cbcbca056c6200d33e075d4455b50aa Mon Sep 17 00:00:00 2001 From: banteg <4562643+banteg@users.noreply.github.com> Date: Fri, 16 Jan 2026 02:03:39 +0400 Subject: [PATCH] fix(telegram): handle forwarded uploads and show overrides (#149) --- docs/reference/commands-and-directives.md | 1 + src/takopi/telegram/commands/agent.py | 41 +++++++++++- src/takopi/telegram/engine_overrides.py | 4 +- src/takopi/telegram/loop.py | 8 ++- tests/test_telegram_bridge.py | 78 +++++++++++++++++++++++ 5 files changed, 127 insertions(+), 5 deletions(-) diff --git a/docs/reference/commands-and-directives.md b/docs/reference/commands-and-directives.md index 5b36f1c..e96da66 100644 --- a/docs/reference/commands-and-directives.md +++ b/docs/reference/commands-and-directives.md @@ -38,6 +38,7 @@ This line is parsed from replies and takes precedence over new directives. | `/agent` | Show/set the default agent for the current scope. | | `/model` | Show/set the model override for the current scope. | | `/reasoning` | Show/set the reasoning override for the current scope. | +| `/trigger` | Show/set trigger mode (mentions-only vs all). | | `/file put ` | Upload a document into the repo/worktree (requires file transfer enabled). | | `/file get ` | Fetch a file or directory back into Telegram. | | `/topic @branch` | Create/bind a topic (topics enabled). | diff --git a/src/takopi/telegram/commands/agent.py b/src/takopi/telegram/commands/agent.py index fec3672..2c6bc6d 100644 --- a/src/takopi/telegram/commands/agent.py +++ b/src/takopi/telegram/commands/agent.py @@ -6,6 +6,7 @@ from ...context import RunContext from ...directives import DirectiveError from ..chat_prefs import ChatPrefsStore from ..engine_defaults import resolve_engine_for_message +from ..engine_overrides import resolve_override_value from ..files import split_command_args from ..topic_state import TopicStateStore from ..topics import _topic_key @@ -89,6 +90,40 @@ async def _handle_agent_command( "global_default": "global default", } agent_line = f"agent: {selection.engine} ({source_labels[selection.source]})" + topic_override = None + if tkey is not None and topic_store is not None: + topic_override = await topic_store.get_engine_override( + tkey[0], tkey[1], selection.engine + ) + chat_override = None + if chat_prefs is not None: + chat_override = await chat_prefs.get_engine_override( + msg.chat_id, selection.engine + ) + override_labels = { + "topic_override": "topic override", + "chat_default": "chat default", + "default": "no override", + } + model_resolution = resolve_override_value( + topic_override=topic_override, + chat_override=chat_override, + field="model", + ) + reasoning_resolution = resolve_override_value( + topic_override=topic_override, + chat_override=chat_override, + field="reasoning", + ) + model_value = model_resolution.value or "default" + model_line = ( + f"model: {model_value} ({override_labels[model_resolution.source]})" + ) + reasoning_value = reasoning_resolution.value or "default" + reasoning_line = ( + "reasoning: " + f"{reasoning_value} ({override_labels[reasoning_resolution.source]})" + ) topic_default = selection.topic_default or "none" if tkey is None: topic_default = "none" @@ -110,7 +145,11 @@ async def _handle_agent_command( ) available = ", ".join(cfg.runtime.engine_ids) available_line = f"available: {available}" - await reply(text="\n\n".join([agent_line, defaults_line, available_line])) + await reply( + text="\n\n".join( + [agent_line, model_line, reasoning_line, defaults_line, available_line] + ) + ) return if action == "set": diff --git a/src/takopi/telegram/engine_overrides.py b/src/takopi/telegram/engine_overrides.py index 6cebd89..2071ade 100644 --- a/src/takopi/telegram/engine_overrides.py +++ b/src/takopi/telegram/engine_overrides.py @@ -8,7 +8,6 @@ import msgspec OverrideSource = Literal["topic_override", "chat_default", "default"] REASONING_LEVELS: tuple[str, ...] = ("minimal", "low", "medium", "high", "xhigh") -OPENCODE_REASONING_LEVELS: tuple[str, ...] = ("none", *REASONING_LEVELS) REASONING_SUPPORTED_ENGINES = frozenset({"codex"}) @@ -98,8 +97,7 @@ def resolve_override_value( def allowed_reasoning_levels(engine: str) -> tuple[str, ...]: - if engine == "opencode": - return OPENCODE_REASONING_LEVELS + _ = engine return REASONING_LEVELS diff --git a/src/takopi/telegram/loop.py b/src/takopi/telegram/loop.py index 55fbe0c..8bb51b8 100644 --- a/src/takopi/telegram/loop.py +++ b/src/takopi/telegram/loop.py @@ -1283,7 +1283,13 @@ async def run_main_loop( reply = make_reply(cfg, msg) text = msg.text is_voice_transcribed = False - if _is_forwarded(msg.raw): + is_forward_candidate = ( + _is_forwarded(msg.raw) + and msg.document is None + and msg.voice is None + and msg.media_group_id is None + ) + if is_forward_candidate: _attach_forward(msg) continue forward_key = _forward_key(msg) diff --git a/tests/test_telegram_bridge.py b/tests/test_telegram_bridge.py index ea36068..8d91389 100644 --- a/tests/test_telegram_bridge.py +++ b/tests/test_telegram_bridge.py @@ -2372,6 +2372,84 @@ async def test_run_main_loop_ignores_forwarded_without_prompt() -> None: assert runner.calls == [] +@pytest.mark.anyio +async def test_run_main_loop_forwarded_document_still_uploads( + tmp_path: Path, +) -> None: + payload = b"hello" + + class _UploadBot(_FakeBot): + async def get_file(self, file_id: str) -> File | None: + _ = file_id + return File(file_path="files/hello.txt") + + async def download_file(self, file_path: str) -> bytes | None: + _ = file_path + return payload + + runner = ScriptRunner([Return(answer="ok")], engine=CODEX_ENGINE) + projects = ProjectsConfig( + projects={ + "proj": ProjectConfig( + alias="proj", + path=tmp_path, + worktrees_dir=Path(".worktrees"), + ) + }, + default_project="proj", + ) + runtime = TransportRuntime(router=_make_router(runner), projects=projects) + transport = _FakeTransport() + exec_cfg = ExecBridgeConfig( + transport=transport, + presenter=MarkdownPresenter(), + final_notify=True, + ) + cfg = TelegramBridgeConfig( + bot=_UploadBot(), + runtime=runtime, + chat_id=123, + startup_msg="", + exec_cfg=exec_cfg, + forward_coalesce_s=FAST_FORWARD_COALESCE_S, + media_group_debounce_s=FAST_MEDIA_GROUP_DEBOUNCE_S, + files=TelegramFilesSettings( + enabled=True, + auto_put=True, + auto_put_mode="prompt", + ), + ) + + async def poller(_cfg: TelegramBridgeConfig): + yield TelegramIncomingMessage( + transport="telegram", + chat_id=123, + message_id=1, + text="do thing", + reply_to_message_id=None, + reply_to_text=None, + sender_id=123, + chat_type="private", + document=TelegramDocument( + file_id="doc-1", + file_name="hello.txt", + mime_type="text/plain", + file_size=len(payload), + raw={"file_id": "doc-1"}, + ), + raw={"forward_origin": {"type": "user"}}, + ) + + await run_main_loop(cfg, poller) + + saved_path = tmp_path / "incoming" / "hello.txt" + assert saved_path.read_bytes() == payload + assert runner.calls + prompt_text, _ = runner.calls[0] + assert prompt_text.startswith("do thing") + assert "[uploaded file: incoming/hello.txt]" in prompt_text + + @pytest.mark.anyio async def test_run_main_loop_prompt_upload_auto_resumes_chat_sessions( tmp_path: Path,