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

@@ -1,7 +1,7 @@
TG_API_ID=123456 TG_API_ID=123456
TG_API_HASH=your_api_hash_here TG_API_HASH=your_api_hash_here
SESSION_DIR=sessions SESSION_DIR=sessions
ACCOUNTS_JSON=[{"name":"personal","session":"personal"},{"name":"work","session":"work"}] ACCOUNTS_JSON=[{"name":"personal","session":"personal","use_qr":true},{"name":"work","session":"work","use_qr":true}]
BOT_CONFIG=config.toml BOT_CONFIG=config.toml
ELEVENLABS_API_KEY=your_elevenlabs_api_key_here ELEVENLABS_API_KEY=your_elevenlabs_api_key_here
ELEVENLABS_MODEL_ID=scribe_v2 ELEVENLABS_MODEL_ID=scribe_v2
@@ -18,3 +18,5 @@ DEEPSEEK_MODEL=deepseek-v4-flash
# AI_MAX_TOOL_ROUNDS=16 # AI_MAX_TOOL_ROUNDS=16
BRAVE_SEARCH_API_KEY=your_brave_search_api_key_here BRAVE_SEARCH_API_KEY=your_brave_search_api_key_here
# BRAVE_SEARCH_TIMEOUT_SECONDS=10 # BRAVE_SEARCH_TIMEOUT_SECONDS=10
# AI_WEB_PAGE_TIMEOUT_SECONDS=45
# AI_WEB_PAGE_MAX_CHARS=60000

1
.gitignore vendored
View File

@@ -1,6 +1,7 @@
.venv/ .venv/
__pycache__/ __pycache__/
*.pyc *.pyc
*.log
sessions/ sessions/
*.acl.json *.acl.json
config.json config.json

View File

@@ -6,6 +6,7 @@ Repository guidance for automated coding tools and contributors.
```bash ```bash
uv sync uv sync
uv run crawl4ai-setup
uv run python main.py uv run python main.py
``` ```
@@ -23,9 +24,11 @@ Copy `config.example.toml` to `config.toml` and fill in credentials, or use envi
Key env vars: `TG_API_ID`, `TG_API_HASH`, `SESSION_DIR`, `ACCOUNTS_JSON` (JSON list) or `ACCOUNT_SESSIONS` (comma-separated session names), `BOT_CONFIG` (path to config file), and `BOT_DOTENV_PATH` (path to `.env`). Key env vars: `TG_API_ID`, `TG_API_HASH`, `SESSION_DIR`, `ACCOUNTS_JSON` (JSON list) or `ACCOUNT_SESSIONS` (comma-separated session names), `BOT_CONFIG` (path to config file), and `BOT_DOTENV_PATH` (path to `.env`).
Set `use_qr = true` on an account to authorize a new user session by scanning the QR code printed in the terminal. Existing authorized sessions start normally. If the account has 2FA enabled, Kurigram asks for its password in the terminal after the QR code is scanned.
The `.txt` transcription command uses `[elevenlabs]` settings or `ELEVENLABS_API_KEY`, `ELEVENLABS_MODEL_ID`, `ELEVENLABS_LANGUAGE_CODE`, and `ELEVENLABS_REQUEST_TIMEOUT_SECONDS`. The `.txt` transcription command uses `[elevenlabs]` settings or `ELEVENLABS_API_KEY`, `ELEVENLABS_MODEL_ID`, `ELEVENLABS_LANGUAGE_CODE`, and `ELEVENLABS_REQUEST_TIMEOUT_SECONDS`.
The `.ai` agent command uses `[ai.glm]` by default and `[ai.deepseek]` with the `.ds` flag. Provider environment variables are `GLM_API_URL`, `GLM_API_KEY`, `GLM_MODEL`, `DEEPSEEK_API_URL`, `DEEPSEEK_API_KEY`, and `DEEPSEEK_MODEL`. `.th` enables reasoning. Brave Search is available in every AI session and requires `BRAVE_SEARCH_API_KEY` or `ai.brave_api_key`. The `.ai` agent command uses `[ai.glm]` by default and `[ai.deepseek]` with the `.ds` flag. Provider environment variables are `GLM_API_URL`, `GLM_API_KEY`, `GLM_MODEL`, `DEEPSEEK_API_URL`, `DEEPSEEK_API_KEY`, and `DEEPSEEK_MODEL`. Z.AI GLM uses the Chat Completions protocol; DeepSeek uses the Responses protocol. `.th` enables reasoning. Brave Search is available in every AI session and requires `BRAVE_SEARCH_API_KEY` or `ai.brave_api_key`. Crawl4AI's `read_web_page` tool is also always available and uses headless Playwright Chromium to render public pages into Markdown before analysis. Configure it with `ai.web_page_timeout_seconds` / `AI_WEB_PAGE_TIMEOUT_SECONDS` and `ai.web_page_max_chars` / `AI_WEB_PAGE_MAX_CHARS`.
AI prompt templates are always loaded from `prompts/MASTER.txt` and `prompts/USER.txt` for every invocation. C-style `/* ... */` blocks and `//` comments (at line start or following whitespace) are removed before rendering. Both `{name}` and `{{name}}` placeholders are supported, including dotted values such as `caller_user.id`, `caller_user.name`, `owner.id`, `owner.name`, `is_owner`, `text`, `chat.*`, `message.*`, `reply.*`, `account.*`, `provider`, `thinking`, and `context_messages`. Unknown placeholders fail the command with 👎. AI prompt templates are always loaded from `prompts/MASTER.txt` and `prompts/USER.txt` for every invocation. C-style `/* ... */` blocks and `//` comments (at line start or following whitespace) are removed before rendering. Both `{name}` and `{{name}}` placeholders are supported, including dotted values such as `caller_user.id`, `caller_user.name`, `owner.id`, `owner.name`, `is_owner`, `text`, `chat.*`, `message.*`, `reply.*`, `account.*`, `provider`, `thinking`, and `context_messages`. Unknown placeholders fail the command with 👎.

