feat(telegram): add file transfer support (#83)

This commit is contained in:
banteg
2026-01-11 05:32:31 +04:00
committed by GitHub
parent 22d010ece3
commit ab1ecc277d
15 changed files with 2047 additions and 48 deletions
+71 -2
View File
@@ -333,7 +333,67 @@ When you send a voice note, takopi transcribes it and runs the result as a norma
--- ---
## 9. Configuration reference ---
## 9. File transfer
Upload files into the active repo/worktree or fetch files back into Telegram.
### Upload a file
Send a document with a caption:
```
/file put <path>
```
Examples:
```
/file put docs/spec.pdf
/file put /happy-gadgets @feat/camera assets/logo.png
```
If you send a file **without a caption**, takopi saves it to:
```
incoming/<original_filename>
```
Use `--force` to overwrite an existing file:
```
/file put --force docs/spec.pdf
```
### Fetch a file
Send:
```
/file get <path>
```
Directories are zipped automatically.
### File transfer config
```toml
[transports.telegram.files]
enabled = true
auto_put = true
uploads_dir = "incoming"
allowed_user_ids = [123456789]
deny_globs = [".git/**", ".env", ".envrc", "**/*.pem", "**/.ssh/**"]
```
Notes:
- File transfer is **disabled by default**.
- If `allowed_user_ids` is empty, private chats are allowed and group usage requires admin privileges.
---
## 10. Configuration reference
Full example with all options: Full example with all options:
@@ -349,6 +409,13 @@ bot_token = "123456789:ABCdefGHIjklMNOpqrsTUVwxyz"
chat_id = 123456789 chat_id = 123456789
voice_transcription = true voice_transcription = true
[transports.telegram.files]
enabled = true
auto_put = true
uploads_dir = "incoming"
allowed_user_ids = [123456789]
deny_globs = [".git/**", ".env", ".envrc", "**/*.pem", "**/.ssh/**"]
[transports.telegram.topics] [transports.telegram.topics]
enabled = true enabled = true
scope = "auto" scope = "auto"
@@ -370,7 +437,7 @@ worktree_base = "develop"
--- ---
## 10. Command cheatsheet ## 11. Command cheatsheet
### Message directives ### Message directives
@@ -386,6 +453,8 @@ worktree_base = "develop"
| Command | Description | | Command | Description |
|---------|-------------| |---------|-------------|
| `/cancel` | Reply to the progress message to stop the current run | | `/cancel` | Reply to the progress message to stop the current run |
| `/file put <path>` | Upload a document into the repo/worktree |
| `/file get <path>` | Fetch a file (directories are zipped) |
| `/topic <project> @branch` | Create/bind a topic | | `/topic <project> @branch` | Create/bind a topic |
| `/ctx` | Show current context | | `/ctx` | Show current context |
| `/ctx set <project> @branch` | Update context binding | | `/ctx set <project> @branch` | Update context binding |
+7
View File
@@ -20,6 +20,8 @@ parallel runs across threads, per thread queue support.
optional voice note transcription for telegram (routes transcript like typed text). optional voice note transcription for telegram (routes transcript like typed text).
telegram file transfer: upload documents into repos (`/file put`) and fetch files back (`/file get`).
telegram forum topics: bind a topic to a project/branch and keep per-topic session resumes. telegram forum topics: bind a topic to a project/branch and keep per-topic session resumes.
per-project chat routing: assign different telegram chats to different projects. per-project chat routing: assign different telegram chats to different projects.
@@ -71,6 +73,11 @@ bot_token = "123456789:ABCdefGHIjklMNOpqrsTUVwxyz"
chat_id = 123456789 chat_id = 123456789
voice_transcription = true voice_transcription = true
[transports.telegram.files]
enabled = true
auto_put = true
allowed_user_ids = [123456789]
[transports.telegram.topics] [transports.telegram.topics]
enabled = true enabled = true
-1
View File
@@ -274,7 +274,6 @@ def _run_auto_router(
) -> None: ) -> None:
if debug: if debug:
os.environ.setdefault("TAKOPI_LOG_FILE", "debug.log") os.environ.setdefault("TAKOPI_LOG_FILE", "debug.log")
os.environ.setdefault("TAKOPI_LOG_FORMAT", "json")
setup_logging(debug=debug) setup_logging(debug=debug)
lock_handle: LockHandle | None = None lock_handle: LockHandle | None = None
try: try:
+1 -1
View File
@@ -6,7 +6,7 @@ ID_PATTERN = r"^[a-z0-9_]{1,32}$"
_ID_RE = re.compile(ID_PATTERN) _ID_RE = re.compile(ID_PATTERN)
RESERVED_CLI_COMMANDS = frozenset({"init", "plugins"}) RESERVED_CLI_COMMANDS = frozenset({"init", "plugins"})
RESERVED_CHAT_COMMANDS = frozenset({"cancel"}) RESERVED_CHAT_COMMANDS = frozenset({"cancel", "file"})
RESERVED_ENGINE_IDS = RESERVED_CLI_COMMANDS | RESERVED_CHAT_COMMANDS RESERVED_ENGINE_IDS = RESERVED_CLI_COMMANDS | RESERVED_CHAT_COMMANDS
RESERVED_COMMAND_IDS = RESERVED_CLI_COMMANDS | RESERVED_CHAT_COMMANDS RESERVED_COMMAND_IDS = RESERVED_CLI_COMMANDS | RESERVED_CHAT_COMMANDS
+62
View File
@@ -46,6 +46,67 @@ class TelegramTopicsSettings(BaseModel):
return cleaned return cleaned
class TelegramFilesSettings(BaseModel):
model_config = ConfigDict(extra="forbid")
enabled: bool = False
auto_put: bool = True
uploads_dir: str = "incoming"
allowed_user_ids: list[int] = Field(default_factory=list)
deny_globs: list[str] = Field(
default_factory=lambda: [
".git/**",
".env",
".envrc",
"**/*.pem",
"**/.ssh/**",
]
)
@field_validator("uploads_dir", mode="before")
@classmethod
def _validate_uploads_dir(cls, value: Any) -> Any:
if value is None:
raise ValueError("files.uploads_dir must be a string")
if not isinstance(value, str):
raise ValueError("files.uploads_dir must be a string")
cleaned = value.strip()
if not cleaned:
raise ValueError("files.uploads_dir must be a non-empty string")
if Path(cleaned).is_absolute():
raise ValueError("files.uploads_dir must be a relative path")
return cleaned
@field_validator("allowed_user_ids", mode="before")
@classmethod
def _validate_allowed_users(cls, value: Any) -> Any:
if value is None:
return []
if not isinstance(value, list):
raise ValueError("files.allowed_user_ids must be a list of integers")
for item in value:
if isinstance(item, bool) or not isinstance(item, int):
raise ValueError("files.allowed_user_ids must be a list of integers")
return value
@field_validator("deny_globs", mode="before")
@classmethod
def _validate_deny_globs(cls, value: Any) -> Any:
if value is None:
return []
if not isinstance(value, list):
raise ValueError("files.deny_globs must be a list of strings")
cleaned: list[str] = []
for item in value:
if not isinstance(item, str):
raise ValueError("files.deny_globs must be a list of strings")
stripped = item.strip()
if not stripped:
raise ValueError("files.deny_globs entries must be non-empty strings")
cleaned.append(stripped)
return cleaned
class TelegramTransportSettings(BaseModel): class TelegramTransportSettings(BaseModel):
model_config = ConfigDict(extra="forbid") model_config = ConfigDict(extra="forbid")
@@ -53,6 +114,7 @@ class TelegramTransportSettings(BaseModel):
chat_id: int | None = None chat_id: int | None = None
voice_transcription: bool = False voice_transcription: bool = False
topics: TelegramTopicsSettings = Field(default_factory=TelegramTopicsSettings) topics: TelegramTopicsSettings = Field(default_factory=TelegramTopicsSettings)
files: TelegramFilesSettings = Field(default_factory=TelegramFilesSettings)
@field_validator("bot_token", mode="before") @field_validator("bot_token", mode="before")
@classmethod @classmethod
+2
View File
@@ -3,6 +3,7 @@
from .client import parse_incoming_update, poll_incoming from .client import parse_incoming_update, poll_incoming
from .types import ( from .types import (
TelegramCallbackQuery, TelegramCallbackQuery,
TelegramDocument,
TelegramIncomingMessage, TelegramIncomingMessage,
TelegramIncomingUpdate, TelegramIncomingUpdate,
TelegramVoice, TelegramVoice,
@@ -10,6 +11,7 @@ from .types import (
__all__ = [ __all__ = [
"TelegramCallbackQuery", "TelegramCallbackQuery",
"TelegramDocument",
"TelegramIncomingMessage", "TelegramIncomingMessage",
"TelegramIncomingUpdate", "TelegramIncomingUpdate",
"TelegramVoice", "TelegramVoice",
+32 -1
View File
@@ -11,13 +11,19 @@ from ..config import ConfigError
from ..logging import get_logger from ..logging import get_logger
from pydantic import ValidationError from pydantic import ValidationError
from ..settings import TelegramTopicsSettings, load_settings, require_telegram_config from ..settings import (
TelegramFilesSettings,
TelegramTopicsSettings,
load_settings,
require_telegram_config,
)
from ..transports import SetupResult, TransportBackend from ..transports import SetupResult, TransportBackend
from ..transport_runtime import TransportRuntime from ..transport_runtime import TransportRuntime
from .bridge import ( from .bridge import (
TelegramBridgeConfig, TelegramBridgeConfig,
TelegramPresenter, TelegramPresenter,
TelegramTransport, TelegramTransport,
TelegramFilesConfig,
TelegramTopicsConfig, TelegramTopicsConfig,
TelegramVoiceTranscriptionConfig, TelegramVoiceTranscriptionConfig,
run_main_loop, run_main_loop,
@@ -79,6 +85,29 @@ def _build_topics_config(
) )
def _build_files_config(
transport_config: dict[str, object],
*,
config_path: Path,
) -> TelegramFilesConfig:
raw = transport_config.get("files") or {}
if not isinstance(raw, dict):
raise ConfigError(
f"Invalid `transports.telegram.files` in {config_path}; expected a table."
)
try:
settings = TelegramFilesSettings.model_validate(raw)
except ValidationError as exc:
raise ConfigError(f"Invalid files config in {config_path}: {exc}") from exc
return TelegramFilesConfig(
enabled=settings.enabled,
auto_put=settings.auto_put,
uploads_dir=settings.uploads_dir,
allowed_user_ids=frozenset(settings.allowed_user_ids),
deny_globs=tuple(settings.deny_globs),
)
class TelegramBackend(TransportBackend): class TelegramBackend(TransportBackend):
id = "telegram" id = "telegram"
description = "Telegram bot" description = "Telegram bot"
@@ -135,6 +164,7 @@ class TelegramBackend(TransportBackend):
) )
voice_transcription = _build_voice_transcription_config(transport_config) voice_transcription = _build_voice_transcription_config(transport_config)
topics = _build_topics_config(transport_config, config_path=config_path) topics = _build_topics_config(transport_config, config_path=config_path)
files = _build_files_config(transport_config, config_path=config_path)
cfg = TelegramBridgeConfig( cfg = TelegramBridgeConfig(
bot=bot, bot=bot,
runtime=runtime, runtime=runtime,
@@ -143,6 +173,7 @@ class TelegramBackend(TransportBackend):
exec_cfg=exec_cfg, exec_cfg=exec_cfg,
voice_transcription=voice_transcription, voice_transcription=voice_transcription,
topics=topics, topics=topics,
files=files,
) )
async def run_loop() -> None: async def run_loop() -> None:
File diff suppressed because it is too large Load Diff
+235 -7
View File
@@ -21,6 +21,7 @@ import anyio
from ..logging import get_logger from ..logging import get_logger
from .types import ( from .types import (
TelegramCallbackQuery, TelegramCallbackQuery,
TelegramDocument,
TelegramIncomingMessage, TelegramIncomingMessage,
TelegramIncomingUpdate, TelegramIncomingUpdate,
TelegramVoice, TelegramVoice,
@@ -74,15 +75,39 @@ def _parse_incoming_message(
chat_id: int | None = None, chat_id: int | None = None,
chat_ids: set[int] | None = None, chat_ids: set[int] | None = None,
) -> TelegramIncomingMessage | None: ) -> TelegramIncomingMessage | None:
text = msg.get("text") def _parse_document_payload(payload: dict[str, Any]) -> TelegramDocument | None:
voice_payload: TelegramVoice | None = None file_id = payload.get("file_id")
if not isinstance(text, str):
voice = msg.get("voice")
if not isinstance(voice, dict):
return None
file_id = voice.get("file_id")
if not isinstance(file_id, str) or not file_id: if not isinstance(file_id, str) or not file_id:
return None return None
return TelegramDocument(
file_id=file_id,
file_name=payload.get("file_name")
if isinstance(payload.get("file_name"), str)
else None,
mime_type=payload.get("mime_type")
if isinstance(payload.get("mime_type"), str)
else None,
file_size=payload.get("file_size")
if isinstance(payload.get("file_size"), int)
and not isinstance(payload.get("file_size"), bool)
else None,
raw=payload,
)
raw_text = msg.get("text")
text = raw_text if isinstance(raw_text, str) else None
caption = msg.get("caption")
if text is None and isinstance(caption, str):
text = caption
if text is None:
text = ""
voice_payload: TelegramVoice | None = None
voice = msg.get("voice")
if isinstance(voice, dict):
file_id = voice.get("file_id")
if not isinstance(file_id, str) or not file_id:
file_id = None
if file_id is not None:
voice_payload = TelegramVoice( voice_payload = TelegramVoice(
file_id=file_id, file_id=file_id,
mime_type=voice.get("mime_type") mime_type=voice.get("mime_type")
@@ -98,7 +123,49 @@ def _parse_incoming_message(
else None, else None,
raw=voice, raw=voice,
) )
if not isinstance(raw_text, str) and not isinstance(caption, str):
text = "" text = ""
document_payload: TelegramDocument | None = None
document = msg.get("document")
if isinstance(document, dict):
document_payload = _parse_document_payload(document)
if document_payload is None:
video = msg.get("video")
if isinstance(video, dict):
document_payload = _parse_document_payload(video)
if document_payload is None:
photo = msg.get("photo")
if isinstance(photo, list):
best: dict[str, Any] | None = None
best_score = -1
for item in photo:
if not isinstance(item, dict):
continue
file_id = item.get("file_id")
if not isinstance(file_id, str) or not file_id:
continue
size = item.get("file_size")
if isinstance(size, int) and not isinstance(size, bool):
score = size
else:
width = item.get("width")
height = item.get("height")
if isinstance(width, int) and isinstance(height, int):
score = width * height
else:
score = 0
if score > best_score:
best_score = score
best = item
if best is not None:
document_payload = _parse_document_payload(best)
if document_payload is None:
sticker = msg.get("sticker")
if isinstance(sticker, dict):
document_payload = _parse_document_payload(sticker)
has_text = isinstance(raw_text, str) or isinstance(caption, str)
if not has_text and voice_payload is None and document_payload is None:
return None
chat = msg.get("chat") chat = msg.get("chat")
if not isinstance(chat, dict): if not isinstance(chat, dict):
return None return None
@@ -135,6 +202,9 @@ def _parse_incoming_message(
if isinstance(sender, dict) and isinstance(sender.get("id"), int) if isinstance(sender, dict) and isinstance(sender.get("id"), int)
else None else None
) )
media_group_id = msg.get("media_group_id")
if not isinstance(media_group_id, str):
media_group_id = None
thread_id = msg.get("message_thread_id") thread_id = msg.get("message_thread_id")
if isinstance(thread_id, bool) or not isinstance(thread_id, int): if isinstance(thread_id, bool) or not isinstance(thread_id, int):
thread_id = None thread_id = None
@@ -149,11 +219,13 @@ def _parse_incoming_message(
reply_to_message_id=reply_to_message_id, reply_to_message_id=reply_to_message_id,
reply_to_text=reply_to_text, reply_to_text=reply_to_text,
sender_id=sender_id, sender_id=sender_id,
media_group_id=media_group_id,
thread_id=thread_id, thread_id=thread_id,
is_topic_message=is_topic_message, is_topic_message=is_topic_message,
chat_type=chat_type, chat_type=chat_type,
is_forum=is_forum, is_forum=is_forum,
voice=voice_payload, voice=voice_payload,
document=document_payload,
raw=msg, raw=msg,
) )
@@ -259,6 +331,17 @@ class BotClient(Protocol):
replace_message_id: int | None = None, replace_message_id: int | None = None,
) -> dict | None: ... ) -> dict | None: ...
async def send_document(
self,
chat_id: int,
filename: str,
content: bytes,
reply_to_message_id: int | None = None,
message_thread_id: int | None = None,
disable_notification: bool | None = False,
caption: str | None = None,
) -> dict | None: ...
async def edit_message_text( async def edit_message_text(
self, self,
chat_id: int, chat_id: int,
@@ -683,6 +766,106 @@ class TelegramClient:
logger.debug("telegram.response", method=method, payload=payload) logger.debug("telegram.response", method=method, payload=payload)
return payload.get("result") return payload.get("result")
async def _post_form(
self,
method: str,
data: dict[str, Any],
files: dict[str, Any],
) -> Any | None:
if self._http_client is None or self._base is None:
raise RuntimeError("TelegramClient is configured without an HTTP client.")
logger.debug("telegram.request", method=method, payload=data)
try:
resp = await self._http_client.post(
f"{self._base}/{method}", data=data, files=files
)
except httpx.HTTPError as e:
url = getattr(e.request, "url", None)
logger.error(
"telegram.network_error",
method=method,
url=str(url) if url is not None else None,
error=str(e),
error_type=e.__class__.__name__,
)
return None
try:
resp.raise_for_status()
except httpx.HTTPStatusError as e:
if resp.status_code == 429:
retry_after: float | None = None
try:
payload = resp.json()
except Exception:
payload = None
if isinstance(payload, dict):
retry_after = retry_after_from_payload(payload)
retry_after = 5.0 if retry_after is None else retry_after
logger.warning(
"telegram.rate_limited",
method=method,
status=resp.status_code,
url=str(resp.request.url),
retry_after=retry_after,
)
raise TelegramRetryAfter(retry_after) from e
body = resp.text
logger.error(
"telegram.http_error",
method=method,
status=resp.status_code,
url=str(resp.request.url),
error=str(e),
body=body,
)
return None
try:
payload = resp.json()
except Exception as e:
body = resp.text
logger.error(
"telegram.bad_response",
method=method,
status=resp.status_code,
error=str(e),
error_type=e.__class__.__name__,
body=body,
)
return None
if not isinstance(payload, dict):
logger.error(
"telegram.invalid_payload",
method=method,
url=str(resp.request.url),
payload=payload,
)
return None
if not payload.get("ok"):
if payload.get("error_code") == 429:
retry_after = retry_after_from_payload(payload)
retry_after = 5.0 if retry_after is None else retry_after
logger.warning(
"telegram.rate_limited",
method=method,
url=str(resp.request.url),
retry_after=retry_after,
)
raise TelegramRetryAfter(retry_after)
logger.error(
"telegram.api_error",
method=method,
url=str(resp.request.url),
payload=payload,
)
return None
logger.debug("telegram.response", method=method, payload=payload)
return payload.get("result")
async def get_updates( async def get_updates(
self, self,
offset: int | None, offset: int | None,
@@ -806,6 +989,51 @@ class TelegramClient:
await self.delete_message(chat_id=chat_id, message_id=replace_message_id) await self.delete_message(chat_id=chat_id, message_id=replace_message_id)
return result return result
async def send_document(
self,
chat_id: int,
filename: str,
content: bytes,
reply_to_message_id: int | None = None,
message_thread_id: int | None = None,
disable_notification: bool | None = False,
caption: str | None = None,
) -> dict | None:
async def execute() -> dict | None:
if self._client_override is not None:
return await self._client_override.send_document(
chat_id=chat_id,
filename=filename,
content=content,
reply_to_message_id=reply_to_message_id,
message_thread_id=message_thread_id,
disable_notification=disable_notification,
caption=caption,
)
params: dict[str, Any] = {"chat_id": chat_id}
if disable_notification is not None:
params["disable_notification"] = disable_notification
if reply_to_message_id is not None:
params["reply_to_message_id"] = reply_to_message_id
if message_thread_id is not None:
params["message_thread_id"] = message_thread_id
if caption is not None:
params["caption"] = caption
result = await self._post_form(
"sendDocument",
params,
files={"document": (filename, content)},
)
return result if isinstance(result, dict) else None
return await self.enqueue_op(
key=self.unique_key("send_document"),
label="send_document",
execute=execute,
priority=SEND_PRIORITY,
chat_id=chat_id,
)
async def edit_message_text( async def edit_message_text(
self, self,
chat_id: int, chat_id: int,
+153
View File
@@ -0,0 +1,153 @@
from __future__ import annotations
import io
import shlex
import tempfile
import zipfile
from collections.abc import Sequence
from pathlib import Path, PurePosixPath
def split_command_args(text: str) -> tuple[str, ...]:
if not text.strip():
return ()
try:
return tuple(shlex.split(text))
except ValueError:
return tuple(text.split())
def file_usage() -> str:
return "usage: `/file put <path>` or `/file get <path>`"
def file_put_usage() -> str:
return "usage: `/file put <path>`"
def file_get_usage() -> str:
return "usage: `/file get <path>`"
def parse_file_command(args_text: str) -> tuple[str | None, str, str | None]:
tokens = split_command_args(args_text)
if not tokens:
return None, "", file_usage()
command = tokens[0].lower()
rest = " ".join(tokens[1:]).strip()
if command not in {"put", "get"}:
return None, rest, file_usage()
return command, rest, None
def parse_file_prompt(
prompt: str, *, allow_empty: bool
) -> tuple[str | None, bool, str | None]:
tokens = split_command_args(prompt)
force = False
parts: list[str] = []
for token in tokens:
if token == "--force":
force = True
continue
if token.startswith("--"):
return None, force, f"unknown flag: {token}"
parts.append(token)
path = " ".join(parts).strip()
if not path and not allow_empty:
return None, force, "missing path"
return (path or None), force, None
def normalize_relative_path(value: str) -> Path | None:
cleaned = value.strip()
if not cleaned:
return None
if cleaned.startswith("~"):
return None
path = Path(cleaned)
if path.is_absolute():
return None
parts = [part for part in path.parts if part not in {"", "."}]
if not parts:
return None
if ".." in parts:
return None
if ".git" in parts:
return None
return Path(*parts)
def resolve_path_within_root(root: Path, rel_path: Path) -> Path | None:
root_resolved = root.resolve(strict=False)
target = (root / rel_path).resolve(strict=False)
if not target.is_relative_to(root_resolved):
return None
return target
def deny_reason(rel_path: Path, deny_globs: Sequence[str]) -> str | None:
if ".git" in rel_path.parts:
return ".git/**"
posix = PurePosixPath(rel_path.as_posix())
for pattern in deny_globs:
if posix.match(pattern):
return pattern
return None
def format_bytes(value: int) -> str:
size = max(0.0, float(value))
units = ("b", "kb", "mb", "gb", "tb")
for unit in units:
if size < 1024 or unit == units[-1]:
if unit == "b":
return f"{int(size)} b"
if size < 10:
return f"{size:.1f} {unit}"
return f"{size:.0f} {unit}"
size /= 1024
return f"{int(size)} B"
def default_upload_name(filename: str | None, file_path: str | None) -> str:
name = Path(filename or "").name
if not name and file_path:
name = Path(file_path).name
if not name:
name = "upload.bin"
return name
def default_upload_path(
uploads_dir: str, filename: str | None, file_path: str | None
) -> Path:
return Path(uploads_dir) / default_upload_name(filename, file_path)
def write_bytes_atomic(path: Path, payload: bytes) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
with tempfile.NamedTemporaryFile(
mode="wb", delete=False, dir=path.parent, prefix=".takopi-upload-"
) as handle:
handle.write(payload)
temp_name = handle.name
Path(temp_name).replace(path)
def zip_directory(
root: Path,
rel_path: Path,
deny_globs: Sequence[str],
) -> bytes:
target = root / rel_path
buffer = io.BytesIO()
with zipfile.ZipFile(buffer, "w", compression=zipfile.ZIP_DEFLATED) as archive:
for item in sorted(target.rglob("*")):
if item.is_dir():
continue
rel_item = rel_path / item.relative_to(target)
if deny_reason(rel_item, deny_globs) is not None:
continue
archive.write(item, arcname=rel_item.as_posix())
return buffer.getvalue()
+11
View File
@@ -13,6 +13,15 @@ class TelegramVoice:
raw: dict[str, Any] raw: dict[str, Any]
@dataclass(frozen=True, slots=True)
class TelegramDocument:
file_id: str
file_name: str | None
mime_type: str | None
file_size: int | None
raw: dict[str, Any]
@dataclass(frozen=True, slots=True) @dataclass(frozen=True, slots=True)
class TelegramIncomingMessage: class TelegramIncomingMessage:
transport: str transport: str
@@ -22,11 +31,13 @@ class TelegramIncomingMessage:
reply_to_message_id: int | None reply_to_message_id: int | None
reply_to_text: str | None reply_to_text: str | None
sender_id: int | None sender_id: int | None
media_group_id: str | None = None
thread_id: int | None = None thread_id: int | None = None
is_topic_message: bool | None = None is_topic_message: bool | None = None
chat_type: str | None = None chat_type: str | None = None
is_forum: bool | None = None is_forum: bool | None = None
voice: TelegramVoice | None = None voice: TelegramVoice | None = None
document: TelegramDocument | None = None
raw: dict[str, Any] | None = None raw: dict[str, Any] | None = None
+112
View File
@@ -0,0 +1,112 @@
from __future__ import annotations
from pathlib import Path
from typing import Any
import pytest
from takopi.config import ConfigError, empty_projects_config
from takopi.model import EngineId
from takopi.router import AutoRouter, RunnerEntry
from takopi.runners.mock import Return, ScriptRunner
from takopi.telegram import backend as telegram_backend
from takopi.transport_runtime import TransportRuntime
def test_build_startup_message_includes_missing_engines(tmp_path: Path) -> None:
codex = EngineId("codex")
pi = EngineId("pi")
runner = ScriptRunner([Return(answer="ok")], engine=codex)
missing = ScriptRunner([Return(answer="ok")], engine=pi)
router = AutoRouter(
entries=[
RunnerEntry(engine=codex, runner=runner, available=True),
RunnerEntry(engine=pi, runner=missing, available=False, issue="missing"),
],
default_engine=codex,
)
runtime = TransportRuntime(router=router, projects=empty_projects_config())
message = telegram_backend._build_startup_message(
runtime, startup_pwd=str(tmp_path)
)
assert "takopi is ready" in message
assert "agents: `codex (not installed: pi)`" in message
assert "projects: `none`" in message
def test_telegram_backend_build_and_run_wires_config(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
config_path = tmp_path / "takopi.toml"
config_path.write_text(
'watch_config = true\ntransport = "telegram"\n\n'
"[transports.telegram]\n"
'bot_token = "token"\n'
"chat_id = 321\n",
encoding="utf-8",
)
codex = EngineId("codex")
runner = ScriptRunner([Return(answer="ok")], engine=codex)
router = AutoRouter(
entries=[RunnerEntry(engine=codex, runner=runner, available=True)],
default_engine=codex,
)
runtime = TransportRuntime(router=router, projects=empty_projects_config())
captured: dict[str, Any] = {}
async def fake_run_main_loop(cfg, **kwargs) -> None:
captured["cfg"] = cfg
captured["kwargs"] = kwargs
class _FakeClient:
def __init__(self, token: str) -> None:
self.token = token
async def close(self) -> None:
return None
monkeypatch.setattr(telegram_backend, "run_main_loop", fake_run_main_loop)
monkeypatch.setattr(telegram_backend, "TelegramClient", _FakeClient)
transport_config = {
"bot_token": "token",
"chat_id": 321,
"voice_transcription": True,
"files": {"enabled": True, "allowed_user_ids": [1, 2]},
"topics": {"enabled": True, "scope": "main"},
}
telegram_backend.TelegramBackend().build_and_run(
transport_config=transport_config,
config_path=config_path,
runtime=runtime,
final_notify=False,
default_engine_override=None,
)
cfg = captured["cfg"]
kwargs = captured["kwargs"]
assert cfg.chat_id == 321
assert cfg.voice_transcription is not None
assert cfg.voice_transcription.enabled is True
assert cfg.files.enabled is True
assert cfg.files.allowed_user_ids == frozenset({1, 2})
assert cfg.topics.enabled is True
assert cfg.bot.token == "token"
assert kwargs["watch_config"] is True
assert kwargs["transport_id"] == "telegram"
def test_build_files_config_rejects_non_dict(tmp_path: Path) -> None:
config_path = tmp_path / "takopi.toml"
transport_config: dict[str, object] = {"files": ["nope"]}
with pytest.raises(ConfigError, match="transports.telegram.files"):
telegram_backend._build_files_config(
transport_config,
config_path=config_path,
)
+292 -1
View File
@@ -10,6 +10,7 @@ import takopi.telegram.bridge as bridge
from takopi.directives import parse_directives from takopi.directives import parse_directives
from takopi.telegram.bridge import ( from takopi.telegram.bridge import (
TelegramBridgeConfig, TelegramBridgeConfig,
TelegramFilesConfig,
TelegramPresenter, TelegramPresenter,
TelegramTransport, TelegramTransport,
_build_bot_commands, _build_bot_commands,
@@ -30,7 +31,11 @@ from takopi.progress import ProgressTracker
from takopi.router import AutoRouter, RunnerEntry from takopi.router import AutoRouter, RunnerEntry
from takopi.transport_runtime import TransportRuntime from takopi.transport_runtime import TransportRuntime
from takopi.runners.mock import Return, ScriptRunner, Sleep, Wait from takopi.runners.mock import Return, ScriptRunner, Sleep, Wait
from takopi.telegram.types import TelegramCallbackQuery, TelegramIncomingMessage from takopi.telegram.types import (
TelegramCallbackQuery,
TelegramDocument,
TelegramIncomingMessage,
)
from takopi.transport import MessageRef, RenderedMessage, SendOptions from takopi.transport import MessageRef, RenderedMessage, SendOptions
from tests.plugin_fixtures import FakeEntryPoint, install_entrypoints from tests.plugin_fixtures import FakeEntryPoint, install_entrypoints
@@ -100,6 +105,7 @@ class _FakeBot(BotClient):
self.command_calls: list[dict] = [] self.command_calls: list[dict] = []
self.callback_calls: list[dict] = [] self.callback_calls: list[dict] = []
self.send_calls: list[dict] = [] self.send_calls: list[dict] = []
self.document_calls: list[dict] = []
self.edit_calls: list[dict] = [] self.edit_calls: list[dict] = []
self.edit_topic_calls: list[dict[str, Any]] = [] self.edit_topic_calls: list[dict[str, Any]] = []
self.delete_calls: list[dict] = [] self.delete_calls: list[dict] = []
@@ -151,6 +157,29 @@ class _FakeBot(BotClient):
) )
return {"message_id": 1} return {"message_id": 1}
async def send_document(
self,
chat_id: int,
filename: str,
content: bytes,
reply_to_message_id: int | None = None,
message_thread_id: int | None = None,
disable_notification: bool | None = False,
caption: str | None = None,
) -> dict[str, Any]:
self.document_calls.append(
{
"chat_id": chat_id,
"filename": filename,
"content": content,
"reply_to_message_id": reply_to_message_id,
"message_thread_id": message_thread_id,
"disable_notification": disable_notification,
"caption": caption,
}
)
return {"message_id": 2}
async def edit_message_text( async def edit_message_text(
self, self,
chat_id: int, chat_id: int,
@@ -331,6 +360,7 @@ def test_build_bot_commands_includes_cancel_and_engine() -> None:
commands = _build_bot_commands(runtime) commands = _build_bot_commands(runtime)
assert {"command": "cancel", "description": "cancel run"} in commands assert {"command": "cancel", "description": "cancel run"} in commands
assert {"command": "file", "description": "upload or fetch files"} in commands
assert any(cmd["command"] == "codex" for cmd in commands) assert any(cmd["command"] == "codex" for cmd in commands)
@@ -529,6 +559,27 @@ async def test_telegram_transport_edit_wait_false_returns_ref() -> None:
_ = reply_markup _ = reply_markup
return None return None
async def send_document(
self,
chat_id: int,
filename: str,
content: bytes,
reply_to_message_id: int | None = None,
message_thread_id: int | None = None,
disable_notification: bool | None = False,
caption: str | None = None,
) -> dict | None:
_ = (
chat_id,
filename,
content,
reply_to_message_id,
message_thread_id,
disable_notification,
caption,
)
return None
async def edit_message_text( async def edit_message_text(
self, self,
chat_id: int, chat_id: int,
@@ -715,6 +766,130 @@ async def test_handle_cancel_only_cancels_matching_progress_message() -> None:
assert len(transport.send_calls) == 0 assert len(transport.send_calls) == 0
@pytest.mark.anyio
async def test_handle_file_put_writes_file(tmp_path: Path) -> None:
payload = b"hello"
class _FileBot(_FakeBot):
async def get_file(self, file_id: str) -> dict[str, Any] | None:
_ = file_id
return {"file_path": "files/hello.txt"}
async def download_file(self, file_path: str) -> bytes | None:
_ = file_path
return payload
transport = _FakeTransport()
bot = _FileBot()
runner = ScriptRunner([Return(answer="ok")], engine=CODEX_ENGINE)
projects = ProjectsConfig(
projects={
"proj": ProjectConfig(
alias="proj",
path=tmp_path,
worktrees_dir=Path(".worktrees"),
)
},
default_project=None,
)
runtime = TransportRuntime(router=_make_router(runner), projects=projects)
exec_cfg = ExecBridgeConfig(
transport=transport,
presenter=MarkdownPresenter(),
final_notify=True,
)
cfg = TelegramBridgeConfig(
bot=bot,
runtime=runtime,
chat_id=123,
startup_msg="",
exec_cfg=exec_cfg,
files=TelegramFilesConfig(enabled=True),
)
msg = TelegramIncomingMessage(
transport="telegram",
chat_id=123,
message_id=10,
text="",
reply_to_message_id=None,
reply_to_text=None,
sender_id=321,
chat_type="private",
document=TelegramDocument(
file_id="doc-id",
file_name="hello.txt",
mime_type="text/plain",
file_size=len(payload),
raw={"file_id": "doc-id"},
),
)
await bridge._handle_file_put(cfg, msg, "/proj uploads/hello.txt", None, None)
target = tmp_path / "uploads" / "hello.txt"
assert target.read_bytes() == payload
assert transport.send_calls
text = transport.send_calls[-1]["message"].text
assert "saved uploads/hello.txt" in text
assert "(5 b)" in text
@pytest.mark.anyio
async def test_handle_file_get_sends_document_for_allowed_user(
tmp_path: Path,
) -> None:
payload = b"fetch"
target = tmp_path / "hello.txt"
target.write_bytes(payload)
transport = _FakeTransport()
bot = _FakeBot()
runner = ScriptRunner([Return(answer="ok")], engine=CODEX_ENGINE)
projects = ProjectsConfig(
projects={
"proj": ProjectConfig(
alias="proj",
path=tmp_path,
worktrees_dir=Path(".worktrees"),
)
},
default_project=None,
)
runtime = TransportRuntime(router=_make_router(runner), projects=projects)
exec_cfg = ExecBridgeConfig(
transport=transport,
presenter=MarkdownPresenter(),
final_notify=True,
)
cfg = TelegramBridgeConfig(
bot=bot,
runtime=runtime,
chat_id=123,
startup_msg="",
exec_cfg=exec_cfg,
files=TelegramFilesConfig(
enabled=True,
allowed_user_ids=frozenset({42}),
),
)
msg = TelegramIncomingMessage(
transport="telegram",
chat_id=-100,
message_id=10,
text="",
reply_to_message_id=None,
reply_to_text=None,
sender_id=42,
chat_type="supergroup",
)
await bridge._handle_file_get(cfg, msg, "/proj hello.txt", None, None)
assert bot.document_calls
assert bot.document_calls[0]["filename"] == "hello.txt"
assert bot.document_calls[0]["content"] == payload
@pytest.mark.anyio @pytest.mark.anyio
async def test_handle_callback_cancel_cancels_running_task() -> None: async def test_handle_callback_cancel_cancels_running_task() -> None:
transport = _FakeTransport() transport = _FakeTransport()
@@ -1169,6 +1344,122 @@ async def test_run_main_loop_replies_in_same_thread() -> None:
assert all(call["options"].thread_id == 77 for call in reply_calls) assert all(call["options"].thread_id == 77 for call in reply_calls)
@pytest.mark.anyio
async def test_run_main_loop_batches_media_group_upload(
tmp_path: Path,
) -> None:
payloads = {
"photos/file_1.jpg": b"one",
"photos/file_2.jpg": b"two",
}
file_map = {
"doc-1": "photos/file_1.jpg",
"doc-2": "photos/file_2.jpg",
}
class _MediaBot(_FakeBot):
async def get_file(self, file_id: str) -> dict[str, Any] | None:
file_path = file_map.get(file_id)
if file_path is None:
return None
return {"file_path": file_path}
async def download_file(self, file_path: str) -> bytes | None:
return payloads.get(file_path)
transport = _FakeTransport()
bot = _MediaBot()
runner = ScriptRunner([Return(answer="ok")], engine=CODEX_ENGINE)
projects = ProjectsConfig(
projects={
"proj": ProjectConfig(
alias="proj",
path=tmp_path,
worktrees_dir=Path(".worktrees"),
)
},
default_project=None,
)
runtime = TransportRuntime(router=_make_router(runner), projects=projects)
exec_cfg = ExecBridgeConfig(
transport=transport,
presenter=MarkdownPresenter(),
final_notify=True,
)
cfg = TelegramBridgeConfig(
bot=bot,
runtime=runtime,
chat_id=123,
startup_msg="",
exec_cfg=exec_cfg,
files=TelegramFilesConfig(enabled=True, auto_put=True),
)
msg1 = TelegramIncomingMessage(
transport="telegram",
chat_id=123,
message_id=1,
text="/file put /proj incoming/test1",
reply_to_message_id=None,
reply_to_text=None,
sender_id=321,
chat_type="private",
media_group_id="grp-1",
document=TelegramDocument(
file_id="doc-1",
file_name=None,
mime_type="image/jpeg",
file_size=len(payloads["photos/file_1.jpg"]),
raw={"file_id": "doc-1"},
),
)
msg2 = TelegramIncomingMessage(
transport="telegram",
chat_id=123,
message_id=2,
text="",
reply_to_message_id=None,
reply_to_text=None,
sender_id=321,
chat_type="private",
media_group_id="grp-1",
document=TelegramDocument(
file_id="doc-2",
file_name=None,
mime_type="image/jpeg",
file_size=len(payloads["photos/file_2.jpg"]),
raw={"file_id": "doc-2"},
),
)
stop_polling = anyio.Event()
async def poller(_cfg: TelegramBridgeConfig):
yield msg1
yield msg2
await stop_polling.wait()
async with anyio.create_task_group() as tg:
tg.start_soon(run_main_loop, cfg, poller)
try:
with anyio.fail_after(3):
while len(transport.send_calls) < 1:
await anyio.sleep(0.05)
assert len(transport.send_calls) == 1
text = transport.send_calls[0]["message"].text
assert "saved file_1.jpg, file_2.jpg" in text
assert "to incoming/test1/" in text
target_dir = tmp_path / "incoming" / "test1"
assert (target_dir / "file_1.jpg").read_bytes() == payloads[
"photos/file_1.jpg"
]
assert (target_dir / "file_2.jpg").read_bytes() == payloads[
"photos/file_2.jpg"
]
finally:
stop_polling.set()
tg.cancel_scope.cancel()
@pytest.mark.anyio @pytest.mark.anyio
async def test_run_main_loop_handles_command_plugins(monkeypatch) -> None: async def test_run_main_loop_handles_command_plugins(monkeypatch) -> None:
class _Command: class _Command:
+148 -1
View File
@@ -32,6 +32,7 @@ def test_parse_incoming_update_maps_fields() -> None:
assert msg.chat_type == "supergroup" assert msg.chat_type == "supergroup"
assert msg.is_forum is True assert msg.is_forum is True
assert msg.voice is None assert msg.voice is None
assert msg.document is None
assert msg.raw == update["message"] assert msg.raw == update["message"]
@@ -51,7 +52,11 @@ def test_parse_incoming_update_filters_non_matching_chat() -> None:
def test_parse_incoming_update_filters_non_text_and_non_voice() -> None: def test_parse_incoming_update_filters_non_text_and_non_voice() -> None:
update = { update = {
"update_id": 1, "update_id": 1,
"message": {"message_id": 10, "chat": {"id": 123}, "photo": []}, "message": {
"message_id": 10,
"chat": {"id": 123},
"location": {"latitude": 1.0, "longitude": 2.0},
},
} }
assert parse_incoming_update(update, chat_id=123) is None assert parse_incoming_update(update, chat_id=123) is None
@@ -84,6 +89,148 @@ def test_parse_incoming_update_voice_message() -> None:
assert msg.voice.duration == 3 assert msg.voice.duration == 3
def test_parse_incoming_update_document_message() -> None:
update = {
"update_id": 1,
"message": {
"message_id": 10,
"caption": "/file put incoming/doc.txt",
"chat": {"id": 123},
"document": {
"file_id": "doc-id",
"file_unique_id": "uniq",
"file_name": "doc.txt",
"mime_type": "text/plain",
"file_size": 4321,
},
},
}
msg = parse_incoming_update(update, chat_id=123)
assert msg is not None
assert isinstance(msg, TelegramIncomingMessage)
assert msg.text == "/file put incoming/doc.txt"
assert msg.document is not None
assert msg.document.file_id == "doc-id"
assert msg.document.file_name == "doc.txt"
assert msg.document.mime_type == "text/plain"
assert msg.document.file_size == 4321
def test_parse_incoming_update_photo_message() -> None:
update = {
"update_id": 1,
"message": {
"message_id": 10,
"caption": "/file put incoming/photo.jpg",
"chat": {"id": 123},
"photo": [
{
"file_id": "small",
"file_unique_id": "uniq-small",
"file_size": 100,
"width": 90,
"height": 90,
},
{
"file_id": "large",
"file_unique_id": "uniq-large",
"file_size": 1000,
"width": 800,
"height": 600,
},
],
},
}
msg = parse_incoming_update(update, chat_id=123)
assert msg is not None
assert isinstance(msg, TelegramIncomingMessage)
assert msg.text == "/file put incoming/photo.jpg"
assert msg.document is not None
assert msg.document.file_id == "large"
assert msg.document.file_name is None
assert msg.document.file_size == 1000
def test_parse_incoming_update_media_group_id() -> None:
update = {
"update_id": 1,
"message": {
"message_id": 10,
"chat": {"id": 123},
"media_group_id": "group-1",
"photo": [
{
"file_id": "large",
"file_unique_id": "uniq-large",
"file_size": 1000,
"width": 800,
"height": 600,
}
],
},
}
msg = parse_incoming_update(update, chat_id=123)
assert msg is not None
assert isinstance(msg, TelegramIncomingMessage)
assert msg.media_group_id == "group-1"
def test_parse_incoming_update_video_message() -> None:
update = {
"update_id": 1,
"message": {
"message_id": 10,
"caption": "/file put incoming/video.mp4",
"chat": {"id": 123},
"video": {
"file_id": "video-id",
"file_unique_id": "uniq",
"file_name": "video.mp4",
"mime_type": "video/mp4",
"file_size": 4242,
},
},
}
msg = parse_incoming_update(update, chat_id=123)
assert msg is not None
assert isinstance(msg, TelegramIncomingMessage)
assert msg.text == "/file put incoming/video.mp4"
assert msg.document is not None
assert msg.document.file_id == "video-id"
assert msg.document.file_name == "video.mp4"
assert msg.document.mime_type == "video/mp4"
assert msg.document.file_size == 4242
def test_parse_incoming_update_sticker_message() -> None:
update = {
"update_id": 1,
"message": {
"message_id": 10,
"chat": {"id": 123},
"sticker": {
"file_id": "sticker-id",
"file_unique_id": "uniq",
"file_size": 2468,
},
},
}
msg = parse_incoming_update(update, chat_id=123)
assert msg is not None
assert isinstance(msg, TelegramIncomingMessage)
assert msg.text == ""
assert msg.document is not None
assert msg.document.file_id == "sticker-id"
assert msg.document.file_name is None
assert msg.document.mime_type is None
assert msg.document.file_size == 2468
def test_parse_incoming_update_callback_query() -> None: def test_parse_incoming_update_callback_query() -> None:
update = { update = {
"update_id": 1, "update_id": 1,
+22
View File
@@ -40,6 +40,28 @@ class _FakeBot(BotClient):
self.calls.append("send_message") self.calls.append("send_message")
return {"message_id": 1} return {"message_id": 1}
async def send_document(
self,
chat_id: int,
filename: str,
content: bytes,
reply_to_message_id: int | None = None,
message_thread_id: int | None = None,
disable_notification: bool | None = False,
caption: str | None = None,
) -> dict[str, Any]:
_ = (
chat_id,
filename,
content,
reply_to_message_id,
message_thread_id,
disable_notification,
caption,
)
self.calls.append("send_document")
return {"message_id": 1}
async def edit_message_text( async def edit_message_text(
self, self,
chat_id: int, chat_id: int,