fix: support AI commands in media captions

This commit is contained in:
Azimkin
2026-09-14 14:35:48 +02:00
parent 40e7930e60
commit c6b93c9aa8
6 changed files with 62 additions and 10 deletions

View File

@@ -6,10 +6,12 @@ Repository guidance for automated coding tools and contributors.
```bash ```bash
uv sync uv sync
uv run crawl4ai-setup uv run python -m playwright install chromium
uv run python main.py uv run python main.py
``` ```
Do not use `crawl4ai-setup` in unprivileged containers such as Pterodactyl: it invokes Playwright with `--with-deps` and attempts to become root. Installing Chromium with the command above only writes to the current user's Playwright cache. If Chromium reports missing shared libraries at runtime, they must be included in the Docker image by its administrator.
Logs go to `bot.log` by default (rotating, 10 MB, 3 backups). Override with `LOG_FILE=<path>`. Logs go to `bot.log` by default (rotating, 10 MB, 3 backups). Override with `LOG_FILE=<path>`.
Run the test suite with: Run the test suite with:
@@ -53,7 +55,7 @@ On supported platforms, `main.py` runs the application with `uvloop`; Windows au
### Agent loop (`app/agentic.py`) ### Agent loop (`app/agentic.py`)
`.ai [count] [.ds] [.th] <prompt>` runs `micro-agents` with GLM by default. `.ds` selects DeepSeek and `.th` enables reasoning. Every session exposes the `micro-agents-tools` Brave Search tool. Unknown leading dot-arguments are ignored. The optional count adds preceding Telegram messages; a replied message is always injected once. Media at or below 5 MiB is attached. Invalid syntax and runtime/API failures only react with 👎. Guests need an ACL grant for `ai` and invoke it as `..ai`. `.ai [count] [.ds] [.th] <prompt>` runs `micro-agents` with GLM by default. `.ds` selects DeepSeek and `.th` enables reasoning. Every session exposes the `micro-agents-tools` Brave Search tool. Unknown leading dot-arguments are ignored. The optional count adds preceding Telegram messages; a replied message is always injected once. Commands are accepted from either message text or a media caption. Media at or below 5 MiB, including media attached directly to the command message, is attached. Invalid syntax and runtime/API failures only react with 👎. Guests need an ACL grant for `ai` and invoke it as `..ai`.
While an `.ai` command is running, its Telegram message has a 🤔 reaction. The reaction is removed after a successful answer and replaced with 👎 on failure. While an `.ai` command is running, its Telegram message has a 🤔 reaction. The reaction is removed after a successful answer and replaced with 👎 on failure.

View File

