feat(telegram): add allowed user gate (#179)
This commit is contained in:
@@ -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):
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user