diff --git a/docs/reference/config.md b/docs/reference/config.md index d24f14a..ece6c85 100644 --- a/docs/reference/config.md +++ b/docs/reference/config.md @@ -46,6 +46,7 @@ If you expect to edit config while Takopi is running, set: |-----|------|---------|-------| | `bot_token` | string | (required) | Telegram bot token from @BotFather. | | `chat_id` | int | (required) | Default chat id. | +| `allowed_user_ids` | int[] | `[]` | Allowed sender user ids. Empty disables sender filtering; when set, only these users can interact (including DMs). | | `message_overflow` | `"trim"`\|`"split"` | `"trim"` | How to handle long final responses. | | `forward_coalesce_s` | float | `1.0` | Quiet window for combining a prompt with immediately-following forwarded messages; set `0` to disable. | | `voice_transcription` | bool | `false` | Enable voice note transcription. | @@ -56,6 +57,8 @@ If you expect to edit config while Takopi is running, set: | `session_mode` | `"stateless"`\|`"chat"` | `"stateless"` | Auto-resume mode. Onboarding sets `"chat"` for assistant/workspace. | | `show_resume_line` | bool | `true` | Show resume line in message footer. Onboarding sets `false` for assistant/workspace. | +When `allowed_user_ids` is set, updates without a sender id (for example, some channel posts) are ignored. + ### `transports.telegram.topics` | Key | Type | Default | Notes | @@ -71,7 +74,7 @@ If you expect to edit config while Takopi is running, set: | `auto_put` | bool | `true` | Auto-save uploads. | | `auto_put_mode` | `"upload"`\|`"prompt"` | `"upload"` | Whether uploads also start a run. | | `uploads_dir` | string | `"incoming"` | Relative path inside the repo/worktree. | -| `allowed_user_ids` | int[] | `[]` | Allowed senders; empty allows private chats (group usage requires admin). | +| `allowed_user_ids` | int[] | `[]` | Allowed senders for file transfer; empty allows private chats (group usage requires admin). | | `deny_globs` | string[] | (defaults) | Glob denylist (e.g. `.git/**`, `**/*.pem`). | File size limits (not configurable): diff --git a/src/takopi/settings.py b/src/takopi/settings.py index 1669e4f..230876f 100644 --- a/src/takopi/settings.py +++ b/src/takopi/settings.py @@ -94,6 +94,7 @@ class TelegramTransportSettings(BaseModel): bot_token: NonEmptyStr chat_id: StrictInt + allowed_user_ids: list[StrictInt] = Field(default_factory=list) message_overflow: Literal["trim", "split"] = "trim" voice_transcription: bool = False voice_max_bytes: StrictInt = 10 * 1024 * 1024 diff --git a/src/takopi/telegram/backend.py b/src/takopi/telegram/backend.py index fa70784..fdff325 100644 --- a/src/takopi/telegram/backend.py +++ b/src/takopi/telegram/backend.py @@ -142,6 +142,7 @@ class TelegramBackend(TransportBackend): voice_transcription_api_key=settings.voice_transcription_api_key, forward_coalesce_s=settings.forward_coalesce_s, media_group_debounce_s=settings.media_group_debounce_s, + allowed_user_ids=tuple(settings.allowed_user_ids), topics=settings.topics, files=settings.files, ) diff --git a/src/takopi/telegram/bridge.py b/src/takopi/telegram/bridge.py index 68f821e..3c5802e 100644 --- a/src/takopi/telegram/bridge.py +++ b/src/takopi/telegram/bridge.py @@ -128,6 +128,7 @@ class TelegramBridgeConfig: voice_transcription_api_key: str | None = None forward_coalesce_s: float = 1.0 media_group_debounce_s: float = 1.0 + allowed_user_ids: tuple[int, ...] = () files: TelegramFilesSettings = field(default_factory=TelegramFilesSettings) chat_ids: tuple[int, ...] | None = None topics: TelegramTopicsSettings = field(default_factory=TelegramTopicsSettings) diff --git a/src/takopi/telegram/loop.py b/src/takopi/telegram/loop.py index c32187e..8fa69e3 100644 --- a/src/takopi/telegram/loop.py +++ b/src/takopi/telegram/loop.py @@ -118,6 +118,7 @@ def _allowed_chat_ids(cfg: TelegramBridgeConfig) -> set[int]: allowed = set(cfg.chat_ids or ()) allowed.add(cfg.chat_id) allowed.update(cfg.runtime.project_chat_ids()) + allowed.update(cfg.allowed_user_ids) return allowed @@ -1779,7 +1780,19 @@ async def run_main_loop( return forward_coalescer.schedule(pending) + allowed_user_ids = set(cfg.allowed_user_ids) + async def route_update(update: TelegramIncomingUpdate) -> None: + if allowed_user_ids: + sender_id = update.sender_id + if sender_id is None or sender_id not in allowed_user_ids: + logger.debug( + "update.ignored", + reason="sender_not_allowed", + chat_id=update.chat_id, + sender_id=sender_id, + ) + return if isinstance(update, TelegramCallbackQuery): if update.data == CANCEL_CALLBACK_DATA: tg.start_soon( diff --git a/tests/test_telegram_backend.py b/tests/test_telegram_backend.py index 4f690c1..677dbd5 100644 --- a/tests/test_telegram_backend.py +++ b/tests/test_telegram_backend.py @@ -140,6 +140,7 @@ def test_telegram_backend_build_and_run_wires_config( transport_config = TelegramTransportSettings( bot_token="token", chat_id=321, + allowed_user_ids=[7, 8], voice_transcription=True, voice_max_bytes=1234, voice_transcription_model="whisper-1", @@ -165,6 +166,7 @@ def test_telegram_backend_build_and_run_wires_config( assert cfg.voice_transcription_model == "whisper-1" assert cfg.voice_transcription_base_url == "http://localhost:8000/v1" assert cfg.voice_transcription_api_key == "local" + assert cfg.allowed_user_ids == (7, 8) assert cfg.files.enabled is True assert cfg.files.allowed_user_ids == [1, 2] assert cfg.topics.enabled is True diff --git a/tests/test_telegram_bridge.py b/tests/test_telegram_bridge.py index 291e69a..f41da99 100644 --- a/tests/test_telegram_bridge.py +++ b/tests/test_telegram_bridge.py @@ -872,6 +872,76 @@ async def test_handle_callback_cancel_without_task_acknowledges() -> None: assert "nothing is currently running" in bot.callback_calls[-1]["text"].lower() +def test_allowed_chat_ids_include_allowed_user_ids() -> None: + cfg = replace(make_cfg(FakeTransport()), allowed_user_ids=(42,)) + allowed = telegram_loop._allowed_chat_ids(cfg) + assert cfg.chat_id in allowed + assert 42 in allowed + + +@pytest.mark.anyio +async def test_run_main_loop_ignores_disallowed_sender() -> None: + runner = ScriptRunner([Return(answer="ok")], engine=CODEX_ENGINE) + cfg = replace(make_cfg(FakeTransport(), runner), allowed_user_ids=(999,)) + + async def poller(_cfg: TelegramBridgeConfig): + yield TelegramIncomingMessage( + transport="telegram", + chat_id=123, + message_id=1, + text="hello", + reply_to_message_id=None, + reply_to_text=None, + sender_id=123, + ) + + await run_main_loop(cfg, poller) + + assert runner.calls == [] + + +@pytest.mark.anyio +async def test_run_main_loop_ignores_disallowed_callback() -> None: + cfg = replace(make_cfg(FakeTransport()), allowed_user_ids=(999,)) + bot = cast(FakeBot, cfg.bot) + + async def poller(_cfg: TelegramBridgeConfig): + yield TelegramCallbackQuery( + transport="telegram", + chat_id=123, + message_id=42, + callback_query_id="cbq-ignored", + data="takopi:cancel", + sender_id=123, + ) + + await run_main_loop(cfg, poller) + + assert bot.callback_calls == [] + + +@pytest.mark.anyio +async def test_run_main_loop_allows_allowed_sender() -> None: + runner = ScriptRunner([Return(answer="ok")], engine=CODEX_ENGINE) + cfg = replace(make_cfg(FakeTransport(), runner), allowed_user_ids=(123,)) + + async def poller(_cfg: TelegramBridgeConfig): + yield TelegramIncomingMessage( + transport="telegram", + chat_id=123, + message_id=1, + text="hello", + reply_to_message_id=None, + reply_to_text=None, + sender_id=123, + ) + + await run_main_loop(cfg, poller) + + assert runner.calls + assert runner.calls[0][0] == "hello" + + def test_cancel_command_accepts_extra_text() -> None: assert is_cancel_command("/cancel now") is True assert is_cancel_command("/cancel@takopi please") is True