@@ -437,8 +437,14 @@ async def run_agent_turn(
context["base_dir"] = config.base_dir context["base_dir"] = config.base_dir
existing.session.context.clear() existing.session.context.clear()
existing.session.context.update(context) existing.session.context.update(context)
media = await _download_message_media(
client,
command_message,
Path(existing.workspace.name),
config.max_media_bytes,
)
return AgentTurnResult( return AgentTurnResult(
answer=await _run_session(existing.session, invocation.prompt), answer=await _run_session(existing.session, invocation.prompt, media=media),
handle=existing, handle=existing,
) )
@@ -463,6 +469,12 @@ async def run_agent_turn(
user_template=templates.user, user_template=templates.user,
template_context=context, template_context=context,
) )
media = await _download_message_media(
client,
command_message,
temp_path,
config.max_media_bytes,
)
tools = [ tools = [
*WebPageTools( *WebPageTools(
timeout_seconds=config.web_page_timeout_seconds, timeout_seconds=config.web_page_timeout_seconds,
@@ -501,7 +513,7 @@ async def run_agent_turn(
session_name=f"telegram:{chat_id}", session_name=f"telegram:{chat_id}",
messages=messages, messages=messages,
) )
answer = await _run_session(session, invocation.prompt) answer = await _run_session(session, invocation.prompt, media=media)
return AgentTurnResult( return AgentTurnResult(
answer=answer, answer=answer,
handle=AgentSessionHandle(session, workspace, set()), handle=AgentSessionHandle(session, workspace, set()),
@@ -511,7 +523,10 @@ async def run_agent_turn(
raise raise
async def _run_session(session: Any, prompt: str) -> str: async def _run_session(session: Any, prompt: str, *, media: list[Media] | None = None) -> str:
if media:
result = await session.run(prompt, media=media)
else:
result = await session.run(prompt) result = await session.run(prompt)
if not result.succeeded or result.final_message is None: if not result.succeeded or result.final_message is None:
raise AgentError("Agent loop failed") from result.error raise AgentError("Agent loop failed") from result.error

View File

@@ -59,10 +59,12 @@ class CommandDispatcher:
account: "AccountState", account: "AccountState",
) -> bool: ) -> bool:
text = getattr(message, "text", None) text = getattr(message, "text", None)
if not isinstance(text, str): caption = getattr(message, "caption", None)
command_text = text if isinstance(text, str) else caption
if not isinstance(command_text, str):
return False return False
parsed = parse_command(text) parsed = parse_command(command_text)
if parsed is None: if parsed is None:
return False return False
@@ -85,7 +87,7 @@ class CommandDispatcher:
message=message, message=message,
command=definition, command=definition,
prefix=parsed.prefix, prefix=parsed.prefix,
raw_text=text, raw_text=command_text,
args=parsed.args, args=parsed.args,
alias=parsed.alias, alias=parsed.alias,
) )

View File

@@ -18,4 +18,4 @@ def register_common_handlers(client: "Client", manager: "AccountManager", accoun
async def command_handler(client_: "Client", message) -> None: async def command_handler(client_: "Client", message) -> None:
await dispatcher.dispatch(client_, message, manager, account) await dispatcher.dispatch(client_, message, manager, account)
client.add_handler(MessageHandler(command_handler, filters.text)) client.add_handler(MessageHandler(command_handler, filters.text | filters.caption))

View File

@@ -167,6 +167,7 @@ class AgentContextTests(unittest.IsolatedAsyncioTestCase):
chat=SimpleNamespace(id=99), chat=SimpleNamespace(id=99),
from_user=SimpleNamespace(id=42), from_user=SimpleNamespace(id=42),
reply_to_message=None, reply_to_message=None,
photo=SimpleNamespace(file_size=100),
) )
account = SimpleNamespace( account = SimpleNamespace(
me=SimpleNamespace(id=7, full_name="Owner", username="owner"), me=SimpleNamespace(id=7, full_name="Owner", username="owner"),
@@ -184,6 +185,7 @@ class AgentContextTests(unittest.IsolatedAsyncioTestCase):
) )
agent = MagicMock() agent = MagicMock()
agent.create_session.return_value = session agent.create_session.return_value = session
attached_media = MagicMock()
with ( with (
patch("app.agentic.OpenAIBasedLLM") as llm_class, patch("app.agentic.OpenAIBasedLLM") as llm_class,
@@ -191,6 +193,10 @@ class AgentContextTests(unittest.IsolatedAsyncioTestCase):
patch("app.agentic.BraveSearchTools") as brave_tools, patch("app.agentic.BraveSearchTools") as brave_tools,
patch("app.agentic.WebPageTools") as web_page_tools, patch("app.agentic.WebPageTools") as web_page_tools,
patch("app.agentic._build_context_messages", AsyncMock(return_value=[])), patch("app.agentic._build_context_messages", AsyncMock(return_value=[])),
patch(
"app.agentic._download_message_media",
AsyncMock(return_value=[attached_media]),
),
): ):
brave_tools.return_value.tools.return_value = [] brave_tools.return_value.tools.return_value = []
web_page_tools.return_value.tools.return_value = [] web_page_tools.return_value.tools.return_value = []
@@ -210,6 +216,7 @@ class AgentContextTests(unittest.IsolatedAsyncioTestCase):
model="ds-model", model="ds-model",
) )
self.assertTrue(aggregator_class.call_args.kwargs["thinking"]) self.assertTrue(aggregator_class.call_args.kwargs["thinking"])
session.run.assert_awaited_once_with("prompt", media=[attached_media])
brave_tools.assert_called_once_with(api_key="brave-key", timeout_seconds=10.0) brave_tools.assert_called_once_with(api_key="brave-key", timeout_seconds=10.0)
web_page_tools.assert_called_once_with(timeout_seconds=45.0, max_chars=60_000) web_page_tools.assert_called_once_with(timeout_seconds=45.0, max_chars=60_000)
result.handle.close() result.handle.close()

View File

@@ -8,6 +8,32 @@ from app.commands import CommandDispatcher, CommandRegistry
class CommandDispatcherTests(unittest.IsolatedAsyncioTestCase): class CommandDispatcherTests(unittest.IsolatedAsyncioTestCase):
async def test_command_in_media_caption_is_dispatched(self) -> None:
registry = CommandRegistry()
handler = AsyncMock()
registry.command("photo", "Caption command")(handler)
dispatcher = CommandDispatcher(registry)
message = SimpleNamespace(
text=None,
caption=".photo argument",
from_user=SimpleNamespace(id=123),
reply_text=AsyncMock(),
)
account = SimpleNamespace(me=SimpleNamespace(id=123), acl_store=None)
handled = await dispatcher.dispatch(
client=SimpleNamespace(),
message=message,
manager=SimpleNamespace(),
account=account,
)
self.assertTrue(handled)
handler.assert_awaited_once()
context = handler.await_args.args[0]
self.assertEqual(context.args, "argument")
self.assertEqual(context.raw_text, ".photo argument")
async def test_unknown_owner_command_is_silent(self) -> None: async def test_unknown_owner_command_is_silent(self) -> None:
dispatcher = CommandDispatcher(CommandRegistry()) dispatcher = CommandDispatcher(CommandRegistry())
message = SimpleNamespace( message = SimpleNamespace(