feat(telegram): add allowed user gate (#179)

This commit is contained in:
banteg
2026-01-22 13:59:14 +04:00
committed by GitHub
parent 4272a2aef3
commit 656dcfdf31
7 changed files with 92 additions and 1 deletions
+4 -1
View File
@@ -46,6 +46,7 @@ If you expect to edit config while Takopi is running, set:
|-----|------|---------|-------| |-----|------|---------|-------|
| `bot_token` | string | (required) | Telegram bot token from @BotFather. | | `bot_token` | string | (required) | Telegram bot token from @BotFather. |
| `chat_id` | int | (required) | Default chat id. | | `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. | | `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. | | `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. | | `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. | | `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. | | `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` ### `transports.telegram.topics`
| Key | Type | Default | Notes | | 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` | bool | `true` | Auto-save uploads. |
| `auto_put_mode` | `"upload"`\|`"prompt"` | `"upload"` | Whether uploads also start a run. | | `auto_put_mode` | `"upload"`\|`"prompt"` | `"upload"` | Whether uploads also start a run. |
| `uploads_dir` | string | `"incoming"` | Relative path inside the repo/worktree. | | `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`). | | `deny_globs` | string[] | (defaults) | Glob denylist (e.g. `.git/**`, `**/*.pem`). |
File size limits (not configurable): File size limits (not configurable):
+1
View File
@@ -94,6 +94,7 @@ class TelegramTransportSettings(BaseModel):
bot_token: NonEmptyStr bot_token: NonEmptyStr
chat_id: StrictInt chat_id: StrictInt
allowed_user_ids: list[StrictInt] = Field(default_factory=list)
message_overflow: Literal["trim", "split"] = "trim" message_overflow: Literal["trim", "split"] = "trim"
voice_transcription: bool = False voice_transcription: bool = False
voice_max_bytes: StrictInt = 10 * 1024 * 1024 voice_max_bytes: StrictInt = 10 * 1024 * 1024
+1
View File
@@ -142,6 +142,7 @@ class TelegramBackend(TransportBackend):
voice_transcription_api_key=settings.voice_transcription_api_key, voice_transcription_api_key=settings.voice_transcription_api_key,
forward_coalesce_s=settings.forward_coalesce_s, forward_coalesce_s=settings.forward_coalesce_s,
media_group_debounce_s=settings.media_group_debounce_s, media_group_debounce_s=settings.media_group_debounce_s,
allowed_user_ids=tuple(settings.allowed_user_ids),
topics=settings.topics, topics=settings.topics,
files=settings.files, files=settings.files,
) )
+1
View File
@@ -128,6 +128,7 @@ class TelegramBridgeConfig:
voice_transcription_api_key: str | None = None voice_transcription_api_key: str | None = None
forward_coalesce_s: float = 1.0 forward_coalesce_s: float = 1.0
media_group_debounce_s: float = 1.0 media_group_debounce_s: float = 1.0
allowed_user_ids: tuple[int, ...] = ()
files: TelegramFilesSettings = field(default_factory=TelegramFilesSettings) files: TelegramFilesSettings = field(default_factory=TelegramFilesSettings)
chat_ids: tuple[int, ...] | None = None chat_ids: tuple[int, ...] | None = None
topics: TelegramTopicsSettings = field(default_factory=TelegramTopicsSettings) topics: TelegramTopicsSettings = field(default_factory=TelegramTopicsSettings)
+13
View File
@@ -118,6 +118,7 @@ def _allowed_chat_ids(cfg: TelegramBridgeConfig) -> set[int]:
allowed = set(cfg.chat_ids or ()) allowed = set(cfg.chat_ids or ())
allowed.add(cfg.chat_id) allowed.add(cfg.chat_id)
allowed.update(cfg.runtime.project_chat_ids()) allowed.update(cfg.runtime.project_chat_ids())
allowed.update(cfg.allowed_user_ids)
return allowed return allowed
@@ -1779,7 +1780,19 @@ async def run_main_loop(
return return
forward_coalescer.schedule(pending) forward_coalescer.schedule(pending)
allowed_user_ids = set(cfg.allowed_user_ids)
async def route_update(update: TelegramIncomingUpdate) -> None: 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 isinstance(update, TelegramCallbackQuery):
if update.data == CANCEL_CALLBACK_DATA: if update.data == CANCEL_CALLBACK_DATA:
tg.start_soon( tg.start_soon(
+2
View File
@@ -140,6 +140,7 @@ def test_telegram_backend_build_and_run_wires_config(
transport_config = TelegramTransportSettings( transport_config = TelegramTransportSettings(
bot_token="token", bot_token="token",
chat_id=321, chat_id=321,
allowed_user_ids=[7, 8],
voice_transcription=True, voice_transcription=True,
voice_max_bytes=1234, voice_max_bytes=1234,
voice_transcription_model="whisper-1", 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_model == "whisper-1"
assert cfg.voice_transcription_base_url == "http://localhost:8000/v1" assert cfg.voice_transcription_base_url == "http://localhost:8000/v1"
assert cfg.voice_transcription_api_key == "local" assert cfg.voice_transcription_api_key == "local"
assert cfg.allowed_user_ids == (7, 8)
assert cfg.files.enabled is True assert cfg.files.enabled is True
assert cfg.files.allowed_user_ids == [1, 2] assert cfg.files.allowed_user_ids == [1, 2]
assert cfg.topics.enabled is True assert cfg.topics.enabled is True
+70
View File
@@ -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() 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: def test_cancel_command_accepts_extra_text() -> None:
assert is_cancel_command("/cancel now") is True assert is_cancel_command("/cancel now") is True
assert is_cancel_command("/cancel@takopi please") is True assert is_cancel_command("/cancel@takopi please") is True