View File

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

View File

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

View File

@@ -31,6 +31,7 @@ class AccountConfig:
no_updates: bool = False no_updates: bool = False
takeout: bool = False takeout: bool = False
hide_password: bool = False hide_password: bool = False
use_qr: bool = False
@dataclass(slots=True) @dataclass(slots=True)
@@ -69,6 +70,8 @@ class AiConfig:
max_tool_rounds: int = 16 max_tool_rounds: int = 16
brave_api_key: str | None = None brave_api_key: str | None = None
brave_timeout_seconds: float = 10.0 brave_timeout_seconds: float = 10.0
web_page_timeout_seconds: float = 45.0
web_page_max_chars: int = 60_000
@dataclass(slots=True) @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 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: def _parse_float(value: Any, *, field_name: str) -> float:
try: try:
return float(value) return float(value)
@@ -206,6 +223,14 @@ def _parse_ai_config(raw: Any) -> AiConfig:
raw_config.get("brave_timeout_seconds", 10), raw_config.get("brave_timeout_seconds", 10),
field_name="ai.brave_timeout_seconds", 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") 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 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"], os.environ["BRAVE_SEARCH_TIMEOUT_SECONDS"],
field_name="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: if max_context_messages < 0:
raise ValueError("ai.max_context_messages must not be negative") 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") raise ValueError("ai.max_tool_rounds must be greater than zero")
if brave_timeout_seconds <= 0: if brave_timeout_seconds <= 0:
raise ValueError("ai.brave_timeout_seconds must be greater than zero") 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( return AiConfig(
glm=glm, glm=glm,
@@ -252,6 +291,8 @@ def _parse_ai_config(raw: Any) -> AiConfig:
max_tool_rounds=max_tool_rounds, max_tool_rounds=max_tool_rounds,
brave_api_key=brave_api_key, brave_api_key=brave_api_key,
brave_timeout_seconds=brave_timeout_seconds, 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)), no_updates=bool(raw_account.get("no_updates", False)),
takeout=bool(raw_account.get("takeout", False)), takeout=bool(raw_account.get("takeout", False)),
hide_password=bool(raw_account.get("hide_password", 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) register_common_handlers(client, self, state)
try: try:
await client.start() await client.start(use_qr=account.use_qr)
state.me = await client.get_me() state.me = await client.get_me()
state.status = "running" state.status = "running"
log.info("Started account %s (%s)", account.name, account.session) 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})

