diff --git a/src/untether/telegram/commands/media.py b/src/untether/telegram/commands/media.py index a3e28879..b22547a6 100644 --- a/src/untether/telegram/commands/media.py +++ b/src/untether/telegram/commands/media.py @@ -22,6 +22,7 @@ if TYPE_CHECKING: from ..bridge import TelegramBridgeConfig + from ..chat_prefs import ChatPrefsStore async def _handle_media_group( @@ -37,6 +38,7 @@ async def _handle_media_group( Awaitable[ResolvedMessage | None], ] | None = None, + chat_prefs: ChatPrefsStore | None = None, ) -> None: if not messages: return @@ -47,15 +49,25 @@ async def _handle_media_group( ) reply = make_reply(cfg, command_msg) topic_key = _topic_key(command_msg, cfg) if topic_store is not None else None + # keep in sync with loop.py:build_message_context — same ambient-context + # fallback ladder: topic-bound → chat_prefs chat-bound → topic-merged default. chat_project = _topics_chat_project(cfg, command_msg.chat_id) bound_context = ( await topic_store.get_context(*topic_key) if topic_store is not None and topic_key is not None else None ) - ambient_context = _merge_topic_context( - chat_project=chat_project, bound=bound_context - ) + chat_bound_context = None + if chat_prefs is not None: + chat_bound_context = await chat_prefs.get_context(command_msg.chat_id) + if bound_context is not None: + ambient_context = _merge_topic_context( + chat_project=chat_project, bound=bound_context + ) + elif chat_bound_context is not None: + ambient_context = chat_bound_context + else: + ambient_context = _merge_topic_context(chat_project=chat_project, bound=None) command_id, args_text = _parse_slash_command(command_msg.text) if command_id == "file": command, rest, error = parse_file_command(args_text) diff --git a/src/untether/telegram/loop.py b/src/untether/telegram/loop.py index a19130b9..85aaa64c 100644 --- a/src/untether/telegram/loop.py +++ b/src/untether/telegram/loop.py @@ -1102,6 +1102,7 @@ async def _flush_media_group(self, key: tuple[int, str]) -> None: self._topic_store, self._run_prompt_from_upload, self._resolve_prompt_message, + chat_prefs=self._chat_prefs, ) logger.debug( "media_group.flush.ok", diff --git a/tests/test_telegram_media_command.py b/tests/test_telegram_media_command.py index 8a6bed97..cf6a0e4e 100644 --- a/tests/test_telegram_media_command.py +++ b/tests/test_telegram_media_command.py @@ -89,6 +89,49 @@ async def _fake_handle(*_args, **_kwargs) -> None: assert calls["count"] == 1 +class _FakeChatPrefs: + """Minimal ChatPrefsStore stub — only ``get_context`` is exercised.""" + + def __init__(self, context: RunContext | None) -> None: + self._context = context + + async def get_context(self, _chat_id: int) -> RunContext | None: + return self._context + + +@pytest.mark.anyio +async def test_media_group_uses_chat_prefs_bound_context(monkeypatch) -> None: + """Media-group uploads must fall back to chat_prefs-bound context. + + Regression for the channelo bug: with no topic binding, no default_project + and no chat_map entry, a media group resolved ambient_context=None and + failed with "no project context available". The single-file path already + consulted chat_prefs; the media-group path did not. + """ + transport = FakeTransport() + cfg = replace(make_cfg(transport), files=TelegramFilesSettings(enabled=True)) + msg = _msg("") + captured: dict[str, RunContext | None] = {} + + async def _fake_handle( + _cfg, _msg, _rest, _ordered, ambient_context, _topic_store + ) -> None: + captured["ambient_context"] = ambient_context + + monkeypatch.setattr(media_commands, "_handle_file_put_group", _fake_handle) + + await media_commands._handle_media_group( + cfg, + [msg], + topic_store=None, + chat_prefs=_FakeChatPrefs(RunContext(project="auditor-toolkit")), + ) + + ambient = captured["ambient_context"] + assert ambient is not None + assert ambient.project == "auditor-toolkit" + + @pytest.mark.anyio async def test_media_group_auto_put_prompt_resolve_none(monkeypatch) -> None: transport = FakeTransport()