feat(telegram): add file transfer support (#83)
This commit is contained in:
+71
-2
@@ -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 |
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
+889
-24
File diff suppressed because it is too large
Load Diff
+245
-17
@@ -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,31 +75,97 @@ 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
|
||||||
voice_payload = TelegramVoice(
|
return TelegramDocument(
|
||||||
file_id=file_id,
|
file_id=file_id,
|
||||||
mime_type=voice.get("mime_type")
|
file_name=payload.get("file_name")
|
||||||
if isinstance(voice.get("mime_type"), str)
|
if isinstance(payload.get("file_name"), str)
|
||||||
else None,
|
else None,
|
||||||
file_size=voice.get("file_size")
|
mime_type=payload.get("mime_type")
|
||||||
if isinstance(voice.get("file_size"), int)
|
if isinstance(payload.get("mime_type"), str)
|
||||||
and not isinstance(voice.get("file_size"), bool)
|
|
||||||
else None,
|
else None,
|
||||||
duration=voice.get("duration")
|
file_size=payload.get("file_size")
|
||||||
if isinstance(voice.get("duration"), int)
|
if isinstance(payload.get("file_size"), int)
|
||||||
and not isinstance(voice.get("duration"), bool)
|
and not isinstance(payload.get("file_size"), bool)
|
||||||
else None,
|
else None,
|
||||||
raw=voice,
|
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 = ""
|
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(
|
||||||
|
file_id=file_id,
|
||||||
|
mime_type=voice.get("mime_type")
|
||||||
|
if isinstance(voice.get("mime_type"), str)
|
||||||
|
else None,
|
||||||
|
file_size=voice.get("file_size")
|
||||||
|
if isinstance(voice.get("file_size"), int)
|
||||||
|
and not isinstance(voice.get("file_size"), bool)
|
||||||
|
else None,
|
||||||
|
duration=voice.get("duration")
|
||||||
|
if isinstance(voice.get("duration"), int)
|
||||||
|
and not isinstance(voice.get("duration"), bool)
|
||||||
|
else None,
|
||||||
|
raw=voice,
|
||||||
|
)
|
||||||
|
if not isinstance(raw_text, str) and not isinstance(caption, str):
|
||||||
|
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,
|
||||||
|
|||||||
@@ -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()
|
||||||
@@ -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
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
)
|
||||||
@@ -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:
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
Reference in New Issue
Block a user