View File

@@ -5,10 +5,12 @@ session_dir = "sessions"
[[accounts]] [[accounts]]
name = "personal" name = "personal"
session = "personal" session = "personal"
use_qr = true
[[accounts]] [[accounts]]
name = "work" name = "work"
session = "work" session = "work"
use_qr = true
[elevenlabs] [elevenlabs]
api_key = "your_elevenlabs_api_key_here" api_key = "your_elevenlabs_api_key_here"
@@ -22,6 +24,8 @@ max_media_bytes = 5242880
max_tool_rounds = 16 max_tool_rounds = 16
brave_api_key = "your_brave_search_api_key_here" brave_api_key = "your_brave_search_api_key_here"
brave_timeout_seconds = 10 brave_timeout_seconds = 10
web_page_timeout_seconds = 45
web_page_max_chars = 60000
[ai.glm] [ai.glm]
api_url = "https://api.z.ai/api/paas/v4" api_url = "https://api.z.ai/api/paas/v4"

View File

@@ -43,6 +43,8 @@ Missing Telegram values are rendered as an empty string.
You are a helpful assistant embedded in a Telegram client. Answer in the user's language. You are a helpful assistant embedded in a Telegram client. Answer in the user's language.
When the request contains or depends on a web-page URL, use read_web_page before analyzing, summarizing, or quoting that page. Use web_search to discover relevant URLs, then read the selected pages with read_web_page before drawing conclusions.
The caller is {{caller_user.name}} (id={{caller_user.id}}, username={{caller_user.username}}). The caller is {{caller_user.name}} (id={{caller_user.id}}, username={{caller_user.username}}).
The account owner is {{owner.name}} (id={{owner.id}}, username={{owner.username}}). The account owner is {{owner.name}} (id={{owner.id}}, username={{owner.username}}).
Caller is owner: {{is_owner}}. Caller is owner: {{is_owner}}.

View File

@@ -4,10 +4,12 @@ version = "0.1.0"
requires-python = ">=3.12" requires-python = ">=3.12"
dependencies = [ dependencies = [
"aiohttp", "aiohttp",
"crawl4ai==0.9.3",
"kurigram==2.2.24", "kurigram==2.2.24",
"micro-agents==0.1.5", "micro-agents==0.1.5",
"micro-agents-tools==0.1.5", "micro-agents-tools==0.1.5",
"python-dotenv", "python-dotenv",
"qrcode",
"tgcrypto", "tgcrypto",
"uvloop; sys_platform != 'win32'", "uvloop; sys_platform != 'win32'",
] ]

View File

