feat: add QR login and browser-backed web tools
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
|
||||
@@ -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
228
app/llm.py
Normal 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}
|
||||
@@ -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
124
app/web_tools.py
Normal 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})
|
||||
Reference in New Issue
Block a user