Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions docs/features/dialogs.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
=== "CLI"
```bash
python -m src.main dialogs send @username "текст" --yes
python -m src.main dialogs react @chat 42 👍 --phone +79001234567
python -m src.main dialogs edit-message @chat 42 "новый текст" --yes
python -m src.main dialogs delete-message @chat 42 43 44 --yes
python -m src.main dialogs pin-message @chat 42 --notify
Expand Down
16 changes: 16 additions & 0 deletions docs/reference/agent-tools.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,10 @@
| `import_channels` | WRITE | Массовый импорт |
| `refresh_channel_types` | WRITE | Обновить типы каналов |
| `refresh_channel_meta` | WRITE | Обновить метаданные (about, linked_chat_id, has_comments) |
| `list_tags` | READ | Список тегов |
| `create_tag` | WRITE | Создать тег |
| `delete_tag` | DELETE | Удалить тег |
| `set_channel_tags` | WRITE | Обновить теги канала |

## Сбор

Expand All @@ -52,6 +56,13 @@
| `get_pipeline_run` | READ | Детали запуска |
| `publish_pipeline_run` | WRITE | Опубликовать |
| `get_pipeline_queue` | READ | Очередь модерации |
| `get_refinement_steps` | READ | Шаги refinement |
| `set_refinement_steps` | WRITE | Сохранить шаги refinement |
| `export_pipeline_json` | READ | Экспорт JSON |
| `import_pipeline_json` | WRITE | Импорт JSON |
| `list_pipeline_templates` | READ | Список шаблонов |
| `create_pipeline_from_template` | WRITE | Создать pipeline из шаблона |
| `ai_edit_pipeline` | WRITE | AI-редактирование pipeline |

## Модерация

Expand Down Expand Up @@ -169,12 +180,16 @@
| `get_forum_topics` | READ | Топики форума |
| `clear_dialog_cache` | WRITE | Очистить кеш |
| `get_cache_status` | READ | Статус кеша |
| `resolve_entity` | READ | Resolve @username, t.me ссылку или numeric ID |

## Сообщения

| Tool | Категория | Описание |
|------|-----------|----------|
| `read_messages` | READ | Читать сообщения из чата |
| `send_message` | WRITE | Отправить сообщение |
| `send_reaction` | WRITE | Поставить emoji-реакцию на сообщение |
| `forward_messages` | WRITE | Переслать сообщения |
| `edit_message` | WRITE | Редактировать |
| `delete_message` | DELETE | Удалить (⚠️ destructive) |
| `pin_message` | WRITE | Закрепить сообщение |
Expand All @@ -201,6 +216,7 @@
| `list_image_models` | READ | Поиск моделей |
| `list_image_providers` | READ | Список провайдеров |
| `generate_image` | WRITE | Генерация изображения |
| `list_generated_images` | READ | История сгенерированных изображений |

## Настройки

Expand Down
1 change: 1 addition & 0 deletions docs/reference/cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -157,6 +157,7 @@ python -m src.main dialogs topics --channel-id ID [--phone PHONE]
python -m src.main dialogs cache-status
python -m src.main dialogs cache-clear [--phone PHONE]
python -m src.main dialogs send RECIPIENT TEXT [--phone PHONE] [--yes]
python -m src.main dialogs react CHAT_ID MESSAGE_ID EMOJI [--phone PHONE]
python -m src.main dialogs forward FROM_CHAT TO_CHAT MESSAGE_ID [MESSAGE_ID ...] [--phone PHONE] [--yes]
python -m src.main dialogs edit-message CHAT_ID MESSAGE_ID TEXT [--phone PHONE] [--yes]
python -m src.main dialogs delete-message CHAT_ID MESSAGE_ID [MESSAGE_ID ...] [--phone PHONE] [--yes]
Expand Down
1 change: 1 addition & 0 deletions docs/reference/parity.md
Original file line number Diff line number Diff line change
Expand Up @@ -176,6 +176,7 @@
| Топики форума | `dialogs topics` | `GET /agent/forum-topics` | `get_forum_topics` |
| Создать канал | `dialogs create-channel` | `POST /dialogs/create-channel` | `create_telegram_channel` |
| Отправить сообщение | `dialogs send` | `POST /dialogs/send` | `send_message` |
| Поставить реакцию | `dialogs react` | — | `send_reaction` |
| Переслать сообщение | `dialogs forward` | `POST /dialogs/forward` | `forward_messages` |
| Редактировать | `dialogs edit-message` | `POST /dialogs/edit-message` | `edit_message` |
| Удалить | `dialogs delete-message` | `POST /dialogs/delete-message` | `delete_message` |
Expand Down
1 change: 1 addition & 0 deletions src/agent/tools/deepagents_sync.py
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,7 @@
"get_forum_topics": frozenset({"channel_id"}),
"resolve_entity": frozenset({"identifier"}),
"send_message": frozenset({"recipient", "text"}),
"send_reaction": frozenset({"chat_id", "message_id", "emoji"}),
"edit_message": frozenset({"chat_id", "message_id", "text"}),
"delete_message": frozenset({"chat_id", "message_ids"}),
"forward_messages": frozenset({"from_chat", "to_chat", "message_ids"}),
Expand Down
8 changes: 8 additions & 0 deletions src/agent/tools/messaging_schemas.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,14 @@
"confirm": CONFIRM_ARG,
}

SEND_REACTION_SCHEMA = {
"phone": PHONE_ARG,
"chat_id": CHAT_ID_ARG,
"message_id": Annotated[int, "ID сообщения в Telegram"],
"emoji": Annotated[str, "Emoji-реакция"],
"confirm": CONFIRM_ARG,
}

EDIT_MESSAGE_SCHEMA = {
"phone": PHONE_ARG,
"chat_id": CHAT_ID_ARG,
Expand Down
39 changes: 39 additions & 0 deletions src/agent/tools/messaging_write.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
ToolInputError,
_text_response,
arg_csv_ints,
arg_int,
arg_str,
require_confirmation,
)
Expand All @@ -18,6 +19,7 @@
EDIT_MESSAGE_SCHEMA,
FORWARD_MESSAGES_SCHEMA,
SEND_MESSAGE_SCHEMA,
SEND_REACTION_SCHEMA,
)
from src.services.telegram_actions import TelegramActionClientUnavailableError, TelegramActionService

Expand Down Expand Up @@ -58,6 +60,43 @@ async def send_message(args):

tools.append(send_message)

@tool(
"send_reaction",
"Set an emoji reaction on a Telegram message. "
"chat_id accepts @username, t.me link, numeric ID, or 'me'. Ask user for confirmation first.",
SEND_REACTION_SCHEMA,
)
async def send_reaction(args):
phone, err = await prepare_telegram_tool(ctx, args, tool_name="send_reaction", action="Реакция на сообщение")
if err:
return err
try:
chat_id = arg_str(args, "chat_id", required=True)
message_id = arg_int(args, "message_id", required=True)
emoji = arg_str(args, "emoji", required=True)
except ToolInputError:
return _text_response("Ошибка: chat_id, message_id и emoji обязательны.")
gate = require_confirmation(
f"поставит реакцию {emoji} на сообщение #{message_id} в чате {chat_id} от аккаунта {phone}",
args,
)
if gate:
return gate
try:
await TelegramActionService(client_pool).send_reaction(
phone=phone,
chat_id=chat_id,
message_id=int(message_id),
emoji=emoji,
)
return _text_response(f"Реакция {emoji} поставлена на сообщение #{message_id} в чате {chat_id}.")
except TelegramActionClientUnavailableError:
return _text_response(f"Клиент для {phone} не найден или flood-wait активен.")
except Exception as e:
return _text_response(f"Ошибка установки реакции: {e}")

tools.append(send_reaction)

@tool(
"edit_message",
"Edit a previously sent message. "
Expand Down
6 changes: 4 additions & 2 deletions src/agent/tools/permissions.py
Original file line number Diff line number Diff line change
Expand Up @@ -171,8 +171,10 @@ class ToolCategory(str, Enum):
"get_forum_topics": ToolCategory.READ,
"clear_dialog_cache": ToolCategory.WRITE,
"get_cache_status": ToolCategory.READ,
"resolve_entity": ToolCategory.READ,
# Messaging
"send_message": ToolCategory.WRITE,
"send_reaction": ToolCategory.WRITE,
"forward_messages": ToolCategory.WRITE,
"edit_message": ToolCategory.WRITE,
"delete_message": ToolCategory.DELETE,
Expand Down Expand Up @@ -276,10 +278,10 @@ class ToolCategory(str, Enum):
]),
("Диалоги", [
"search_dialogs", "refresh_dialogs", "leave_dialogs", "create_telegram_channel",
"get_forum_topics", "clear_dialog_cache", "get_cache_status",
"get_forum_topics", "clear_dialog_cache", "get_cache_status", "resolve_entity",
]),
("Сообщения", [
"send_message", "forward_messages", "edit_message", "delete_message",
"send_message", "send_reaction", "forward_messages", "edit_message", "delete_message",
"pin_message", "unpin_message", "download_media", "read_messages",
]),
("Управление чатом", [
Expand Down
2 changes: 1 addition & 1 deletion src/services/pipeline_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -337,7 +337,7 @@ async def list_cached_dialogs_by_phone(
self,
active_only: bool = False,
) -> dict[str, list[dict]]:
accounts = await self._bundle.list_accounts(active_only=active_only)
accounts = await self._bundle.list_account_summaries(active_only=active_only)
dialogs = await asyncio.gather(
*[self._bundle.list_cached_dialogs(account.phone) for account in accounts]
)
Expand Down
1 change: 1 addition & 0 deletions src/services/telegram_action_inventory.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ class TelegramActionInventoryItem:
TelegramActionInventoryItem(
action="send_reaction",
cli="dialogs react",
agent_tool="send_reaction",
pipeline_node="react",
backend_method="send_reaction",
),
Expand Down
8 changes: 4 additions & 4 deletions src/web/pipelines/handlers.py
Original file line number Diff line number Diff line change
Expand Up @@ -121,7 +121,7 @@ async def api_channels_search(request: Request, q: str = ""):
async def _page_context(request: Request) -> dict:
svc = deps.pipeline_service(request)
channels = await deps.get_channel_bundle(request).list_channels(include_filtered=True)
accounts = await deps.get_account_bundle(request).list_accounts()
accounts = await deps.get_account_bundle(request).list_account_summaries()
selected_phone = request.query_params.get("phone") or (accounts[0].phone if accounts else "")
if selected_phone:
refresh = request.query_params.get("refresh") == "1"
Expand Down Expand Up @@ -188,7 +188,7 @@ async def pipelines_page(request: Request):

async def create_wizard_page(request: Request):
svc = deps.pipeline_service(request)
accounts = await deps.get_account_bundle(request).list_accounts()
accounts = await deps.get_account_bundle(request).list_account_summaries()
cached_dialogs = await svc.list_cached_dialogs_by_phone()
llm_provider_svc = deps.get_llm_provider_service(request)
llm_configured = llm_provider_svc.has_providers()
Expand Down Expand Up @@ -413,7 +413,7 @@ async def edit_page(request: Request, pipeline_id: int):
return _pipeline_redirect("pipeline_invalid", error=True)
db = deps.get_db(request)
channels = await deps.get_channel_bundle(request).list_channels(include_filtered=True)
accounts = await deps.get_account_bundle(request).list_accounts()
accounts = await deps.get_account_bundle(request).list_account_summaries()
selected_phone = request.query_params.get("phone") or (accounts[0].phone if accounts else "")
if selected_phone and request.query_params.get("refresh") == "1":
try:
Expand Down Expand Up @@ -665,7 +665,7 @@ async def templates_page(request: Request):
svc: PipelineService = deps.pipeline_service(request)
templates = await svc.list_templates()
channels = await deps.get_channel_bundle(request).list_channels(include_filtered=True)
accounts = await deps.get_account_bundle(request).list_accounts()
accounts = await deps.get_account_bundle(request).list_account_summaries()
cached_dialogs = await svc.list_cached_dialogs_by_phone()
llm_configured = deps.get_llm_provider_service(request).has_providers()
return deps.get_templates(request).TemplateResponse(
Expand Down
22 changes: 22 additions & 0 deletions tests/routes/test_pipelines_routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@
import pytest
from httpx import ASGITransport, AsyncClient

from src.models import Account

_ADD_DATA = {
"name": "Test Pipeline",
"prompt_template": "Write a summary",
Expand Down Expand Up @@ -139,6 +141,26 @@ async def test_pipeline_edit_page_loads(client):
assert "Редактировать" in resp.text


@pytest.mark.anyio
async def test_pipeline_pages_render_with_encrypted_account_missing_key(client):
"""Read-only pipeline pages must not decrypt account sessions to render."""
await client.post("/pipelines/add", data=_ADD_DATA)

app = client._transport.app # type: ignore[attr-defined]
await app.state.db.add_account(
Account(
phone="+19999999999",
session_string="enc:v1:not-a-valid-token",
is_active=True,
)
)

for path in ("/pipelines/", "/pipelines/create", "/pipelines/templates", "/pipelines/1/edit"):
resp = await client.get(path)
assert resp.status_code == 200, path
assert "Ошибка сервера" not in resp.text


@pytest.mark.anyio
async def test_pipeline_edit_page_not_found(client):
"""GET /pipelines/<id>/edit redirects for invalid ID."""
Expand Down
2 changes: 1 addition & 1 deletion tests/routes/test_pipelines_routes_dag_and_templates.py
Original file line number Diff line number Diff line change
Expand Up @@ -1203,7 +1203,7 @@ async def test_templates_page_renders(route_client, base_app):
mock_svc.return_value.list_templates = AsyncMock(return_value=[])
mock_svc.return_value.list_cached_dialogs_by_phone = AsyncMock(return_value={})
mock_ch.return_value.list_channels = AsyncMock(return_value=[])
mock_acct.return_value.list_accounts = AsyncMock(return_value=[])
mock_acct.return_value.list_account_summaries = AsyncMock(return_value=[])
resp = await route_client.get("/pipelines/templates")
assert resp.status_code == 200

Expand Down
79 changes: 79 additions & 0 deletions tests/test_agent_tool_smoke_contract.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@

from collections.abc import Callable
from datetime import datetime, timedelta
from pathlib import Path
from unittest.mock import MagicMock

import pytest

Expand All @@ -21,6 +23,48 @@ def _assert_no_formatter_contract_error(tool_name: str, text: str) -> None:
assert "required positional" not in lowered, tool_name


def _minimal_deepagents_kwargs(tool_name: str) -> dict[str, object]:
from src.agent.tools.deepagents_sync import _REQUIRED_ARGS_BY_TOOL

samples: dict[str, object] = {
"account_id": 1,
"channel_id": 1001,
"chat_id": "@contract_chat",
"dialog_ids": "1001",
"emoji": "👍",
"file_paths": "/tmp/contract-smoke.jpg",
"folder_path": "/tmp",
"from_chat": "@from_chat",
"identifier": "@contract_channel",
"instruction": "noop",
"item_id": 1,
"job_id": "collect",
"json_text": "{}",
"message_id": 1,
"message_ids": "1",
"minutes": 5,
"name": "contract",
"pipeline_id": 1,
"pk": 1,
"prompt": "contract smoke",
"provider": "stub",
"query": "contract",
"recipient": "@recipient",
"run_id": 1,
"schedule_at": "2030-01-01T00:00:00",
"sq_id": 1,
"source_channel_ids": "1001",
"steps_json": "[]",
"target": "1001",
"target_refs": "+79001234567|1001",
"text": "contract smoke",
"thread_id": 1,
"to_chat": "@to_chat",
"user_id": "@user",
}
return {name: samples.get(name, "contract") for name in _REQUIRED_ARGS_BY_TOOL.get(tool_name, frozenset())}


async def _seed_read_only_contract_data(db) -> None:
now = datetime.now()
await db.add_channel(Channel(channel_id=1001, title="Named Contract", username="named_contract"))
Expand All @@ -41,6 +85,41 @@ async def _seed_read_only_contract_data(db) -> None:
)


def test_agent_registry_matches_permission_contract(mock_db):
from src.agent.tools import build_agent_tool_registry
from src.agent.tools.permissions import BUILTIN_TOOLS, TOOL_CATEGORIES

registry_names = {
tool.name for tool in build_agent_tool_registry(mock_db, client_pool=MagicMock(), wrap_session_gate=False)
}
permission_names = set(TOOL_CATEGORIES) - set(BUILTIN_TOOLS)

assert registry_names - permission_names == set()
assert permission_names - registry_names == set()


def test_agent_tools_reference_docs_cover_registry(mock_db):
from src.agent.tools import build_agent_tool_registry

doc = Path("docs/reference/agent-tools.md").read_text(encoding="utf-8")
missing = [
tool.name
for tool in build_agent_tool_registry(mock_db, client_pool=MagicMock(), wrap_session_gate=False)
if f"`{tool.name}`" not in doc
]

assert missing == []


def test_deepagents_all_tools_smoke_with_minimal_contract_args(cli_db):
tool_map: dict[str, Callable] = {tool.__name__: tool for tool in build_deepagents_tools(cli_db)}

for tool_name, tool in sorted(tool_map.items()):
result = tool(**_minimal_deepagents_kwargs(tool_name))
assert isinstance(result, str), tool_name
_assert_no_formatter_contract_error(tool_name, result)


@pytest.mark.anyio
async def test_pipeline_safe_agent_tools_smoke_on_empty_db(db):
tools = build_agent_tools_dict(db, client_pool=None)
Expand Down
Loading
Loading