@@ -11,6 +11,7 @@ from app.agentic import (
AgentSessionStore, AgentSessionStore,
AgentSyntaxError, AgentSyntaxError,
_build_context_messages, _build_context_messages,
_create_llm,
_download_message_media, _download_message_media,
render_prompt_template, render_prompt_template,
parse_agent_invocation, parse_agent_invocation,
@@ -22,6 +23,26 @@ from app.config import AiConfig, AiProviderConfig
class AgentInvocationTests(unittest.TestCase): class AgentInvocationTests(unittest.TestCase):
def test_glm_uses_chat_completions_and_deepseek_uses_responses(self) -> None:
provider = AiProviderConfig("https://example.test", "key", "model")
with (
patch("app.agentic.ChatCompletionsLLM") as chat_llm,
patch("app.agentic.OpenAIBasedLLM") as responses_llm,
):
_create_llm(provider, parse_agent_invocation("prompt", AiConfig()))
_create_llm(provider, parse_agent_invocation(".ds prompt", AiConfig()))
chat_llm.assert_called_once_with(
api_url="https://example.test",
api_key="key",
model="model",
)
responses_llm.assert_called_once_with(
api_url="https://example.test",
api_key="key",
model="model",
)
def test_prompt_comments_are_removed_without_breaking_urls(self) -> None: def test_prompt_comments_are_removed_without_breaking_urls(self) -> None:
value = strip_prompt_comments( value = strip_prompt_comments(
"// first\nkeep https://example.com // trailing\n/* block\ncomment */\nlast" "// first\nkeep https://example.com // trailing\n/* block\ncomment */\nlast"
@@ -168,9 +189,11 @@ class AgentContextTests(unittest.IsolatedAsyncioTestCase):
patch("app.agentic.OpenAIBasedLLM") as llm_class, patch("app.agentic.OpenAIBasedLLM") as llm_class,
patch("app.agentic.Aggregator", return_value=agent) as aggregator_class, patch("app.agentic.Aggregator", return_value=agent) as aggregator_class,
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._build_context_messages", AsyncMock(return_value=[])), patch("app.agentic._build_context_messages", AsyncMock(return_value=[])),
): ):
brave_tools.return_value.tools.return_value = [] brave_tools.return_value.tools.return_value = []
web_page_tools.return_value.tools.return_value = []
result = await run_agent_turn( result = await run_agent_turn(
SimpleNamespace(), SimpleNamespace(),
command, command,
@@ -188,6 +211,7 @@ class AgentContextTests(unittest.IsolatedAsyncioTestCase):
) )
self.assertTrue(aggregator_class.call_args.kwargs["thinking"]) self.assertTrue(aggregator_class.call_args.kwargs["thinking"])
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)
result.handle.close() result.handle.close()
async def test_reply_to_ai_response_reuses_existing_session(self) -> None: async def test_reply_to_ai_response_reuses_existing_session(self) -> None:

View File

@@ -45,6 +45,8 @@ class ConfigAiTests(unittest.TestCase):
{ {
"glm": {"api_key": "glm-key", "model": "glm-model"}, "glm": {"api_key": "glm-key", "model": "glm-model"},
"deepseek": {"api_key": "ds-key", "model": "ds-model"}, "deepseek": {"api_key": "ds-key", "model": "ds-model"},
"web_page_timeout_seconds": 20,
"web_page_max_chars": 12345,
} }
) )
@@ -52,6 +54,8 @@ class ConfigAiTests(unittest.TestCase):
self.assertEqual(config.glm.model, "glm-model") self.assertEqual(config.glm.model, "glm-model")
self.assertEqual(config.deepseek.api_key, "ds-key") self.assertEqual(config.deepseek.api_key, "ds-key")
self.assertEqual(config.deepseek.model, "ds-model") self.assertEqual(config.deepseek.model, "ds-model")
self.assertEqual(config.web_page_timeout_seconds, 20)
self.assertEqual(config.web_page_max_chars, 12345)
def test_ai_provider_env_overrides(self) -> None: def test_ai_provider_env_overrides(self) -> None:
env = { env = {
@@ -59,6 +63,8 @@ class ConfigAiTests(unittest.TestCase):
"GLM_MODEL": "env-glm-model", "GLM_MODEL": "env-glm-model",
"DEEPSEEK_API_KEY": "env-ds", "DEEPSEEK_API_KEY": "env-ds",
"DEEPSEEK_MODEL": "env-ds-model", "DEEPSEEK_MODEL": "env-ds-model",
"AI_WEB_PAGE_TIMEOUT_SECONDS": "30",
"AI_WEB_PAGE_MAX_CHARS": "54321",
} }
with patch.dict("os.environ", env, clear=True): with patch.dict("os.environ", env, clear=True):
config = _parse_ai_config({}) config = _parse_ai_config({})
@@ -67,6 +73,8 @@ class ConfigAiTests(unittest.TestCase):
self.assertEqual(config.glm.model, "env-glm-model") self.assertEqual(config.glm.model, "env-glm-model")
self.assertEqual(config.deepseek.api_key, "env-ds") self.assertEqual(config.deepseek.api_key, "env-ds")
self.assertEqual(config.deepseek.model, "env-ds-model") self.assertEqual(config.deepseek.model, "env-ds-model")
self.assertEqual(config.web_page_timeout_seconds, 30)
self.assertEqual(config.web_page_max_chars, 54321)
if __name__ == "__main__": if __name__ == "__main__":

