feat: add QR login and browser-backed web tools

This commit is contained in:
Azimkin
2026-09-14 14:19:20 +02:00
parent 3a8c198caa
commit b68e715267
19 changed files with 2740 additions and 8 deletions

View File

@@ -15,6 +15,8 @@ from micro_agents_tools import BraveSearchTools, FilesystemTools, PythonTools
from app.agent_tools import AgentToolManager, TelegramTools
from app.config import AiConfig, AiProviderConfig
from app.llm import ChatCompletionsLLM
from app.web_tools import WebPageTools
class AgentError(RuntimeError):
@@ -401,6 +403,15 @@ def _provider(config: AiConfig, invocation: AgentInvocation) -> AiProviderConfig
return config.deepseek if invocation.use_deepseek else config.glm
def _create_llm(provider: AiProviderConfig, invocation: AgentInvocation) -> Any:
llm_class = OpenAIBasedLLM if invocation.use_deepseek else ChatCompletionsLLM
return llm_class(
api_url=provider.api_url,
api_key=provider.api_key,
model=provider.model,
)
async def run_agent_turn(
client: Any,
command_message: Any,
@@ -433,11 +444,7 @@ async def run_agent_turn(
if not provider.api_key:
raise AgentError("The selected AI provider has no API key")
llm = OpenAIBasedLLM(
api_url=provider.api_url,
api_key=provider.api_key,
model=provider.model,
)
llm = _create_llm(provider, invocation)
templates = load_prompt_templates(config.prompt_dir)
context = _agent_context(command_message, account, invocation)
context["base_dir"] = config.base_dir
@@ -455,6 +462,10 @@ async def run_agent_turn(
template_context=context,
)
tools = [
*WebPageTools(
timeout_seconds=config.web_page_timeout_seconds,
max_chars=config.web_page_max_chars,
).tools(),
*BraveSearchTools(
api_key=config.brave_api_key,
timeout_seconds=config.brave_timeout_seconds,

View File

@@ -416,6 +416,7 @@ async def echo(ctx: "CommandContext") -> None:
category="shared",
).shared()
async def ai(ctx: "CommandContext") -> None:
failed = False
try:
invocation = parse_agent_invocation(ctx.args, ctx.manager.config.ai)
turn = await run_agent_turn(
@@ -431,6 +432,9 @@ async def ai(ctx: "CommandContext") -> None:
ctx.account.ai_sessions.register_response(turn.handle, sent)
except Exception:
log.exception("AI command failed for message %s", getattr(ctx.message, "id", None))
failed = True
if failed:
await _dislike(ctx.message)

View File

@@ -31,6 +31,7 @@ class AccountConfig:
no_updates: bool = False
takeout: bool = False
hide_password: bool = False
use_qr: bool = False
@dataclass(slots=True)
@@ -69,6 +70,8 @@ class AiConfig:
max_tool_rounds: int = 16
brave_api_key: str | None = None
brave_timeout_seconds: float = 10.0
web_page_timeout_seconds: float = 45.0
web_page_max_chars: int = 60_000
@dataclass(slots=True)
@@ -93,6 +96,20 @@ def _parse_int(value: Any, *, field_name: str) -> int:
raise ValueError(f"{field_name} must be an integer") from exc
def _parse_bool(value: Any, *, field_name: str) -> bool:
if isinstance(value, bool):
return value
if isinstance(value, str):
normalized = value.strip().lower()
if normalized in {"1", "true", "yes", "on"}:
return True
if normalized in {"0", "false", "no", "off"}:
return False
if isinstance(value, int):
return bool(value)
raise ValueError(f"{field_name} must be a boolean")
def _parse_float(value: Any, *, field_name: str) -> float:
try:
return float(value)
@@ -206,6 +223,14 @@ def _parse_ai_config(raw: Any) -> AiConfig:
raw_config.get("brave_timeout_seconds", 10),
field_name="ai.brave_timeout_seconds",
)
web_page_timeout_seconds = _parse_float(
raw_config.get("web_page_timeout_seconds", 45),
field_name="ai.web_page_timeout_seconds",
)
web_page_max_chars = _parse_int(
raw_config.get("web_page_max_chars", 60_000),
field_name="ai.web_page_max_chars",
)
raw_brave_key = raw_config.get("brave_api_key")
brave_api_key = str(raw_brave_key).strip() if raw_brave_key is not None else None
@@ -234,6 +259,16 @@ def _parse_ai_config(raw: Any) -> AiConfig:
os.environ["BRAVE_SEARCH_TIMEOUT_SECONDS"],
field_name="BRAVE_SEARCH_TIMEOUT_SECONDS",
)
if os.environ.get("AI_WEB_PAGE_TIMEOUT_SECONDS") is not None:
web_page_timeout_seconds = _parse_float(
os.environ["AI_WEB_PAGE_TIMEOUT_SECONDS"],
field_name="AI_WEB_PAGE_TIMEOUT_SECONDS",
)
if os.environ.get("AI_WEB_PAGE_MAX_CHARS") is not None:
web_page_max_chars = _parse_int(
os.environ["AI_WEB_PAGE_MAX_CHARS"],
field_name="AI_WEB_PAGE_MAX_CHARS",
)
if max_context_messages < 0:
raise ValueError("ai.max_context_messages must not be negative")
@@ -243,6 +278,10 @@ def _parse_ai_config(raw: Any) -> AiConfig:
raise ValueError("ai.max_tool_rounds must be greater than zero")
if brave_timeout_seconds <= 0:
raise ValueError("ai.brave_timeout_seconds must be greater than zero")
if web_page_timeout_seconds <= 0:
raise ValueError("ai.web_page_timeout_seconds must be greater than zero")
if web_page_max_chars < 1:
raise ValueError("ai.web_page_max_chars must be greater than zero")
return AiConfig(
glm=glm,
@@ -252,6 +291,8 @@ def _parse_ai_config(raw: Any) -> AiConfig:
max_tool_rounds=max_tool_rounds,
brave_api_key=brave_api_key,
brave_timeout_seconds=brave_timeout_seconds,
web_page_timeout_seconds=web_page_timeout_seconds,
web_page_max_chars=web_page_max_chars,
)
@@ -314,6 +355,10 @@ def _parse_accounts(raw_accounts: Any, *, default_api_id: int | None, default_ap
no_updates=bool(raw_account.get("no_updates", False)),
takeout=bool(raw_account.get("takeout", False)),
hide_password=bool(raw_account.get("hide_password", False)),
use_qr=_parse_bool(
raw_account.get("use_qr", False),
field_name=f"accounts[{index}].use_qr",
),
)
)

228
app/llm.py Normal file
View File

@@ -0,0 +1,228 @@
from __future__ import annotations
import base64
import json
from typing import Any, cast
from micro_agents import (
Conversation,
LlmMessage,
Media,
Tool,
ToolCallMessage,
ToolCallResponseMessage,
UserMessage,
)
from micro_agents.conversation import Message, ReasoningMessage
from micro_agents.llm import OpenAIBasedLLM
class ChatCompletionsLLM(OpenAIBasedLLM):
"""micro-agents adapter for OpenAI-compatible Chat Completions APIs."""
async def ask(
self,
conversation: Conversation,
tools: list[Tool],
*,
instructions: str | None = None,
thinking: bool | None = None,
) -> list[Message]:
messages = self._serialize_conversation(conversation, instructions)
serialized_tools = self._serialize_tools(tools)
request: dict[str, Any] = {
"model": self._model,
"messages": messages,
"stream": False,
"extra_body": {
"thinking": {"type": "enabled" if thinking else "disabled"},
},
}
if serialized_tools:
request["tools"] = serialized_tools
response = await self._ai.chat.completions.create(**cast(Any, request))
if not response.choices:
raise RuntimeError("Chat Completions API returned no choices")
message = response.choices[0].message
result: list[Message] = []
reasoning = getattr(message, "reasoning_content", None)
if reasoning:
result.append(ReasoningMessage(str(reasoning)))
if message.content:
result.append(LlmMessage(message.content))
for call in message.tool_calls or ():
try:
arguments = json.loads(call.function.arguments)
except (TypeError, ValueError) as exc:
raise ValueError("Model returned invalid tool-call arguments") from exc
if not isinstance(arguments, dict):
raise ValueError("Model tool-call arguments must be an object")
result.append(ToolCallMessage(call.id, call.function.name, arguments))
return result
def _serialize_tools(self, tools: list[Tool]) -> list[dict[str, object]]:
response_tools = super()._serialize_tools(tools)
result: list[dict[str, object]] = []
for tool in response_tools:
function = {
key: tool[key]
for key in ("name", "description", "parameters")
if key in tool and tool[key] is not None
}
result.append({"type": "function", "function": function})
return result
@classmethod
def _serialize_conversation(
cls,
conversation: Conversation,
instructions: str | None,
) -> list[dict[str, object]]:
result: list[dict[str, object]] = []
if instructions:
result.append({"role": "system", "content": instructions})
pending_assistant: dict[str, object] | None = None
pending_attachments: list[Media] = []
def flush_assistant() -> None:
nonlocal pending_assistant
if pending_assistant is None:
return
pending_assistant.setdefault("content", "")
result.append(pending_assistant)
pending_assistant = None
def flush_attachments() -> None:
if not pending_attachments:
return
result.append(
{
"role": "user",
"content": cls._serialize_user_content(
"Attachments returned by tools:",
pending_attachments,
),
}
)
pending_attachments.clear()
for message in conversation.messages:
if isinstance(message, ToolCallResponseMessage):
flush_assistant()
result.append(
{
"role": "tool",
"tool_call_id": message.unique_id(),
"content": message.text(),
}
)
pending_attachments.extend(message.attachments())
continue
flush_attachments()
if isinstance(message, UserMessage):
flush_assistant()
result.append(
{
"role": "user",
"content": cls._serialize_user_content(
message.message(),
message.media(),
),
}
)
elif isinstance(message, ReasoningMessage):
flush_assistant()
pending_assistant = {
"role": "assistant",
"reasoning_content": message.text(),
}
elif isinstance(message, LlmMessage):
if pending_assistant is None:
pending_assistant = {"role": "assistant"}
pending_assistant["content"] = message.message()
elif isinstance(message, ToolCallMessage):
if pending_assistant is None:
pending_assistant = {"role": "assistant"}
calls = cast(list[dict[str, object]], pending_assistant.setdefault("tool_calls", []))
calls.append(
{
"id": message.unique_id(),
"type": "function",
"function": {
"name": message.tool_name(),
"arguments": json.dumps(
message.arguments(),
ensure_ascii=False,
separators=(",", ":"),
),
},
}
)
else:
raise ValueError(f"Unknown conversation message: {type(message).__name__}")
flush_assistant()
flush_attachments()
return result
@classmethod
def _serialize_user_content(
cls,
text: str,
media_items: list[Media],
) -> str | list[dict[str, object]]:
if not media_items:
return text
content: list[dict[str, object]] = []
if text:
content.append({"type": "text", "text": text})
content.extend(cls._serialize_media(media) for media in media_items)
return content
@staticmethod
def _serialize_media(media: Media) -> dict[str, object]:
if media.kind == "image":
if media.url is not None:
url = media.url
elif media.data is not None:
mime_type = media.mime_type or "image/jpeg"
encoded = base64.b64encode(media.data).decode("ascii")
url = f"data:{mime_type};base64,{encoded}"
else:
raise ValueError("Chat Completions image input requires data or URL")
return {
"type": "image_url",
"image_url": {"url": url, "detail": media.detail},
}
if media.kind == "audio":
if media.data is None:
raise ValueError("Chat Completions audio input requires inline data")
mime_type = media.mime_type or ""
audio_format = "mp3" if mime_type == "audio/mpeg" else "wav"
return {
"type": "input_audio",
"input_audio": {
"data": base64.b64encode(media.data).decode("ascii"),
"format": audio_format,
},
}
file: dict[str, object] = {}
if media.file_id is not None:
file["file_id"] = media.file_id
elif media.data is not None:
mime_type = media.mime_type or "application/octet-stream"
encoded = base64.b64encode(media.data).decode("ascii")
file["file_data"] = f"data:{mime_type};base64,{encoded}"
file["filename"] = media.filename or "file"
elif media.url is not None:
file["file_data"] = media.url
file["filename"] = media.filename or "file"
return {"type": "file", "file": file}

View File

@@ -78,7 +78,7 @@ class AccountManager:
register_common_handlers(client, self, state)
try:
await client.start()
await client.start(use_qr=account.use_qr)
state.me = await client.get_me()
state.status = "running"
log.info("Started account %s (%s)", account.name, account.session)

124
app/web_tools.py Normal file
View File

@@ -0,0 +1,124 @@
from __future__ import annotations
import asyncio
import ipaddress
import json
import socket
from collections.abc import Sequence
from typing import Any
from urllib.parse import urlsplit
from micro_agents import Tool
class WebPageTools:
def __init__(self, *, timeout_seconds: float = 45, max_chars: int = 60_000):
if timeout_seconds <= 0:
raise ValueError("timeout_seconds must be positive")
if max_chars < 1:
raise ValueError("max_chars must be positive")
self._timeout_seconds = timeout_seconds
self._max_chars = max_chars
def tools(self) -> list[Tool]:
return [Tool(self.read_web_page, name="read_web_page", strict=False)]
async def read_web_page(self, url: str) -> str:
"""Read a public web page in a Playwright browser and return cleaned Markdown.
Use this before analyzing, summarizing, or quoting a URL. Unlike web_search,
this loads the page itself and can render JavaScript-driven content.
"""
safe_url = await _ensure_public_url(url)
try:
result = await asyncio.wait_for(
_crawl_with_playwright(safe_url, self._timeout_seconds),
timeout=self._timeout_seconds + 5,
)
except TimeoutError as exc:
raise RuntimeError("Web page crawl timed out") from exc
final_url = str(getattr(result, "redirected_url", None) or getattr(result, "url", safe_url))
await _ensure_public_url(final_url)
if not getattr(result, "success", False):
error = str(getattr(result, "error_message", None) or "unknown crawl error")
raise RuntimeError(f"Could not read web page: {error}")
markdown_result = getattr(result, "markdown", None)
markdown = str(markdown_result or "").strip()
if not markdown:
raise RuntimeError("Web page did not contain readable content")
truncated = len(markdown) > self._max_chars
if truncated:
markdown = markdown[: self._max_chars].rstrip()
metadata = getattr(result, "metadata", None)
title = metadata.get("title") if isinstance(metadata, dict) else None
return json.dumps(
{
"url": final_url,
"title": title,
"status_code": getattr(result, "status_code", None),
"truncated": truncated,
"markdown": markdown,
},
ensure_ascii=False,
)
async def _crawl_with_playwright(url: str, timeout_seconds: float) -> Any:
from crawl4ai import AsyncWebCrawler, BrowserConfig, CacheMode, CrawlerRunConfig
browser_config = BrowserConfig(
browser_type="chromium",
headless=True,
text_mode=True,
light_mode=True,
enable_stealth=True,
verbose=False,
)
run_config = CrawlerRunConfig(
cache_mode=CacheMode.BYPASS,
page_timeout=max(1, int(timeout_seconds * 1000)),
wait_until="domcontentloaded",
remove_overlay_elements=True,
remove_consent_popups=True,
exclude_social_media_links=True,
process_iframes=False,
scan_full_page=False,
verbose=False,
log_console=False,
)
async with AsyncWebCrawler(config=browser_config) as crawler:
return await crawler.arun(url=url, config=run_config)
async def _ensure_public_url(value: str) -> str:
url = value.strip()
if not url or len(url) > 2048:
raise ValueError("url must contain at most 2048 characters")
parsed = urlsplit(url)
if parsed.scheme not in {"http", "https"}:
raise ValueError("Only http and https URLs are allowed")
if parsed.username is not None or parsed.password is not None:
raise ValueError("Credentials in URLs are not allowed")
hostname = parsed.hostname
if not hostname:
raise ValueError("URL must include a hostname")
try:
addresses = [ipaddress.ip_address(hostname)]
except ValueError:
addresses = await asyncio.to_thread(_resolve_addresses, hostname, parsed.port)
if not addresses or any(not address.is_global for address in addresses):
raise ValueError("Local and private network URLs are not allowed")
return url
def _resolve_addresses(hostname: str, port: int | None) -> Sequence[ipaddress.IPv4Address | ipaddress.IPv6Address]:
try:
records = socket.getaddrinfo(hostname, port or 443, type=socket.SOCK_STREAM)
except socket.gaierror as exc:
raise ValueError("URL hostname could not be resolved") from exc
return tuple({ipaddress.ip_address(record[4][0]) for record in records})