View File

@@ -21,6 +21,7 @@ session_dir = "sessions"
[[accounts]] [[accounts]]
name = "personal" name = "personal"
session = "personal" session = "personal"
use_qr = true
[elevenlabs] [elevenlabs]
api_key = "test-key" api_key = "test-key"
@@ -34,6 +35,7 @@ model_id = "scribe_v2"
self.assertEqual(config.api_id, 123456) self.assertEqual(config.api_id, 123456)
self.assertEqual(config.accounts[0].session, "personal") self.assertEqual(config.accounts[0].session, "personal")
self.assertTrue(config.accounts[0].use_qr)
self.assertEqual(config.elevenlabs.api_key, "test-key") self.assertEqual(config.elevenlabs.api_key, "test-key")
self.assertEqual(config.session_dir, root / "sessions") self.assertEqual(config.session_dir, root / "sessions")
self.assertEqual(config.ai.prompt_dir, root / "prompts") self.assertEqual(config.ai.prompt_dir, root / "prompts")

101
tests/test_llm.py Normal file
View File

@@ -0,0 +1,101 @@
from __future__ import annotations
import unittest
from types import SimpleNamespace
from unittest.mock import AsyncMock
from micro_agents import Conversation, LlmMessage, Tool, ToolCallMessage, ToolCallResponseMessage, UserMessage
from micro_agents.conversation import ReasoningMessage
from app.llm import ChatCompletionsLLM
class ChatCompletionsLlmTests(unittest.IsolatedAsyncioTestCase):
async def test_uses_chat_completions_with_system_prompt_and_thinking(self) -> None:
response_message = SimpleNamespace(
content="done",
reasoning_content="reasoning",
tool_calls=None,
)
create = AsyncMock(
return_value=SimpleNamespace(
choices=[SimpleNamespace(message=response_message)],
)
)
llm = ChatCompletionsLLM("https://glm.example/v4", "key", "glm-model")
llm._ai = SimpleNamespace( # type: ignore[assignment]
chat=SimpleNamespace(completions=SimpleNamespace(create=create))
)
result = await llm.ask(
Conversation([UserMessage("hello")]),
[],
instructions="system prompt",
thinking=True,
)
request = create.await_args.kwargs
self.assertEqual(request["model"], "glm-model")
self.assertEqual(
request["messages"],
[
{"role": "system", "content": "system prompt"},
{"role": "user", "content": "hello"},
],
)
self.assertEqual(request["extra_body"], {"thinking": {"type": "enabled"}})
self.assertIsInstance(result[0], ReasoningMessage)
self.assertIsInstance(result[1], LlmMessage)
async def test_serializes_and_parses_tool_calls(self) -> None:
model_call = SimpleNamespace(
id="call-2",
function=SimpleNamespace(name="lookup", arguments='{"query":"news"}'),
)
create = AsyncMock(
return_value=SimpleNamespace(
choices=[
SimpleNamespace(
message=SimpleNamespace(
content="",
reasoning_content=None,
tool_calls=[model_call],
)
)
],
)
)
llm = ChatCompletionsLLM("https://glm.example/v4", "key", "glm-model")
llm._ai = SimpleNamespace( # type: ignore[assignment]
chat=SimpleNamespace(completions=SimpleNamespace(create=create))
)
def lookup(query: str) -> str:
"""Look up a query."""
return query
conversation = Conversation(
[
UserMessage("find it"),
ReasoningMessage("need a tool"),
LlmMessage("checking"),
ToolCallMessage("call-1", "lookup", {"query": "weather"}),
ToolCallResponseMessage("call-1", "sunny"),
]
)
result = await llm.ask(conversation, [Tool(lookup)], thinking=False)
request = create.await_args.kwargs
self.assertEqual(request["extra_body"], {"thinking": {"type": "disabled"}})
self.assertEqual(request["tools"][0]["type"], "function")
self.assertEqual(request["tools"][0]["function"]["name"], "lookup")
self.assertEqual(request["messages"][1]["role"], "assistant")
self.assertEqual(request["messages"][1]["reasoning_content"], "need a tool")
self.assertEqual(request["messages"][2]["role"], "tool")
self.assertIsInstance(result[0], ToolCallMessage)
self.assertEqual(result[0].tool_name(), "lookup")
self.assertEqual(result[0].arguments(), {"query": "news"})
if __name__ == "__main__":
unittest.main()

37
tests/test_manager_qr.py Normal file
View File

@@ -0,0 +1,37 @@
from __future__ import annotations
import tempfile
import unittest
from pathlib import Path
from unittest.mock import AsyncMock, MagicMock, patch
from app.config import AccountConfig, AppConfig
from app.manager import AccountManager
class AccountManagerQrTests(unittest.IsolatedAsyncioTestCase):
async def test_qr_flag_is_passed_when_starting_account(self) -> None:
with tempfile.TemporaryDirectory() as temp_dir:
config = AppConfig(
api_id=123456,
api_hash="hash",
session_dir=Path(temp_dir),
accounts=[AccountConfig(name="personal", session="personal", use_qr=True)],
)
manager = AccountManager(config)
client = MagicMock()
client.start = AsyncMock()
client.get_me = AsyncMock(return_value=MagicMock(id=42))
with (
patch.object(AccountManager, "_make_client", return_value=client),
patch("app.manager.register_common_handlers"),
):
await manager.start_account(manager.states[0])
client.start.assert_awaited_once_with(use_qr=True)
self.assertEqual(manager.states[0].status, "running")
if __name__ == "__main__":
unittest.main()

81
tests/test_web_tools.py Normal file
View File

@@ -0,0 +1,81 @@
from __future__ import annotations
import json
import unittest
from types import SimpleNamespace
from unittest.mock import AsyncMock, patch
from app.web_tools import WebPageTools, _ensure_public_url
class WebPageToolsTests(unittest.IsolatedAsyncioTestCase):
async def test_private_network_urls_are_rejected(self) -> None:
for url in (
"http://127.0.0.1/admin",
"http://10.0.0.1/",
"http://169.254.169.254/latest/meta-data/",
"http://[::1]/",
):
with self.subTest(url=url):
with self.assertRaisesRegex(ValueError, "private network"):
await _ensure_public_url(url)
async def test_reads_page_as_bounded_markdown(self) -> None:
crawl_result = SimpleNamespace(
success=True,
url="https://example.com/article",
redirected_url=None,
status_code=200,
metadata={"title": "Example"},
markdown="abcdefghij",
)
with (
patch(
"app.web_tools._ensure_public_url",
AsyncMock(side_effect=lambda value: value),
) as ensure_public,
patch(
"app.web_tools._crawl_with_playwright",
AsyncMock(return_value=crawl_result),
) as crawl,
):
payload = json.loads(
await WebPageTools(timeout_seconds=12, max_chars=5).read_web_page(
"https://example.com/article"
)
)
self.assertEqual(payload["markdown"], "abcde")
self.assertTrue(payload["truncated"])
self.assertEqual(payload["title"], "Example")
self.assertEqual(ensure_public.await_count, 2)
crawl.assert_awaited_once_with("https://example.com/article", 12)
async def test_crawl_failure_is_reported_to_agent(self) -> None:
crawl_result = SimpleNamespace(
success=False,
url="https://example.com",
redirected_url=None,
error_message="blocked",
)
with (
patch(
"app.web_tools._ensure_public_url",
AsyncMock(side_effect=lambda value: value),
),
patch(
"app.web_tools._crawl_with_playwright",
AsyncMock(return_value=crawl_result),
),
):
with self.assertRaisesRegex(RuntimeError, "blocked"):
await WebPageTools().read_web_page("https://example.com")
def test_tool_is_registered_for_all_agent_sessions(self) -> None:
tools = WebPageTools().tools()
self.assertEqual([tool.name for tool in tools], ["read_web_page"])
if __name__ == "__main__":
unittest.main()

2053
uv.lock generated

File diff suppressed because it is too large Load Diff