mirror of
https://github.com/HKUDS/nanobot.git
synced 2026-08-07 09:58:34 +00:00
refactor: enforce BasedPyright strict type checking (#5158)
This commit is contained in:
parent
e703481755
commit
757ad9c764
@ -24,6 +24,14 @@ Fix bugs by changing only what is necessary. Do not bundle unrelated refactors o
|
|||||||
|
|
||||||
A bugfix should make the protected invariant clear, change the smallest surface that enforces it, and add only the closest regression test. If a diff starts changing ownership boundaries or mixing behavior changes with clean-up, split it before it becomes hard to review.
|
A bugfix should make the protected invariant clear, change the smallest surface that enforces it, and add only the closest regression test. If a diff starts changing ownership boundaries or mixing behavior changes with clean-up, split it before it becomes hard to review.
|
||||||
|
|
||||||
|
## Type dynamic boundaries at the edge
|
||||||
|
|
||||||
|
Wire payloads, persisted records, and third-party SDK objects are untrusted dynamic boundaries. Prefer a parser or small normalizer at the owning edge, and use `TypedDict` for stable dictionary shapes, so validation happens once and internal code receives a concrete type. Do not spread raw dynamic dictionaries or SDK objects through the core.
|
||||||
|
|
||||||
|
Stable first-party dependencies must be typed where they are stored or passed. Do not declare an internal service, context field, or callback result as `Any` and then recover its real type with consumer-side casts. Use the concrete type or a narrow `Protocol`; reserve `Any` for genuinely dynamic boundaries.
|
||||||
|
|
||||||
|
`typing.cast` performs no runtime validation. Every new cast must be supported by a runtime check on the same path or by an explicit invariant that is clear from construction and control flow (and documented locally when it is not obvious). If input can violate the claimed type, handle that invalid case before casting; never use `cast` only to silence BasedPyright.
|
||||||
|
|
||||||
## Explicit over magical
|
## Explicit over magical
|
||||||
|
|
||||||
Configuration must be declared explicitly in `config/schema.py` Pydantic models. Error handling should raise clear exceptions rather than silently correcting bad input. Provider auto-detection exists, but every resolution path must be traceable from the factory to the concrete provider class.
|
Configuration must be declared explicitly in `config/schema.py` Pydantic models. Error handling should raise clear exceptions rather than silently correcting bad input. Provider auto-detection exists, but every resolution path must be traceable from the factory to the concrete provider class.
|
||||||
|
|||||||
4
.github/workflows/ci.yml
vendored
4
.github/workflows/ci.yml
vendored
@ -129,6 +129,10 @@ jobs:
|
|||||||
if: matrix.coverage
|
if: matrix.coverage
|
||||||
run: uv run --no-sync ruff check nanobot tests conftest.py
|
run: uv run --no-sync ruff check nanobot tests conftest.py
|
||||||
|
|
||||||
|
- name: Type check with BasedPyright (strict)
|
||||||
|
if: matrix.coverage
|
||||||
|
run: uv run --no-sync basedpyright
|
||||||
|
|
||||||
- name: Run tests with coverage
|
- name: Run tests with coverage
|
||||||
if: matrix.coverage
|
if: matrix.coverage
|
||||||
run: >-
|
run: >-
|
||||||
|
|||||||
@ -11,6 +11,11 @@ nanobot is a lightweight, open-source AI agent framework written in Python with
|
|||||||
pytest tests/test_openai_api.py::test_function -v
|
pytest tests/test_openai_api.py::test_function -v
|
||||||
ruff check nanobot/
|
ruff check nanobot/
|
||||||
|
|
||||||
|
# Strict type checking (matches CI)
|
||||||
|
uv sync --all-extras --dev
|
||||||
|
uv run --no-sync python -m scripts.install_channel_dependencies --all-channels
|
||||||
|
uv run --no-sync basedpyright
|
||||||
|
|
||||||
# WebUI: dev server (proxies API/WS to gateway :8765), build, test
|
# WebUI: dev server (proxies API/WS to gateway :8765), build, test
|
||||||
# Build outputs to ../nanobot/web/dist (bundled into the Python wheel)
|
# Build outputs to ../nanobot/web/dist (bundled into the Python wheel)
|
||||||
cd webui && bun run dev # or NANOBOT_API_URL=... bun run dev
|
cd webui && bun run dev # or NANOBOT_API_URL=... bun run dev
|
||||||
|
|||||||
@ -78,6 +78,20 @@ ruff check nanobot/
|
|||||||
ruff format <files-you-changed>
|
ruff format <files-you-changed>
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Strict Type Checking
|
||||||
|
|
||||||
|
Strict type checking covers optional providers and channels. Reproduce the CI environment
|
||||||
|
with the same dependency sources and commands:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
uv sync --all-extras --dev
|
||||||
|
uv run --no-sync python -m scripts.install_channel_dependencies --all-channels
|
||||||
|
uv run --no-sync basedpyright
|
||||||
|
```
|
||||||
|
|
||||||
|
Keep `--no-sync` on the final commands: channel dependencies come from their package
|
||||||
|
manifests and are installed explicitly by the setup step.
|
||||||
|
|
||||||
## Contribution License
|
## Contribution License
|
||||||
|
|
||||||
By submitting a contribution, you confirm that you have the right to submit it
|
By submitting a contribution, you confirm that you have the right to submit it
|
||||||
|
|||||||
@ -6,6 +6,32 @@ import tomllib
|
|||||||
from importlib.metadata import PackageNotFoundError
|
from importlib.metadata import PackageNotFoundError
|
||||||
from importlib.metadata import version as _pkg_version
|
from importlib.metadata import version as _pkg_version
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
from typing import TYPE_CHECKING, Any
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from .agent.tools.context import RequestContext
|
||||||
|
from .bus.runtime_events import SessionTurnPersisted
|
||||||
|
from .nanobot import (
|
||||||
|
STREAM_EVENT_REASONING_COMPLETED,
|
||||||
|
STREAM_EVENT_REASONING_DELTA,
|
||||||
|
STREAM_EVENT_RUN_COMPLETED,
|
||||||
|
STREAM_EVENT_RUN_FAILED,
|
||||||
|
STREAM_EVENT_RUN_STARTED,
|
||||||
|
STREAM_EVENT_TEXT_COMPLETED,
|
||||||
|
STREAM_EVENT_TEXT_DELTA,
|
||||||
|
STREAM_EVENT_TOOL_COMPLETED,
|
||||||
|
STREAM_EVENT_TOOL_FAILED,
|
||||||
|
STREAM_EVENT_TOOL_STARTED,
|
||||||
|
STREAM_EVENT_TYPES,
|
||||||
|
Nanobot,
|
||||||
|
RunResult,
|
||||||
|
RunStream,
|
||||||
|
SessionInfo,
|
||||||
|
SessionSnapshot,
|
||||||
|
StreamEvent,
|
||||||
|
StreamEventType,
|
||||||
|
)
|
||||||
|
from .runtime_context import RuntimeContextBlock, RuntimeContextProvider
|
||||||
|
|
||||||
|
|
||||||
def _read_pyproject_version() -> str | None:
|
def _read_pyproject_version() -> str | None:
|
||||||
@ -54,7 +80,7 @@ _LAZY_EXPORTS = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def __getattr__(name: str):
|
def __getattr__(name: str) -> Any:
|
||||||
module_path = _LAZY_EXPORTS.get(name)
|
module_path = _LAZY_EXPORTS.get(name)
|
||||||
if module_path is None:
|
if module_path is None:
|
||||||
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
||||||
|
|||||||
@ -4,7 +4,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
from collections.abc import Collection
|
from collections.abc import Collection
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from typing import TYPE_CHECKING, Callable, Coroutine
|
from typing import TYPE_CHECKING, Any, Callable, Coroutine, cast
|
||||||
|
|
||||||
from loguru import logger
|
from loguru import logger
|
||||||
|
|
||||||
@ -65,7 +65,7 @@ class AutoCompact:
|
|||||||
|
|
||||||
def check_expired(
|
def check_expired(
|
||||||
self,
|
self,
|
||||||
schedule_background: Callable[[Coroutine], None],
|
schedule_background: Callable[[Coroutine[Any, Any, None]], None],
|
||||||
resolve_runtime: Callable[[Session], LLMRuntime],
|
resolve_runtime: Callable[[Session], LLMRuntime],
|
||||||
active_session_keys: Collection[str] = (),
|
active_session_keys: Collection[str] = (),
|
||||||
) -> None:
|
) -> None:
|
||||||
@ -103,8 +103,8 @@ class AutoCompact:
|
|||||||
meta = session.metadata.get("_last_summary")
|
meta = session.metadata.get("_last_summary")
|
||||||
if isinstance(meta, dict):
|
if isinstance(meta, dict):
|
||||||
self._summaries[key] = (
|
self._summaries[key] = (
|
||||||
meta["text"],
|
cast(str, meta["text"]),
|
||||||
datetime.fromisoformat(meta["last_active"]),
|
datetime.fromisoformat(cast(str, meta["last_active"])),
|
||||||
)
|
)
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.exception("Auto-compact: failed for {}", key)
|
logger.exception("Auto-compact: failed for {}", key)
|
||||||
@ -126,5 +126,8 @@ class AutoCompact:
|
|||||||
# Cold path: summary persisted in session metadata (process restarted).
|
# Cold path: summary persisted in session metadata (process restarted).
|
||||||
meta = session.metadata.get("_last_summary")
|
meta = session.metadata.get("_last_summary")
|
||||||
if isinstance(meta, dict):
|
if isinstance(meta, dict):
|
||||||
return session, self._format_summary(meta["text"], datetime.fromisoformat(meta["last_active"]))
|
return session, self._format_summary(
|
||||||
|
cast(str, meta["text"]),
|
||||||
|
datetime.fromisoformat(cast(str, meta["last_active"])),
|
||||||
|
)
|
||||||
return session, None
|
return session, None
|
||||||
|
|||||||
@ -4,7 +4,7 @@ import base64
|
|||||||
import mimetypes
|
import mimetypes
|
||||||
import platform
|
import platform
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any, Mapping, Sequence
|
from typing import Any, Mapping, Sequence, cast
|
||||||
|
|
||||||
from nanobot.agent.memory import MemoryStore
|
from nanobot.agent.memory import MemoryStore
|
||||||
from nanobot.agent.skills import SkillsLoader
|
from nanobot.agent.skills import SkillsLoader
|
||||||
@ -148,7 +148,12 @@ class ContextBuilder:
|
|||||||
|
|
||||||
def _to_blocks(value: Any) -> list[dict[str, Any]]:
|
def _to_blocks(value: Any) -> list[dict[str, Any]]:
|
||||||
if isinstance(value, list):
|
if isinstance(value, list):
|
||||||
return [item if isinstance(item, dict) else {"type": "text", "text": str(item)} for item in value]
|
return [
|
||||||
|
cast(dict[str, Any], item)
|
||||||
|
if isinstance(item, dict)
|
||||||
|
else {"type": "text", "text": str(item)}
|
||||||
|
for item in cast(list[Any], value)
|
||||||
|
]
|
||||||
if value is None:
|
if value is None:
|
||||||
return []
|
return []
|
||||||
return [{"type": "text", "text": str(value)}]
|
return [{"type": "text", "text": str(value)}]
|
||||||
@ -157,7 +162,7 @@ class ContextBuilder:
|
|||||||
|
|
||||||
def _load_bootstrap_files(self, workspace: Path | None = None) -> str:
|
def _load_bootstrap_files(self, workspace: Path | None = None) -> str:
|
||||||
"""Load project instructions plus the agent's global profile files."""
|
"""Load project instructions plus the agent's global profile files."""
|
||||||
parts = []
|
parts: list[str] = []
|
||||||
project_root = workspace or self.workspace
|
project_root = workspace or self.workspace
|
||||||
sources = [
|
sources = [
|
||||||
("AGENTS.md", project_root),
|
("AGENTS.md", project_root),
|
||||||
@ -212,7 +217,7 @@ class ContextBuilder:
|
|||||||
user_content = self.build_user_content(current_message, image_paths=media)
|
user_content = self.build_user_content(current_message, image_paths=media)
|
||||||
blocks = list(runtime_context_blocks or ()) if current_role == "user" else []
|
blocks = list(runtime_context_blocks or ()) if current_role == "user" else []
|
||||||
merged, runtime_context_meta = append_runtime_context(user_content, blocks)
|
merged, runtime_context_meta = append_runtime_context(user_content, blocks)
|
||||||
messages = [
|
messages: list[dict[str, Any]] = [
|
||||||
{
|
{
|
||||||
"role": "system",
|
"role": "system",
|
||||||
"content": self.build_system_prompt(
|
"content": self.build_system_prompt(
|
||||||
@ -235,7 +240,7 @@ class ContextBuilder:
|
|||||||
last["_meta"] = internal_meta
|
last["_meta"] = internal_meta
|
||||||
messages[-1] = last
|
messages[-1] = last
|
||||||
return messages
|
return messages
|
||||||
current = {"role": current_role, "content": merged}
|
current: dict[str, Any] = {"role": current_role, "content": merged}
|
||||||
if current_role == "user" and runtime_context_meta is not None:
|
if current_role == "user" and runtime_context_meta is not None:
|
||||||
current["_meta"] = {RUNTIME_CONTEXT_MESSAGE_META: runtime_context_meta}
|
current["_meta"] = {RUNTIME_CONTEXT_MESSAGE_META: runtime_context_meta}
|
||||||
messages.append(current)
|
messages.append(current)
|
||||||
@ -250,7 +255,7 @@ class ContextBuilder:
|
|||||||
if not image_paths:
|
if not image_paths:
|
||||||
return text
|
return text
|
||||||
|
|
||||||
image_blocks = []
|
image_blocks: list[dict[str, Any]] = []
|
||||||
for path in image_paths:
|
for path in image_paths:
|
||||||
p = Path(path)
|
p = Path(path)
|
||||||
if not p.is_file():
|
if not p.is_file():
|
||||||
|
|||||||
@ -9,7 +9,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import TYPE_CHECKING, Any
|
from typing import TYPE_CHECKING, Any, cast
|
||||||
|
|
||||||
from loguru import logger
|
from loguru import logger
|
||||||
|
|
||||||
@ -23,6 +23,7 @@ from nanobot.utils.helpers import (
|
|||||||
from nanobot.utils.runtime import ensure_nonempty_tool_result
|
from nanobot.utils.runtime import ensure_nonempty_tool_result
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
|
from nanobot.agent.tools.registry import ToolRegistry
|
||||||
from nanobot.providers.base import LLMProvider
|
from nanobot.providers.base import LLMProvider
|
||||||
|
|
||||||
SNIP_SAFETY_BUFFER = 1024
|
SNIP_SAFETY_BUFFER = 1024
|
||||||
@ -49,8 +50,9 @@ def _tool_call_name_is_valid(tool_call: Any) -> bool:
|
|||||||
"""
|
"""
|
||||||
if not isinstance(tool_call, dict):
|
if not isinstance(tool_call, dict):
|
||||||
return False
|
return False
|
||||||
fn = tool_call.get("function")
|
tool_call_data = cast(dict[str, Any], tool_call)
|
||||||
name = fn.get("name") if isinstance(fn, dict) else tool_call.get("name")
|
fn = tool_call_data.get("function")
|
||||||
|
name = cast(dict[str, Any], fn).get("name") if isinstance(fn, dict) else tool_call_data.get("name")
|
||||||
return isinstance(name, str) and bool(name)
|
return isinstance(name, str) and bool(name)
|
||||||
|
|
||||||
|
|
||||||
@ -58,7 +60,7 @@ def _tool_call_name_is_valid(tool_call: Any) -> bool:
|
|||||||
class ContextGovernanceConfig:
|
class ContextGovernanceConfig:
|
||||||
provider: LLMProvider
|
provider: LLMProvider
|
||||||
model: str
|
model: str
|
||||||
tools: Any
|
tools: ToolRegistry
|
||||||
workspace: Path | None
|
workspace: Path | None
|
||||||
session_key: str | None
|
session_key: str | None
|
||||||
max_tool_result_chars: int
|
max_tool_result_chars: int
|
||||||
@ -199,7 +201,7 @@ class ContextGovernor:
|
|||||||
if updated is not None:
|
if updated is not None:
|
||||||
updated.append(msg)
|
updated.append(msg)
|
||||||
continue
|
continue
|
||||||
kept = [tc for tc in calls if _tool_call_name_is_valid(tc)]
|
kept = [tc for tc in cast(list[Any], calls) if _tool_call_name_is_valid(tc)]
|
||||||
if len(kept) == len(calls):
|
if len(kept) == len(calls):
|
||||||
if updated is not None:
|
if updated is not None:
|
||||||
updated.append(msg)
|
updated.append(msg)
|
||||||
@ -238,9 +240,11 @@ class ContextGovernor:
|
|||||||
for idx, msg in enumerate(messages):
|
for idx, msg in enumerate(messages):
|
||||||
role = msg.get("role")
|
role = msg.get("role")
|
||||||
if role == "assistant":
|
if role == "assistant":
|
||||||
for tc in msg.get("tool_calls") or []:
|
for tc in cast(list[Any], msg.get("tool_calls") or []):
|
||||||
if isinstance(tc, dict) and tc.get("id"):
|
if isinstance(tc, dict):
|
||||||
declared.add(str(tc["id"]))
|
tool_call = cast(dict[str, Any], tc)
|
||||||
|
if tool_call.get("id"):
|
||||||
|
declared.add(str(tool_call["id"]))
|
||||||
if role == "tool":
|
if role == "tool":
|
||||||
tid = msg.get("tool_call_id")
|
tid = msg.get("tool_call_id")
|
||||||
tid_str = str(tid) if tid else ""
|
tid_str = str(tid) if tid else ""
|
||||||
@ -266,13 +270,17 @@ class ContextGovernor:
|
|||||||
for idx, msg in enumerate(messages):
|
for idx, msg in enumerate(messages):
|
||||||
role = msg.get("role")
|
role = msg.get("role")
|
||||||
if role == "assistant":
|
if role == "assistant":
|
||||||
for tc in msg.get("tool_calls") or []:
|
for tc in cast(list[Any], msg.get("tool_calls") or []):
|
||||||
if isinstance(tc, dict) and tc.get("id"):
|
if isinstance(tc, dict):
|
||||||
name = ""
|
name = ""
|
||||||
func = tc.get("function")
|
tool_call = cast(dict[str, Any], tc)
|
||||||
if isinstance(func, dict):
|
if tool_call.get("id"):
|
||||||
name = func.get("name", "")
|
func = tool_call.get("function")
|
||||||
declared.append((idx, str(tc["id"]), name))
|
if isinstance(func, dict):
|
||||||
|
func_data = cast(dict[str, Any], func)
|
||||||
|
raw_name = func_data.get("name", "")
|
||||||
|
name = raw_name if isinstance(raw_name, str) else str(raw_name)
|
||||||
|
declared.append((idx, str(tool_call["id"]), name))
|
||||||
elif role == "tool":
|
elif role == "tool":
|
||||||
tid = msg.get("tool_call_id")
|
tid = msg.get("tool_call_id")
|
||||||
if tid:
|
if tid:
|
||||||
|
|||||||
@ -4,7 +4,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
from collections.abc import Awaitable, Callable
|
from collections.abc import Awaitable, Callable
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any
|
from typing import Any, cast
|
||||||
|
|
||||||
from nanobot.agent.hook import (
|
from nanobot.agent.hook import (
|
||||||
AgentHook,
|
AgentHook,
|
||||||
@ -56,17 +56,21 @@ class FileEditActivityHook(AgentHook):
|
|||||||
) -> None:
|
) -> None:
|
||||||
if self._on_progress is None or not isinstance(params, dict):
|
if self._on_progress is None or not isinstance(params, dict):
|
||||||
return
|
return
|
||||||
|
typed_params = cast(dict[str, Any], params)
|
||||||
trackers = prepare_file_edit_trackers(
|
trackers = prepare_file_edit_trackers(
|
||||||
call_id=tool_call.id,
|
call_id=tool_call.id,
|
||||||
tool_name=tool_call.name,
|
tool_name=tool_call.name,
|
||||||
tool=tool,
|
tool=tool,
|
||||||
workspace=self._workspace,
|
workspace=self._workspace,
|
||||||
params=params,
|
params=typed_params,
|
||||||
)
|
)
|
||||||
if not trackers:
|
if not trackers:
|
||||||
return
|
return
|
||||||
self._trackers_by_call[self._tool_call_key(tool_call)] = trackers
|
self._trackers_by_call[self._tool_call_key(tool_call)] = trackers
|
||||||
await self._emit([build_file_edit_start_event(tracker, params) for tracker in trackers])
|
await self._emit([
|
||||||
|
build_file_edit_start_event(tracker, typed_params)
|
||||||
|
for tracker in trackers
|
||||||
|
])
|
||||||
|
|
||||||
async def after_execute_tool(
|
async def after_execute_tool(
|
||||||
self,
|
self,
|
||||||
|
|||||||
@ -1,5 +1,7 @@
|
|||||||
"""Agent loop: the core processing engine."""
|
"""Agent loop: the core processing engine."""
|
||||||
|
|
||||||
|
# pyright: reportPrivateUsage=false
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
@ -7,13 +9,13 @@ import dataclasses
|
|||||||
import inspect
|
import inspect
|
||||||
import os
|
import os
|
||||||
import time
|
import time
|
||||||
from collections.abc import Mapping
|
from collections.abc import Coroutine, Iterable, Mapping
|
||||||
from contextlib import AbstractContextManager, ExitStack, nullcontext, suppress
|
from contextlib import AbstractContextManager, ExitStack, nullcontext, suppress
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from enum import Enum, auto
|
from enum import Enum, auto
|
||||||
from functools import partial
|
from functools import partial
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import TYPE_CHECKING, Any, Awaitable, Callable, TypeVar
|
from typing import TYPE_CHECKING, Any, Awaitable, Callable, TypeVar, cast
|
||||||
|
|
||||||
from loguru import logger
|
from loguru import logger
|
||||||
|
|
||||||
@ -94,10 +96,13 @@ if TYPE_CHECKING:
|
|||||||
from nanobot.agent.tools.mcp import MCPConnection
|
from nanobot.agent.tools.mcp import MCPConnection
|
||||||
from nanobot.config.schema import (
|
from nanobot.config.schema import (
|
||||||
ChannelsConfig,
|
ChannelsConfig,
|
||||||
|
Config,
|
||||||
|
MCPServerConfig,
|
||||||
ProviderConfig,
|
ProviderConfig,
|
||||||
ToolsConfig,
|
ToolsConfig,
|
||||||
)
|
)
|
||||||
from nanobot.cron.service import CronService
|
from nanobot.cron.service import CronService
|
||||||
|
from nanobot.triggers.local_store import LocalTriggerStore
|
||||||
|
|
||||||
_T = TypeVar("_T")
|
_T = TypeVar("_T")
|
||||||
|
|
||||||
@ -142,7 +147,7 @@ class TurnContext:
|
|||||||
on_runtime_admitted: Callable[[LLMRuntime], Awaitable[None]] | None = None
|
on_runtime_admitted: Callable[[LLMRuntime], Awaitable[None]] | None = None
|
||||||
on_retry_wait: Callable[[str], Awaitable[None]] | None = None
|
on_retry_wait: Callable[[str], Awaitable[None]] | None = None
|
||||||
|
|
||||||
pending_queue: asyncio.Queue | None = None
|
pending_queue: asyncio.Queue[InboundMessage] | None = None
|
||||||
pending_summary: str | None = None
|
pending_summary: str | None = None
|
||||||
|
|
||||||
ephemeral: bool = False
|
ephemeral: bool = False
|
||||||
@ -156,6 +161,18 @@ class TurnContext:
|
|||||||
visible_run_started_at: float | None = None
|
visible_run_started_at: float | None = None
|
||||||
turn_latency_ms: int | None = None
|
turn_latency_ms: int | None = None
|
||||||
|
|
||||||
|
def require_runtime(self) -> LLMRuntime:
|
||||||
|
"""Return the runtime established by the BUILD stage."""
|
||||||
|
if self.runtime is None:
|
||||||
|
raise RuntimeError("turn runtime is not initialized; BUILD must run before this stage")
|
||||||
|
return self.runtime
|
||||||
|
|
||||||
|
def require_session(self) -> Session:
|
||||||
|
"""Return the session established by the RESTORE stage."""
|
||||||
|
if self.session is None:
|
||||||
|
raise RuntimeError("turn session is not initialized; RESTORE must run before this stage")
|
||||||
|
return self.session
|
||||||
|
|
||||||
|
|
||||||
class AgentLoop:
|
class AgentLoop:
|
||||||
"""
|
"""
|
||||||
@ -243,7 +260,7 @@ class AgentLoop:
|
|||||||
cron_service: CronService | None = None,
|
cron_service: CronService | None = None,
|
||||||
restrict_to_workspace: bool = False,
|
restrict_to_workspace: bool = False,
|
||||||
session_manager: SessionManager | None = None,
|
session_manager: SessionManager | None = None,
|
||||||
mcp_servers: dict | None = None,
|
mcp_servers: dict[str, MCPServerConfig] | None = None,
|
||||||
channels_config: ChannelsConfig | None = None,
|
channels_config: ChannelsConfig | None = None,
|
||||||
timezone: str | None = None,
|
timezone: str | None = None,
|
||||||
session_ttl_minutes: int = 0,
|
session_ttl_minutes: int = 0,
|
||||||
@ -266,7 +283,7 @@ class AgentLoop:
|
|||||||
turn_delivery_factory: TurnDeliveryFactory | None = None,
|
turn_delivery_factory: TurnDeliveryFactory | None = None,
|
||||||
runtime_model_publisher: Callable[[str, str | None], None] | None = None,
|
runtime_model_publisher: Callable[[str, str | None], None] | None = None,
|
||||||
restart_mode: str = "auto",
|
restart_mode: str = "auto",
|
||||||
local_trigger_store: Any | None = None,
|
local_trigger_store: LocalTriggerStore | None = None,
|
||||||
idle_compact_check_interval_seconds: int = 0,
|
idle_compact_check_interval_seconds: int = 0,
|
||||||
):
|
):
|
||||||
from nanobot.config.schema import ToolsConfig
|
from nanobot.config.schema import ToolsConfig
|
||||||
@ -381,7 +398,7 @@ class AgentLoop:
|
|||||||
# Per-session pending queues for mid-turn message injection.
|
# Per-session pending queues for mid-turn message injection.
|
||||||
# When a session has an active task, new messages for that session
|
# When a session has an active task, new messages for that session
|
||||||
# are routed here instead of creating a new task.
|
# are routed here instead of creating a new task.
|
||||||
self._pending_queues: dict[str, asyncio.Queue] = {}
|
self._pending_queues: dict[str, asyncio.Queue[InboundMessage]] = {}
|
||||||
self._deferred_automation_turns: dict[str, list[InboundMessage]] = {}
|
self._deferred_automation_turns: dict[str, list[InboundMessage]] = {}
|
||||||
self._cron_turns = CronTurnCoordinator(
|
self._cron_turns = CronTurnCoordinator(
|
||||||
publish_inbound=self.bus.publish_inbound,
|
publish_inbound=self.bus.publish_inbound,
|
||||||
@ -430,7 +447,7 @@ class AgentLoop:
|
|||||||
@classmethod
|
@classmethod
|
||||||
def from_config(
|
def from_config(
|
||||||
cls,
|
cls,
|
||||||
config: Any,
|
config: Config,
|
||||||
bus: MessageBus | None = None,
|
bus: MessageBus | None = None,
|
||||||
**extra: Any,
|
**extra: Any,
|
||||||
) -> AgentLoop:
|
) -> AgentLoop:
|
||||||
@ -657,12 +674,17 @@ class AgentLoop:
|
|||||||
"""
|
"""
|
||||||
if not turn_continuation.should_persist_user_message(msg.metadata):
|
if not turn_continuation.should_persist_user_message(msg.metadata):
|
||||||
return False
|
return False
|
||||||
media_paths = [p for p in (msg.media or []) if isinstance(p, str) and p]
|
media_paths = [
|
||||||
has_text = isinstance(msg.content, str) and msg.content.strip()
|
path
|
||||||
|
for path in (msg.media or [])
|
||||||
|
if isinstance(cast(object, path), str) and path
|
||||||
|
]
|
||||||
|
content_value = cast(object, msg.content)
|
||||||
|
has_text = isinstance(content_value, str) and content_value.strip()
|
||||||
if has_text or media_paths or runtime_context_blocks:
|
if has_text or media_paths or runtime_context_blocks:
|
||||||
extra: dict[str, Any] = ({"media": list(media_paths)} if media_paths else {}) | agent_context.session_extra(msg.metadata)
|
extra: dict[str, Any] = ({"media": list(media_paths)} if media_paths else {}) | agent_context.session_extra(msg.metadata)
|
||||||
extra.update(kwargs)
|
extra.update(kwargs)
|
||||||
text = msg.content if isinstance(msg.content, str) else ""
|
text = content_value if isinstance(content_value, str) else ""
|
||||||
text_override, automation_extra = automation_history_overrides(msg.metadata)
|
text_override, automation_extra = automation_history_overrides(msg.metadata)
|
||||||
if text_override is not None:
|
if text_override is not None:
|
||||||
text = text_override
|
text = text_override
|
||||||
@ -810,7 +832,7 @@ class AgentLoop:
|
|||||||
|
|
||||||
async def _run_agent_loop(
|
async def _run_agent_loop(
|
||||||
self,
|
self,
|
||||||
initial_messages: list[dict],
|
initial_messages: list[dict[str, Any]],
|
||||||
on_progress: Callable[..., Awaitable[None]] | None = None,
|
on_progress: Callable[..., Awaitable[None]] | None = None,
|
||||||
on_stream: Callable[[str], Awaitable[None]] | None = None,
|
on_stream: Callable[[str], Awaitable[None]] | None = None,
|
||||||
on_stream_end: Callable[..., Awaitable[None]] | None = None,
|
on_stream_end: Callable[..., Awaitable[None]] | None = None,
|
||||||
@ -824,7 +846,7 @@ class AgentLoop:
|
|||||||
metadata: dict[str, Any] | None = None,
|
metadata: dict[str, Any] | None = None,
|
||||||
session_key: str | None = None,
|
session_key: str | None = None,
|
||||||
original_user_text: str | None = None,
|
original_user_text: str | None = None,
|
||||||
pending_queue: asyncio.Queue | None = None,
|
pending_queue: asyncio.Queue[InboundMessage] | None = None,
|
||||||
ephemeral: bool = False,
|
ephemeral: bool = False,
|
||||||
run_extra_hooks_for_ephemeral: bool = False,
|
run_extra_hooks_for_ephemeral: bool = False,
|
||||||
hooks: list[AgentHook] | None = None,
|
hooks: list[AgentHook] | None = None,
|
||||||
@ -832,7 +854,7 @@ class AgentLoop:
|
|||||||
turn_scopes: list[AbstractContextManager[Any]] | None = None,
|
turn_scopes: list[AbstractContextManager[Any]] | None = None,
|
||||||
tools: ToolRegistry | None = None,
|
tools: ToolRegistry | None = None,
|
||||||
request_context: RequestContext | None = None,
|
request_context: RequestContext | None = None,
|
||||||
) -> tuple[str | None, list[str], list[dict], str, bool]:
|
) -> tuple[str | None, list[str], list[dict[str, Any]], str, bool]:
|
||||||
"""Run the agent iteration loop.
|
"""Run the agent iteration loop.
|
||||||
|
|
||||||
*on_stream*: called with each content delta during streaming.
|
*on_stream*: called with each content delta during streaming.
|
||||||
@ -875,7 +897,12 @@ class AgentLoop:
|
|||||||
image_paths=image_paths,
|
image_paths=image_paths,
|
||||||
)
|
)
|
||||||
row: dict[str, Any] = {"role": "user", "content": user_content}
|
row: dict[str, Any] = {"role": "user", "content": user_content}
|
||||||
metadata = pending_msg.metadata if isinstance(pending_msg.metadata, dict) else {}
|
metadata_value = cast(object, pending_msg.metadata)
|
||||||
|
metadata = (
|
||||||
|
pending_msg.metadata
|
||||||
|
if isinstance(metadata_value, dict)
|
||||||
|
else {}
|
||||||
|
)
|
||||||
if pending_msg.channel != "system":
|
if pending_msg.channel != "system":
|
||||||
scope = self.workspace_scopes.for_turn(
|
scope = self.workspace_scopes.for_turn(
|
||||||
channel=pending_msg.channel,
|
channel=pending_msg.channel,
|
||||||
@ -899,19 +926,24 @@ class AgentLoop:
|
|||||||
pending_request,
|
pending_request,
|
||||||
effective_tools,
|
effective_tools,
|
||||||
)
|
)
|
||||||
row["content"], marker = append_runtime_context(user_content, blocks)
|
row["content"], runtime_marker = append_runtime_context(
|
||||||
if marker is not None:
|
user_content,
|
||||||
row["_meta"] = {RUNTIME_CONTEXT_MESSAGE_META: marker}
|
blocks,
|
||||||
|
)
|
||||||
|
if runtime_marker is not None:
|
||||||
|
row["_meta"] = {
|
||||||
|
RUNTIME_CONTEXT_MESSAGE_META: runtime_marker,
|
||||||
|
}
|
||||||
if (
|
if (
|
||||||
pending_msg.sender_id == "subagent"
|
pending_msg.sender_id == "subagent"
|
||||||
and metadata.get("injected_event") == "subagent_result"
|
and metadata.get("injected_event") == "subagent_result"
|
||||||
):
|
):
|
||||||
marker: dict[str, Any] = {"kind": "subagent_result"}
|
subagent_marker: dict[str, Any] = {"kind": "subagent_result"}
|
||||||
task_id = metadata.get("subagent_task_id")
|
task_id = metadata.get("subagent_task_id")
|
||||||
if isinstance(task_id, str) and task_id:
|
if isinstance(task_id, str) and task_id:
|
||||||
marker["subagent_task_id"] = task_id
|
subagent_marker["subagent_task_id"] = task_id
|
||||||
row["subagent_task_id"] = task_id
|
row["subagent_task_id"] = task_id
|
||||||
row[HIDDEN_HISTORY_META] = marker
|
row[HIDDEN_HISTORY_META] = subagent_marker
|
||||||
row["injected_event"] = "subagent_result"
|
row["injected_event"] = "subagent_result"
|
||||||
return row
|
return row
|
||||||
|
|
||||||
@ -1178,7 +1210,7 @@ class AgentLoop:
|
|||||||
gate = self._concurrency_gate or nullcontext()
|
gate = self._concurrency_gate or nullcontext()
|
||||||
|
|
||||||
delivery = self.turn_delivery_factory.unrouted(msg, session_key)
|
delivery = self.turn_delivery_factory.unrouted(msg, session_key)
|
||||||
pending: asyncio.Queue | None = None
|
pending: asyncio.Queue[InboundMessage] | None = None
|
||||||
try:
|
try:
|
||||||
async with lock, gate:
|
async with lock, gate:
|
||||||
# Only the task that owns the session lock may publish the
|
# Only the task that owns the session lock may publish the
|
||||||
@ -1304,7 +1336,7 @@ class AgentLoop:
|
|||||||
if errors:
|
if errors:
|
||||||
raise BaseExceptionGroup("failed to close agent resources", errors)
|
raise BaseExceptionGroup("failed to close agent resources", errors)
|
||||||
|
|
||||||
def _schedule_background(self, coro) -> None:
|
def _schedule_background(self, coro: Coroutine[Any, Any, Any]) -> None:
|
||||||
"""Schedule a coroutine as a tracked background task (drained on shutdown)."""
|
"""Schedule a coroutine as a tracked background task (drained on shutdown)."""
|
||||||
task = asyncio.create_task(coro)
|
task = asyncio.create_task(coro)
|
||||||
self._background_tasks.add(task)
|
self._background_tasks.add(task)
|
||||||
@ -1322,7 +1354,7 @@ class AgentLoop:
|
|||||||
on_progress: Callable[..., Awaitable[None]] | None = None,
|
on_progress: Callable[..., Awaitable[None]] | None = None,
|
||||||
on_stream: Callable[[str], Awaitable[None]] | None = None,
|
on_stream: Callable[[str], Awaitable[None]] | None = None,
|
||||||
on_stream_end: Callable[..., Awaitable[None]] | None = None,
|
on_stream_end: Callable[..., Awaitable[None]] | None = None,
|
||||||
pending_queue: asyncio.Queue | None = None,
|
pending_queue: asyncio.Queue[InboundMessage] | None = None,
|
||||||
ephemeral: bool = False,
|
ephemeral: bool = False,
|
||||||
run_extra_hooks_for_ephemeral: bool = False,
|
run_extra_hooks_for_ephemeral: bool = False,
|
||||||
hooks: list[AgentHook] | None = None,
|
hooks: list[AgentHook] | None = None,
|
||||||
@ -1518,27 +1550,33 @@ class AgentLoop:
|
|||||||
# ensure it exists in case this handler is invoked independently.
|
# ensure it exists in case this handler is invoked independently.
|
||||||
if ctx.session is None:
|
if ctx.session is None:
|
||||||
ctx.session = self.sessions.get_or_create(ctx.session_key)
|
ctx.session = self.sessions.get_or_create(ctx.session_key)
|
||||||
|
session = ctx.session
|
||||||
self._remember_unified_session_route(
|
self._remember_unified_session_route(
|
||||||
ctx.session,
|
session,
|
||||||
msg,
|
msg,
|
||||||
is_user_turn=ctx.original_user_text is not None,
|
is_user_turn=ctx.original_user_text is not None,
|
||||||
)
|
)
|
||||||
await ctx.delivery.started()
|
await ctx.delivery.started()
|
||||||
if ctx.kind is TurnKind.USER:
|
if ctx.kind is TurnKind.USER:
|
||||||
self.workspace_scopes.persist_message_scope(ctx.session, msg)
|
self.workspace_scopes.persist_message_scope(session, msg)
|
||||||
|
|
||||||
if self._restore_runtime_checkpoint(ctx.session):
|
if self._restore_runtime_checkpoint(session):
|
||||||
self.sessions.save(ctx.session)
|
self.sessions.save(session)
|
||||||
if self._restore_pending_user_turn(ctx.session):
|
if self._restore_pending_user_turn(session):
|
||||||
self.sessions.save(ctx.session)
|
self.sessions.save(session)
|
||||||
|
|
||||||
async def _compact_session(self, ctx: TurnContext) -> None:
|
async def _compact_session(self, ctx: TurnContext) -> None:
|
||||||
ctx.session, pending = self.auto_compact.prepare_session(ctx.session, ctx.session_key)
|
session = ctx.require_session()
|
||||||
|
ctx.session, pending = self.auto_compact.prepare_session(
|
||||||
|
session,
|
||||||
|
ctx.session_key,
|
||||||
|
)
|
||||||
ctx.pending_summary = pending
|
ctx.pending_summary = pending
|
||||||
|
|
||||||
async def _dispatch_command(self, ctx: TurnContext) -> bool:
|
async def _dispatch_command(self, ctx: TurnContext) -> bool:
|
||||||
if ctx.kind is TurnKind.SYSTEM:
|
if ctx.kind is TurnKind.SYSTEM:
|
||||||
return False
|
return False
|
||||||
|
session = ctx.require_session()
|
||||||
raw = ctx.msg.content.strip()
|
raw = ctx.msg.content.strip()
|
||||||
_, automation_metadata = automation_history_overrides(ctx.msg.metadata)
|
_, automation_metadata = automation_history_overrides(ctx.msg.metadata)
|
||||||
is_user_turn = (
|
is_user_turn = (
|
||||||
@ -1549,7 +1587,7 @@ class AgentLoop:
|
|||||||
)
|
)
|
||||||
cmd_ctx = CommandContext(
|
cmd_ctx = CommandContext(
|
||||||
msg=ctx.msg,
|
msg=ctx.msg,
|
||||||
session=ctx.session,
|
session=session,
|
||||||
key=ctx.session_key,
|
key=ctx.session_key,
|
||||||
raw=raw,
|
raw=raw,
|
||||||
loop=self,
|
loop=self,
|
||||||
@ -1567,13 +1605,13 @@ class AgentLoop:
|
|||||||
# intentionally clears the session.
|
# intentionally clears the session.
|
||||||
if cmd_ctx.raw.lower() != "/new":
|
if cmd_ctx.raw.lower() != "/new":
|
||||||
ctx.input_persisted_early = self._persist_user_message_early(
|
ctx.input_persisted_early = self._persist_user_message_early(
|
||||||
ctx.msg, ctx.session, _command=True
|
ctx.msg, session, _command=True
|
||||||
)
|
)
|
||||||
ctx.session.add_message(
|
session.add_message(
|
||||||
"assistant", result.content, _command=True
|
"assistant", result.content, _command=True
|
||||||
)
|
)
|
||||||
self._clear_pending_user_turn(ctx.session)
|
self._clear_pending_user_turn(session)
|
||||||
self.sessions.save(ctx.session)
|
self.sessions.save(session)
|
||||||
if not ctx.ephemeral:
|
if not ctx.ephemeral:
|
||||||
await self.runtime_event_publisher.session_turn_persisted(
|
await self.runtime_event_publisher.session_turn_persisted(
|
||||||
ctx.msg,
|
ctx.msg,
|
||||||
@ -1585,9 +1623,10 @@ class AgentLoop:
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
async def _build_turn(self, ctx: TurnContext) -> None:
|
async def _build_turn(self, ctx: TurnContext) -> None:
|
||||||
|
session = ctx.require_session()
|
||||||
runtime = ctx.runtime
|
runtime = ctx.runtime
|
||||||
if runtime is None:
|
if runtime is None:
|
||||||
runtime = self.runtime_for_session(ctx.session)
|
runtime = self.runtime_for_session(session)
|
||||||
ctx.runtime = runtime
|
ctx.runtime = runtime
|
||||||
if ctx.session_key.startswith("dream:"):
|
if ctx.session_key.startswith("dream:"):
|
||||||
logger.info(
|
logger.info(
|
||||||
@ -1602,7 +1641,7 @@ class AgentLoop:
|
|||||||
)
|
)
|
||||||
if not ctx.ephemeral:
|
if not ctx.ephemeral:
|
||||||
await self.consolidator.maybe_consolidate_by_tokens(
|
await self.consolidator.maybe_consolidate_by_tokens(
|
||||||
ctx.session,
|
session,
|
||||||
runtime=runtime,
|
runtime=runtime,
|
||||||
replay_max_messages=replay_max_messages,
|
replay_max_messages=replay_max_messages,
|
||||||
)
|
)
|
||||||
@ -1617,18 +1656,18 @@ class AgentLoop:
|
|||||||
"max_tokens": self._replay_token_budget(runtime),
|
"max_tokens": self._replay_token_budget(runtime),
|
||||||
"extend_to_user": is_subagent,
|
"extend_to_user": is_subagent,
|
||||||
}
|
}
|
||||||
ctx.history = ctx.session.get_history(**_hist_kwargs)
|
ctx.history = session.get_history(**_hist_kwargs)
|
||||||
if is_subagent:
|
if is_subagent:
|
||||||
# Keep the durable internal delivery as an assistant record, but
|
# Keep the durable internal delivery as an assistant record, but
|
||||||
# present this completion to the model as fresh follow-up input.
|
# present this completion to the model as fresh follow-up input.
|
||||||
# Providers without assistant-prefill support drop trailing
|
# Providers without assistant-prefill support drop trailing
|
||||||
# assistant messages, so using the persisted record as the current
|
# assistant messages, so using the persisted record as the current
|
||||||
# prompt would hide an independently dispatched subagent result.
|
# prompt would hide an independently dispatched subagent result.
|
||||||
if self._persist_subagent_followup(ctx.session, ctx.msg):
|
if self._persist_subagent_followup(session, ctx.msg):
|
||||||
logger.debug("Subagent result persisted for session {}", ctx.session_key)
|
logger.debug("Subagent result persisted for session {}", ctx.session_key)
|
||||||
self.sessions.save(ctx.session)
|
self.sessions.save(session)
|
||||||
ctx.input_persisted_early = True
|
ctx.input_persisted_early = True
|
||||||
ctx.delivery.record_runtime(ctx.runtime)
|
ctx.delivery.record_runtime(runtime)
|
||||||
|
|
||||||
ctx.request_context = self._request_context_for_turn(ctx)
|
ctx.request_context = self._request_context_for_turn(ctx)
|
||||||
if ctx.kind is TurnKind.USER:
|
if ctx.kind is TurnKind.USER:
|
||||||
@ -1637,7 +1676,7 @@ class AgentLoop:
|
|||||||
if ctx.kind is TurnKind.USER:
|
if ctx.kind is TurnKind.USER:
|
||||||
ctx.input_persisted_early = self._persist_user_message_early(
|
ctx.input_persisted_early = self._persist_user_message_early(
|
||||||
ctx.msg,
|
ctx.msg,
|
||||||
ctx.session,
|
session,
|
||||||
runtime_context_blocks=ctx.runtime_context_blocks,
|
runtime_context_blocks=ctx.runtime_context_blocks,
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -1647,12 +1686,13 @@ class AgentLoop:
|
|||||||
ctx.on_retry_wait = ctx.delivery.retry_wait_callback()
|
ctx.on_retry_wait = ctx.delivery.retry_wait_callback()
|
||||||
|
|
||||||
async def _run_turn(self, ctx: TurnContext) -> None:
|
async def _run_turn(self, ctx: TurnContext) -> None:
|
||||||
|
runtime = ctx.require_runtime()
|
||||||
if ctx.visible_run_started_at is None:
|
if ctx.visible_run_started_at is None:
|
||||||
ctx.visible_run_started_at = time.time()
|
ctx.visible_run_started_at = time.time()
|
||||||
await ctx.delivery.running(started_at=ctx.visible_run_started_at)
|
await ctx.delivery.running(started_at=ctx.visible_run_started_at)
|
||||||
result = await self._run_agent_loop(
|
result = await self._run_agent_loop(
|
||||||
ctx.initial_messages,
|
ctx.initial_messages,
|
||||||
runtime=ctx.runtime,
|
runtime=runtime,
|
||||||
on_progress=ctx.on_progress,
|
on_progress=ctx.on_progress,
|
||||||
on_stream=ctx.on_stream,
|
on_stream=ctx.on_stream,
|
||||||
on_stream_end=ctx.on_stream_end,
|
on_stream_end=ctx.on_stream_end,
|
||||||
@ -1682,6 +1722,8 @@ class AgentLoop:
|
|||||||
await turn_continuation.maybe_continue_turn(ctx)
|
await turn_continuation.maybe_continue_turn(ctx)
|
||||||
|
|
||||||
async def _persist_turn(self, ctx: TurnContext) -> None:
|
async def _persist_turn(self, ctx: TurnContext) -> None:
|
||||||
|
runtime = ctx.require_runtime()
|
||||||
|
session = ctx.require_session()
|
||||||
turn_continuation.prepare_save_boundary(ctx)
|
turn_continuation.prepare_save_boundary(ctx)
|
||||||
|
|
||||||
if (
|
if (
|
||||||
@ -1702,26 +1744,26 @@ class AgentLoop:
|
|||||||
)
|
)
|
||||||
ctx.turn_latency_ms = max(0, int((time.time() - latency_started_at) * 1000))
|
ctx.turn_latency_ms = max(0, int((time.time() - latency_started_at) * 1000))
|
||||||
self._save_turn(
|
self._save_turn(
|
||||||
ctx.session, ctx.all_messages, ctx.save_skip,
|
session, ctx.all_messages, ctx.save_skip,
|
||||||
turn_latency_ms=ctx.turn_latency_ms,
|
turn_latency_ms=ctx.turn_latency_ms,
|
||||||
)
|
)
|
||||||
ctx.delivery.record_latency(ctx.turn_latency_ms)
|
ctx.delivery.record_latency(ctx.turn_latency_ms)
|
||||||
if not ctx.ephemeral:
|
if not ctx.ephemeral:
|
||||||
ctx.session.enforce_file_cap(
|
session.enforce_file_cap(
|
||||||
on_archive=partial(self.context.memory.raw_archive, session_key=ctx.session_key)
|
on_archive=partial(self.context.memory.raw_archive, session_key=ctx.session_key)
|
||||||
)
|
)
|
||||||
self._schedule_background(
|
self._schedule_background(
|
||||||
self.consolidator.maybe_consolidate_by_tokens(
|
self.consolidator.maybe_consolidate_by_tokens(
|
||||||
ctx.session,
|
session,
|
||||||
runtime=ctx.runtime,
|
runtime=runtime,
|
||||||
replay_max_messages=replay_max_messages_for_context(
|
replay_max_messages=replay_max_messages_for_context(
|
||||||
ctx.runtime.context_window_tokens
|
runtime.context_window_tokens
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
self._clear_pending_user_turn(ctx.session)
|
self._clear_pending_user_turn(session)
|
||||||
self._clear_runtime_checkpoint(ctx.session)
|
self._clear_runtime_checkpoint(session)
|
||||||
self.sessions.save(ctx.session)
|
self.sessions.save(session)
|
||||||
if not ctx.ephemeral:
|
if not ctx.ephemeral:
|
||||||
await self.runtime_event_publisher.session_turn_persisted(
|
await self.runtime_event_publisher.session_turn_persisted(
|
||||||
ctx.msg,
|
ctx.msg,
|
||||||
@ -1744,7 +1786,7 @@ class AgentLoop:
|
|||||||
return
|
return
|
||||||
ctx.outbound = self._assemble_outbound(
|
ctx.outbound = self._assemble_outbound(
|
||||||
ctx.msg,
|
ctx.msg,
|
||||||
ctx.final_content,
|
cast(str, ctx.final_content),
|
||||||
ctx.stop_reason,
|
ctx.stop_reason,
|
||||||
ctx.had_injections,
|
ctx.had_injections,
|
||||||
ctx.streamed_content,
|
ctx.streamed_content,
|
||||||
@ -1755,39 +1797,47 @@ class AgentLoop:
|
|||||||
|
|
||||||
def _sanitize_persisted_blocks(
|
def _sanitize_persisted_blocks(
|
||||||
self,
|
self,
|
||||||
content: list[dict[str, Any]],
|
content: list[object],
|
||||||
*,
|
*,
|
||||||
should_truncate_text: bool = False,
|
should_truncate_text: bool = False,
|
||||||
) -> list[dict[str, Any]]:
|
) -> list[object]:
|
||||||
"""Strip volatile multimodal payloads before writing session history."""
|
"""Strip volatile multimodal payloads before writing session history."""
|
||||||
filtered: list[dict[str, Any]] = []
|
filtered: list[object] = []
|
||||||
for block in content:
|
for block in content:
|
||||||
if not isinstance(block, dict):
|
if not isinstance(block, dict):
|
||||||
filtered.append(block)
|
filtered.append(block)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
if block.get("type") == "image_url" and block.get("image_url", {}).get(
|
block_data = cast(dict[str, Any], block)
|
||||||
"url", ""
|
image_url = cast(dict[str, Any], block_data.get("image_url", {}))
|
||||||
|
if block_data.get("type") == "image_url" and str(
|
||||||
|
image_url.get("url", "")
|
||||||
).startswith("data:image/"):
|
).startswith("data:image/"):
|
||||||
path = (block.get("_meta") or {}).get("path", "")
|
internal_meta = cast(dict[str, Any], block_data.get("_meta") or {})
|
||||||
filtered.append({"type": "text", "text": image_placeholder_text(path)})
|
path = cast(str, internal_meta.get("path", ""))
|
||||||
|
filtered.append(
|
||||||
|
{"type": "text", "text": image_placeholder_text(path)}
|
||||||
|
)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
if block.get("type") == "text" and isinstance(block.get("text"), str):
|
if block_data.get("type") == "text" and isinstance(
|
||||||
text = block["text"]
|
block_data.get("text"),
|
||||||
|
str,
|
||||||
|
):
|
||||||
|
text = cast(str, block_data["text"])
|
||||||
if should_truncate_text and len(text) > self.max_tool_result_chars:
|
if should_truncate_text and len(text) > self.max_tool_result_chars:
|
||||||
text = truncate_text_fn(text, self.max_tool_result_chars)
|
text = truncate_text_fn(text, self.max_tool_result_chars)
|
||||||
filtered.append({**block, "text": text})
|
filtered.append({**block_data, "text": text})
|
||||||
continue
|
continue
|
||||||
|
|
||||||
filtered.append(block)
|
filtered.append(block_data)
|
||||||
|
|
||||||
return filtered
|
return filtered
|
||||||
|
|
||||||
def _save_turn(
|
def _save_turn(
|
||||||
self,
|
self,
|
||||||
session: Session,
|
session: Session,
|
||||||
messages: list[dict],
|
messages: list[dict[str, Any]],
|
||||||
skip: int,
|
skip: int,
|
||||||
*,
|
*,
|
||||||
turn_latency_ms: int | None = None,
|
turn_latency_ms: int | None = None,
|
||||||
@ -1799,8 +1849,10 @@ class AgentLoop:
|
|||||||
str(tc["id"])
|
str(tc["id"])
|
||||||
for m in session.messages
|
for m in session.messages
|
||||||
if m.get("role") == "assistant"
|
if m.get("role") == "assistant"
|
||||||
for tc in m.get("tool_calls") or []
|
for tc_value in cast(Iterable[object], m.get("tool_calls") or [])
|
||||||
if isinstance(tc, dict) and tc.get("id")
|
if isinstance(tc_value, dict)
|
||||||
|
for tc in (cast(dict[str, Any], tc_value),)
|
||||||
|
if tc.get("id")
|
||||||
}
|
}
|
||||||
fulfilled_tool_call_ids = {
|
fulfilled_tool_call_ids = {
|
||||||
str(m["tool_call_id"])
|
str(m["tool_call_id"])
|
||||||
@ -1810,9 +1862,11 @@ class AgentLoop:
|
|||||||
last_assistant_idx: int | None = None
|
last_assistant_idx: int | None = None
|
||||||
for m in messages[skip:]:
|
for m in messages[skip:]:
|
||||||
entry = dict(m)
|
entry = dict(m)
|
||||||
internal_meta = entry.pop("_meta", None)
|
internal_meta = cast(object, entry.pop("_meta", None))
|
||||||
runtime_context_meta = (
|
runtime_context_meta = (
|
||||||
internal_meta.get(RUNTIME_CONTEXT_MESSAGE_META)
|
cast(dict[str, Any], internal_meta).get(
|
||||||
|
RUNTIME_CONTEXT_MESSAGE_META
|
||||||
|
)
|
||||||
if isinstance(internal_meta, dict)
|
if isinstance(internal_meta, dict)
|
||||||
else None
|
else None
|
||||||
)
|
)
|
||||||
@ -1838,7 +1892,10 @@ class AgentLoop:
|
|||||||
if isinstance(content, str) and len(content) > self.max_tool_result_chars:
|
if isinstance(content, str) and len(content) > self.max_tool_result_chars:
|
||||||
entry["content"] = truncate_text_fn(content, self.max_tool_result_chars)
|
entry["content"] = truncate_text_fn(content, self.max_tool_result_chars)
|
||||||
elif isinstance(content, list):
|
elif isinstance(content, list):
|
||||||
filtered = self._sanitize_persisted_blocks(content, should_truncate_text=True)
|
filtered = self._sanitize_persisted_blocks(
|
||||||
|
cast(list[object], content),
|
||||||
|
should_truncate_text=True,
|
||||||
|
)
|
||||||
if not filtered:
|
if not filtered:
|
||||||
# Preserve the tool_call/result pair after block filtering.
|
# Preserve the tool_call/result pair after block filtering.
|
||||||
filtered = [
|
filtered = [
|
||||||
@ -1847,7 +1904,9 @@ class AgentLoop:
|
|||||||
entry["content"] = filtered
|
entry["content"] = filtered
|
||||||
elif role == "user":
|
elif role == "user":
|
||||||
if isinstance(content, list):
|
if isinstance(content, list):
|
||||||
filtered = self._sanitize_persisted_blocks(content)
|
filtered = self._sanitize_persisted_blocks(
|
||||||
|
cast(list[object], content),
|
||||||
|
)
|
||||||
if not filtered:
|
if not filtered:
|
||||||
continue
|
continue
|
||||||
entry["content"] = filtered
|
entry["content"] = filtered
|
||||||
@ -1859,8 +1918,13 @@ class AgentLoop:
|
|||||||
last_assistant_idx = len(session.messages) - 1
|
last_assistant_idx = len(session.messages) - 1
|
||||||
declared_tool_call_ids.update(
|
declared_tool_call_ids.update(
|
||||||
str(tc["id"])
|
str(tc["id"])
|
||||||
for tc in entry.get("tool_calls") or []
|
for tc_value in cast(
|
||||||
if isinstance(tc, dict) and tc.get("id")
|
Iterable[object],
|
||||||
|
entry.get("tool_calls") or [],
|
||||||
|
)
|
||||||
|
if isinstance(tc_value, dict)
|
||||||
|
for tc in (cast(dict[str, Any], tc_value),)
|
||||||
|
if tc.get("id")
|
||||||
)
|
)
|
||||||
if turn_latency_ms is not None and last_assistant_idx is not None:
|
if turn_latency_ms is not None and last_assistant_idx is not None:
|
||||||
session.messages[last_assistant_idx]["latency_ms"] = int(turn_latency_ms)
|
session.messages[last_assistant_idx]["latency_ms"] = int(turn_latency_ms)
|
||||||
@ -1875,7 +1939,12 @@ class AgentLoop:
|
|||||||
"""
|
"""
|
||||||
if not msg.content:
|
if not msg.content:
|
||||||
return False
|
return False
|
||||||
task_id = msg.metadata.get("subagent_task_id") if isinstance(msg.metadata, dict) else None
|
metadata_value = cast(object, msg.metadata)
|
||||||
|
task_id = (
|
||||||
|
msg.metadata.get("subagent_task_id")
|
||||||
|
if isinstance(metadata_value, dict)
|
||||||
|
else None
|
||||||
|
)
|
||||||
if task_id and any(
|
if task_id and any(
|
||||||
m.get("injected_event") == "subagent_result" and m.get("subagent_task_id") == task_id
|
m.get("injected_event") == "subagent_result" and m.get("subagent_task_id") == task_id
|
||||||
for m in session.messages
|
for m in session.messages
|
||||||
@ -1921,29 +1990,44 @@ class AgentLoop:
|
|||||||
"""Materialize an unfinished turn into session history before a new request."""
|
"""Materialize an unfinished turn into session history before a new request."""
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
|
||||||
checkpoint = session.metadata.get(self._RUNTIME_CHECKPOINT_KEY)
|
checkpoint = cast(
|
||||||
|
object,
|
||||||
|
session.metadata.get(self._RUNTIME_CHECKPOINT_KEY),
|
||||||
|
)
|
||||||
if not isinstance(checkpoint, dict):
|
if not isinstance(checkpoint, dict):
|
||||||
return False
|
return False
|
||||||
|
checkpoint_data = cast(dict[str, Any], checkpoint)
|
||||||
|
|
||||||
assistant_message = checkpoint.get("assistant_message")
|
assistant_message = cast(object, checkpoint_data.get("assistant_message"))
|
||||||
completed_tool_results = checkpoint.get("completed_tool_results") or []
|
completed_tool_results = cast(
|
||||||
pending_tool_calls = checkpoint.get("pending_tool_calls") or []
|
Iterable[object],
|
||||||
|
checkpoint_data.get("completed_tool_results") or [],
|
||||||
|
)
|
||||||
|
pending_tool_calls = cast(
|
||||||
|
Iterable[object],
|
||||||
|
checkpoint_data.get("pending_tool_calls") or [],
|
||||||
|
)
|
||||||
|
|
||||||
restored_messages: list[dict[str, Any]] = []
|
restored_messages: list[dict[str, Any]] = []
|
||||||
if isinstance(assistant_message, dict):
|
if isinstance(assistant_message, dict):
|
||||||
restored = dict(assistant_message)
|
restored = dict(cast(dict[str, Any], assistant_message))
|
||||||
restored.setdefault("timestamp", datetime.now().isoformat())
|
restored.setdefault("timestamp", datetime.now().isoformat())
|
||||||
restored_messages.append(restored)
|
restored_messages.append(restored)
|
||||||
for message in completed_tool_results:
|
for message in completed_tool_results:
|
||||||
if isinstance(message, dict):
|
if isinstance(message, dict):
|
||||||
restored = dict(message)
|
restored = dict(cast(dict[str, Any], message))
|
||||||
restored.setdefault("timestamp", datetime.now().isoformat())
|
restored.setdefault("timestamp", datetime.now().isoformat())
|
||||||
restored_messages.append(restored)
|
restored_messages.append(restored)
|
||||||
for tool_call in pending_tool_calls:
|
for tool_call in pending_tool_calls:
|
||||||
if not isinstance(tool_call, dict):
|
if not isinstance(tool_call, dict):
|
||||||
continue
|
continue
|
||||||
tool_id = tool_call.get("id")
|
tool_call_data = cast(dict[str, Any], tool_call)
|
||||||
name = ((tool_call.get("function") or {}).get("name")) or "tool"
|
tool_id = tool_call_data.get("id")
|
||||||
|
function_data = cast(
|
||||||
|
dict[str, Any],
|
||||||
|
tool_call_data.get("function") or {},
|
||||||
|
)
|
||||||
|
name = function_data.get("name") or "tool"
|
||||||
restored_messages.append(
|
restored_messages.append(
|
||||||
{
|
{
|
||||||
"role": "tool",
|
"role": "tool",
|
||||||
|
|||||||
@ -1,5 +1,10 @@
|
|||||||
"""Memory system: pure file I/O store and lightweight Consolidator."""
|
"""Memory system: pure file I/O store and lightweight Consolidator."""
|
||||||
|
|
||||||
|
# Tool schemas are installed by the ``@tool_parameters`` class decorator at
|
||||||
|
# runtime; static analyzers cannot observe that it clears ``parameters`` from
|
||||||
|
# ``__abstractmethods__`` before these classes are instantiated.
|
||||||
|
# pyright: reportAbstractUsage=false, reportPrivateUsage=false
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
@ -11,7 +16,7 @@ import weakref
|
|||||||
from contextlib import suppress
|
from contextlib import suppress
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import TYPE_CHECKING, Any, Callable, Iterator
|
from typing import TYPE_CHECKING, Any, Callable, Iterator, cast
|
||||||
|
|
||||||
from loguru import logger
|
from loguru import logger
|
||||||
|
|
||||||
@ -38,6 +43,7 @@ from nanobot.utils.workspace_prompts import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
|
from nanobot.agent.tools.registry import ToolRegistry
|
||||||
from nanobot.utils.llm_runtime import LLMRuntime
|
from nanobot.utils.llm_runtime import LLMRuntime
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@ -58,7 +64,7 @@ class DreamRunProgress:
|
|||||||
**_kwargs: Any,
|
**_kwargs: Any,
|
||||||
) -> None:
|
) -> None:
|
||||||
if any(
|
if any(
|
||||||
isinstance(event, dict) and event.get("phase") == "error"
|
isinstance(cast(object, event), dict) and event.get("phase") == "error"
|
||||||
for event in tool_events or ()
|
for event in tool_events or ()
|
||||||
):
|
):
|
||||||
self.had_tool_errors = True
|
self.had_tool_errors = True
|
||||||
@ -474,11 +480,11 @@ class MemoryStore:
|
|||||||
line = line.strip()
|
line = line.strip()
|
||||||
if line:
|
if line:
|
||||||
try:
|
try:
|
||||||
parsed = json.loads(line)
|
parsed: object = json.loads(line)
|
||||||
except json.JSONDecodeError:
|
except json.JSONDecodeError:
|
||||||
continue
|
continue
|
||||||
if isinstance(parsed, dict):
|
if isinstance(parsed, dict):
|
||||||
entries.append(parsed)
|
entries.append(cast(dict[str, Any], parsed))
|
||||||
|
|
||||||
return entries
|
return entries
|
||||||
|
|
||||||
@ -496,8 +502,8 @@ class MemoryStore:
|
|||||||
lines = [line for line in data.split("\n") if line.strip()]
|
lines = [line for line in data.split("\n") if line.strip()]
|
||||||
if not lines:
|
if not lines:
|
||||||
return None
|
return None
|
||||||
parsed = json.loads(lines[-1])
|
parsed: object = json.loads(lines[-1])
|
||||||
return parsed if isinstance(parsed, dict) else None
|
return cast(dict[str, Any], parsed) if isinstance(parsed, dict) else None
|
||||||
except (FileNotFoundError, json.JSONDecodeError, UnicodeDecodeError):
|
except (FileNotFoundError, json.JSONDecodeError, UnicodeDecodeError):
|
||||||
return None
|
return None
|
||||||
|
|
||||||
@ -612,7 +618,7 @@ class MemoryStore:
|
|||||||
("USER.md", self.user_file),
|
("USER.md", self.user_file),
|
||||||
("memory/MEMORY.md", self.memory_file),
|
("memory/MEMORY.md", self.memory_file),
|
||||||
]
|
]
|
||||||
blocks = []
|
blocks: list[str] = []
|
||||||
for label, path in files:
|
for label, path in files:
|
||||||
try:
|
try:
|
||||||
content = path.read_text(encoding="utf-8") if path.exists() else ""
|
content = path.read_text(encoding="utf-8") if path.exists() else ""
|
||||||
@ -633,7 +639,7 @@ class MemoryStore:
|
|||||||
return ""
|
return ""
|
||||||
return self._git.summarize_working_tree(list(self._DREAM_CONTENT_PATHS))
|
return self._git.summarize_working_tree(list(self._DREAM_CONTENT_PATHS))
|
||||||
|
|
||||||
def build_dream_tools(self):
|
def build_dream_tools(self) -> ToolRegistry:
|
||||||
"""Build the restricted tool registry used by Dream runs."""
|
"""Build the restricted tool registry used by Dream runs."""
|
||||||
from nanobot.agent.skills import BUILTIN_SKILLS_DIR
|
from nanobot.agent.skills import BUILTIN_SKILLS_DIR
|
||||||
from nanobot.agent.tools.apply_patch import ApplyPatchTool
|
from nanobot.agent.tools.apply_patch import ApplyPatchTool
|
||||||
@ -684,17 +690,15 @@ class MemoryStore:
|
|||||||
) -> bool:
|
) -> bool:
|
||||||
"""Return True only when a Dream turn completed without tool failures."""
|
"""Return True only when a Dream turn completed without tool failures."""
|
||||||
metadata = getattr(resp, "metadata", None)
|
metadata = getattr(resp, "metadata", None)
|
||||||
return (
|
if had_tool_errors or not isinstance(metadata, dict):
|
||||||
not had_tool_errors
|
return False
|
||||||
and isinstance(metadata, dict)
|
return cast(dict[str, Any], metadata).get("_stop_reason") == "completed"
|
||||||
and metadata.get("_stop_reason") == "completed"
|
|
||||||
)
|
|
||||||
|
|
||||||
# -- message formatting utility ------------------------------------------
|
# -- message formatting utility ------------------------------------------
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _format_messages(messages: list[dict]) -> str:
|
def _format_messages(messages: list[dict[str, Any]]) -> str:
|
||||||
lines = []
|
lines: list[str] = []
|
||||||
for message in messages:
|
for message in messages:
|
||||||
content = content_with_media_breadcrumbs(
|
content = content_with_media_breadcrumbs(
|
||||||
message.get("role"),
|
message.get("role"),
|
||||||
@ -703,16 +707,22 @@ class MemoryStore:
|
|||||||
)
|
)
|
||||||
if not content:
|
if not content:
|
||||||
continue
|
continue
|
||||||
tools = f" [tools: {', '.join(message['tools_used'])}]" if message.get("tools_used") else ""
|
tools_used = message.get("tools_used")
|
||||||
|
tools = (
|
||||||
|
f" [tools: {', '.join(cast(list[str], tools_used))}]"
|
||||||
|
if tools_used
|
||||||
|
else ""
|
||||||
|
)
|
||||||
|
timestamp = cast(str, message.get("timestamp", "?"))
|
||||||
|
role = cast(str, message["role"])
|
||||||
lines.append(
|
lines.append(
|
||||||
f"[{message.get('timestamp', '?')[:16]}] "
|
f"[{timestamp[:16]}] {role.upper()}{tools}: {content}"
|
||||||
f"{message['role'].upper()}{tools}: {content}"
|
|
||||||
)
|
)
|
||||||
return "\n".join(lines)
|
return "\n".join(lines)
|
||||||
|
|
||||||
def raw_archive(
|
def raw_archive(
|
||||||
self,
|
self,
|
||||||
messages: list[dict],
|
messages: list[dict[str, Any]],
|
||||||
*,
|
*,
|
||||||
max_chars: int | None = None,
|
max_chars: int | None = None,
|
||||||
session_key: str | None = None,
|
session_key: str | None = None,
|
||||||
@ -766,9 +776,9 @@ class MemoryStore:
|
|||||||
Only current base64url-encoded Dream session keys are considered.
|
Only current base64url-encoded Dream session keys are considered.
|
||||||
Non-dream session files are never touched.
|
Non-dream session files are never touched.
|
||||||
"""
|
"""
|
||||||
dream_files = []
|
dream_files: list[Path] = []
|
||||||
for path in sessions_dir.glob("*.jsonl"):
|
for path in sessions_dir.glob("*.jsonl"):
|
||||||
decoded_key = SessionManager._decode_storage_key(path.stem)
|
decoded_key = SessionManager.decode_storage_key(path.stem)
|
||||||
if decoded_key is not None and decoded_key.startswith("dream:"):
|
if decoded_key is not None and decoded_key.startswith("dream:"):
|
||||||
dream_files.append(path)
|
dream_files.append(path)
|
||||||
dream_files.sort(key=lambda p: p.stat().st_mtime)
|
dream_files.sort(key=lambda p: p.stat().st_mtime)
|
||||||
@ -943,7 +953,13 @@ class Consolidator:
|
|||||||
channel = session.key.split(":", 1)[0] if ":" in session.key else None
|
channel = session.key.split(":", 1)[0] if ":" in session.key else None
|
||||||
# Include archived summary in estimation so the budget accounts for it.
|
# Include archived summary in estimation so the budget accounts for it.
|
||||||
meta = session.metadata.get("_last_summary")
|
meta = session.metadata.get("_last_summary")
|
||||||
summary = meta.get("text") if isinstance(meta, dict) else (meta if isinstance(meta, str) else None)
|
summary = (
|
||||||
|
cast(dict[str, Any], meta).get("text")
|
||||||
|
if isinstance(meta, dict)
|
||||||
|
else meta
|
||||||
|
if isinstance(meta, str)
|
||||||
|
else None
|
||||||
|
)
|
||||||
probe_messages = self._build_messages(
|
probe_messages = self._build_messages(
|
||||||
history=history,
|
history=history,
|
||||||
current_message="[token-probe]",
|
current_message="[token-probe]",
|
||||||
@ -976,11 +992,11 @@ class Consolidator:
|
|||||||
|
|
||||||
async def archive(
|
async def archive(
|
||||||
self,
|
self,
|
||||||
messages: list[dict],
|
messages: list[dict[str, Any]],
|
||||||
*,
|
*,
|
||||||
runtime: LLMRuntime,
|
runtime: LLMRuntime,
|
||||||
session_key: str | None = None,
|
session_key: str | None = None,
|
||||||
summary_messages: list[dict] | None = None,
|
summary_messages: list[dict[str, Any]] | None = None,
|
||||||
) -> str | None:
|
) -> str | None:
|
||||||
"""Summarize messages via LLM and append to history.jsonl.
|
"""Summarize messages via LLM and append to history.jsonl.
|
||||||
|
|
||||||
|
|||||||
@ -5,9 +5,8 @@ from __future__ import annotations
|
|||||||
from collections.abc import Callable, Mapping
|
from collections.abc import Callable, Mapping
|
||||||
from dataclasses import replace
|
from dataclasses import replace
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
from nanobot.config.schema import ModelPresetConfig
|
from nanobot.config.schema import Config, ModelPresetConfig
|
||||||
from nanobot.providers.base import LLMProvider
|
from nanobot.providers.base import LLMProvider
|
||||||
from nanobot.providers.factory import ProviderSnapshot, build_provider_snapshot
|
from nanobot.providers.factory import ProviderSnapshot, build_provider_snapshot
|
||||||
|
|
||||||
@ -22,7 +21,7 @@ def default_selection_signature(
|
|||||||
return (model_preset, *signature[:2]) if signature else None
|
return (model_preset, *signature[:2]) if signature else None
|
||||||
|
|
||||||
|
|
||||||
def configured_model_presets(config: Any) -> dict[str, ModelPresetConfig]:
|
def configured_model_presets(config: Config) -> dict[str, ModelPresetConfig]:
|
||||||
return {**config.model_presets, "default": config.resolve_default_preset()}
|
return {**config.model_presets, "default": config.resolve_default_preset()}
|
||||||
|
|
||||||
|
|
||||||
@ -41,7 +40,7 @@ def load_model_preset_catalog(
|
|||||||
|
|
||||||
|
|
||||||
def make_preset_snapshot_loader(
|
def make_preset_snapshot_loader(
|
||||||
config: Any,
|
config: Config,
|
||||||
provider_snapshot_loader: Callable[..., ProviderSnapshot] | None,
|
provider_snapshot_loader: Callable[..., ProviderSnapshot] | None,
|
||||||
) -> PresetSnapshotLoader:
|
) -> PresetSnapshotLoader:
|
||||||
if provider_snapshot_loader is not None:
|
if provider_snapshot_loader is not None:
|
||||||
|
|||||||
@ -5,6 +5,7 @@ from __future__ import annotations
|
|||||||
from collections.abc import Callable, Mapping
|
from collections.abc import Callable, Mapping
|
||||||
from dataclasses import replace
|
from dataclasses import replace
|
||||||
from types import MappingProxyType
|
from types import MappingProxyType
|
||||||
|
from typing import cast
|
||||||
|
|
||||||
from nanobot.agent import model_presets as preset_helpers
|
from nanobot.agent import model_presets as preset_helpers
|
||||||
from nanobot.config.schema import Config, ModelPresetConfig
|
from nanobot.config.schema import Config, ModelPresetConfig
|
||||||
@ -139,7 +140,7 @@ class ModelRuntimeResolver:
|
|||||||
|
|
||||||
def select_model(self, model: str) -> LLMRuntime:
|
def select_model(self, model: str) -> LLMRuntime:
|
||||||
"""Change the default model without reconstructing downstream consumers."""
|
"""Change the default model without reconstructing downstream consumers."""
|
||||||
if not isinstance(model, str) or not model.strip():
|
if not isinstance(cast(object, model), str) or not model.strip():
|
||||||
raise ValueError("model must be a non-empty string")
|
raise ValueError("model must be a non-empty string")
|
||||||
self._runtime = replace(
|
self._runtime = replace(
|
||||||
self._runtime,
|
self._runtime,
|
||||||
@ -150,8 +151,9 @@ class ModelRuntimeResolver:
|
|||||||
|
|
||||||
def select_context_window(self, context_window_tokens: int) -> LLMRuntime:
|
def select_context_window(self, context_window_tokens: int) -> LLMRuntime:
|
||||||
"""Change the default context limit for future admissions."""
|
"""Change the default context limit for future admissions."""
|
||||||
if not isinstance(context_window_tokens, int) or isinstance(
|
raw_context_window = cast(object, context_window_tokens)
|
||||||
context_window_tokens,
|
if not isinstance(raw_context_window, int) or isinstance(
|
||||||
|
raw_context_window,
|
||||||
bool,
|
bool,
|
||||||
):
|
):
|
||||||
raise TypeError("context_window_tokens must be an integer")
|
raise TypeError("context_window_tokens must be an integer")
|
||||||
|
|||||||
@ -4,7 +4,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import inspect
|
import inspect
|
||||||
import json
|
import json
|
||||||
from typing import Any, Awaitable, Callable
|
from typing import Any, Awaitable, Callable, cast
|
||||||
|
|
||||||
from loguru import logger
|
from loguru import logger
|
||||||
|
|
||||||
@ -124,7 +124,7 @@ class AgentProgressHook(AgentHook):
|
|||||||
arguments = event.get("arguments")
|
arguments = event.get("arguments")
|
||||||
if not isinstance(arguments, dict):
|
if not isinstance(arguments, dict):
|
||||||
arguments = {}
|
arguments = {}
|
||||||
payload = {
|
payload: dict[str, Any] = {
|
||||||
"version": 1,
|
"version": 1,
|
||||||
"phase": phase,
|
"phase": phase,
|
||||||
"call_id": str(call_id),
|
"call_id": str(call_id),
|
||||||
@ -169,7 +169,7 @@ class AgentProgressHook(AgentHook):
|
|||||||
tool_events = [build_tool_event_start_payload(tc) for tc in context.tool_calls]
|
tool_events = [build_tool_event_start_payload(tc) for tc in context.tool_calls]
|
||||||
await invoke_on_progress(
|
await invoke_on_progress(
|
||||||
self._on_progress,
|
self._on_progress,
|
||||||
tool_hint,
|
cast(str, tool_hint),
|
||||||
tool_hint=True,
|
tool_hint=True,
|
||||||
tool_events=tool_events,
|
tool_events=tool_events,
|
||||||
)
|
)
|
||||||
|
|||||||
@ -5,10 +5,11 @@ from __future__ import annotations
|
|||||||
import asyncio
|
import asyncio
|
||||||
import inspect
|
import inspect
|
||||||
import os
|
import os
|
||||||
|
from collections.abc import Awaitable, Callable, Iterable
|
||||||
from copy import deepcopy
|
from copy import deepcopy
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any, Callable
|
from typing import Any, cast
|
||||||
|
|
||||||
from loguru import logger
|
from loguru import logger
|
||||||
|
|
||||||
@ -48,6 +49,10 @@ from nanobot.utils.runtime import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
GoalContinueMessage = str | Callable[[], str | None]
|
GoalContinueMessage = str | Callable[[], str | None]
|
||||||
|
ProgressCallback = Callable[[str], Awaitable[None]]
|
||||||
|
RetryWaitCallback = Callable[[str], Awaitable[None]]
|
||||||
|
CheckpointCallback = Callable[[dict[str, Any]], Awaitable[None]]
|
||||||
|
InjectionCallback = Callable[..., Awaitable[Iterable[Any] | None]]
|
||||||
|
|
||||||
_DEFAULT_ERROR_MESSAGE = "Sorry, I encountered an error calling the AI model."
|
_DEFAULT_ERROR_MESSAGE = "Sorry, I encountered an error calling the AI model."
|
||||||
_ARREARAGE_ERROR_MESSAGE = (
|
_ARREARAGE_ERROR_MESSAGE = (
|
||||||
@ -90,11 +95,11 @@ class AgentRunSpec:
|
|||||||
session_key: str | None = None
|
session_key: str | None = None
|
||||||
context_block_limit: int | None = None
|
context_block_limit: int | None = None
|
||||||
provider_retry_mode: str = "standard"
|
provider_retry_mode: str = "standard"
|
||||||
progress_callback: Any | None = None
|
progress_callback: ProgressCallback | None = None
|
||||||
stream_progress_deltas: bool = True
|
stream_progress_deltas: bool = True
|
||||||
retry_wait_callback: Any | None = None
|
retry_wait_callback: RetryWaitCallback | None = None
|
||||||
checkpoint_callback: Any | None = None
|
checkpoint_callback: CheckpointCallback | None = None
|
||||||
injection_callback: Any | None = None
|
injection_callback: InjectionCallback | None = None
|
||||||
llm_timeout_s: float | None = None
|
llm_timeout_s: float | None = None
|
||||||
goal_active_predicate: Callable[[], bool] | None = None
|
goal_active_predicate: Callable[[], bool] | None = None
|
||||||
goal_continue_message: GoalContinueMessage | None = None
|
goal_continue_message: GoalContinueMessage | None = None
|
||||||
@ -131,8 +136,10 @@ class AgentRunner:
|
|||||||
def _to_blocks(value: Any) -> list[dict[str, Any]]:
|
def _to_blocks(value: Any) -> list[dict[str, Any]]:
|
||||||
if isinstance(value, list):
|
if isinstance(value, list):
|
||||||
return [
|
return [
|
||||||
item if isinstance(item, dict) else {"type": "text", "text": str(item)}
|
cast(dict[str, Any], item)
|
||||||
for item in value
|
if isinstance(item, dict)
|
||||||
|
else {"type": "text", "text": str(item)}
|
||||||
|
for item in cast(list[Any], value)
|
||||||
]
|
]
|
||||||
if value is None:
|
if value is None:
|
||||||
return []
|
return []
|
||||||
@ -158,25 +165,37 @@ class AgentRunner:
|
|||||||
merged = dict(messages[-1])
|
merged = dict(messages[-1])
|
||||||
left_meta = merged.get("_meta")
|
left_meta = merged.get("_meta")
|
||||||
right_meta = injection.get("_meta")
|
right_meta = injection.get("_meta")
|
||||||
|
left_meta_dict = cast(dict[str, Any], left_meta) if isinstance(left_meta, dict) else None
|
||||||
|
right_meta_dict = (
|
||||||
|
cast(dict[str, Any], right_meta) if isinstance(right_meta, dict) else None
|
||||||
|
)
|
||||||
left_marker = (
|
left_marker = (
|
||||||
left_meta.get(RUNTIME_CONTEXT_MESSAGE_META)
|
left_meta_dict.get(RUNTIME_CONTEXT_MESSAGE_META)
|
||||||
if isinstance(left_meta, dict)
|
if left_meta_dict is not None
|
||||||
else None
|
else None
|
||||||
)
|
)
|
||||||
right_marker = (
|
right_marker = (
|
||||||
right_meta.get(RUNTIME_CONTEXT_MESSAGE_META)
|
right_meta_dict.get(RUNTIME_CONTEXT_MESSAGE_META)
|
||||||
if isinstance(right_meta, dict)
|
if right_meta_dict is not None
|
||||||
else None
|
else None
|
||||||
)
|
)
|
||||||
|
left_marker_dict = (
|
||||||
|
cast(dict[str, Any], left_marker) if isinstance(left_marker, dict) else None
|
||||||
|
)
|
||||||
|
right_marker_dict = (
|
||||||
|
cast(dict[str, Any], right_marker) if isinstance(right_marker, dict) else None
|
||||||
|
)
|
||||||
|
empty_sources: list[str] = []
|
||||||
|
empty_blocks: list[dict[str, Any]] = []
|
||||||
detached_left = (
|
detached_left = (
|
||||||
detach_runtime_context(merged.get("content"), left_marker)
|
detach_runtime_context(merged.get("content"), left_marker_dict)
|
||||||
if isinstance(left_marker, dict)
|
if left_marker_dict is not None
|
||||||
else (merged.get("content"), [], [])
|
else (merged.get("content"), empty_sources, empty_blocks)
|
||||||
)
|
)
|
||||||
detached_right = (
|
detached_right = (
|
||||||
detach_runtime_context(injection.get("content"), right_marker)
|
detach_runtime_context(injection.get("content"), right_marker_dict)
|
||||||
if isinstance(right_marker, dict)
|
if right_marker_dict is not None
|
||||||
else (injection.get("content"), [], [])
|
else (injection.get("content"), empty_sources, empty_blocks)
|
||||||
)
|
)
|
||||||
if detached_left is not None and detached_right is not None:
|
if detached_left is not None and detached_right is not None:
|
||||||
left_content, left_sources, left_blocks = detached_left
|
left_content, left_sources, left_blocks = detached_left
|
||||||
@ -189,9 +208,9 @@ class AgentRunner:
|
|||||||
[*left_sources, *right_sources],
|
[*left_sources, *right_sources],
|
||||||
context_blocks,
|
context_blocks,
|
||||||
)
|
)
|
||||||
internal_meta = dict(left_meta) if isinstance(left_meta, dict) else {}
|
internal_meta = dict(left_meta_dict) if left_meta_dict is not None else {}
|
||||||
if isinstance(right_meta, dict):
|
if right_meta_dict is not None:
|
||||||
for key, value in right_meta.items():
|
for key, value in right_meta_dict.items():
|
||||||
internal_meta.setdefault(key, value)
|
internal_meta.setdefault(key, value)
|
||||||
internal_meta[RUNTIME_CONTEXT_MESSAGE_META] = marker
|
internal_meta[RUNTIME_CONTEXT_MESSAGE_META] = marker
|
||||||
merged["_meta"] = internal_meta
|
merged["_meta"] = internal_meta
|
||||||
@ -302,11 +321,11 @@ class AgentRunner:
|
|||||||
for item in items:
|
for item in items:
|
||||||
if item is None:
|
if item is None:
|
||||||
continue
|
continue
|
||||||
if isinstance(item, dict) and item.get("role") == "user" and "content" in item:
|
|
||||||
if self._has_injection_content(item.get("content")):
|
|
||||||
injected_messages.append(item)
|
|
||||||
continue
|
|
||||||
if isinstance(item, dict):
|
if isinstance(item, dict):
|
||||||
|
message_item = cast(dict[str, Any], item)
|
||||||
|
if message_item.get("role") == "user" and "content" in message_item:
|
||||||
|
if self._has_injection_content(message_item.get("content")):
|
||||||
|
injected_messages.append(message_item)
|
||||||
continue
|
continue
|
||||||
content = getattr(item, "content") if hasattr(item, "content") else str(item)
|
content = getattr(item, "content") if hasattr(item, "content") else str(item)
|
||||||
if self._has_injection_content(content):
|
if self._has_injection_content(content):
|
||||||
@ -327,7 +346,7 @@ class AgentRunner:
|
|||||||
if isinstance(content, str):
|
if isinstance(content, str):
|
||||||
return bool(content.strip())
|
return bool(content.strip())
|
||||||
if isinstance(content, list):
|
if isinstance(content, list):
|
||||||
return bool(content)
|
return bool(cast(list[Any], content))
|
||||||
return True
|
return True
|
||||||
|
|
||||||
async def run(self, spec: AgentRunSpec) -> AgentRunResult:
|
async def run(self, spec: AgentRunSpec) -> AgentRunResult:
|
||||||
@ -592,7 +611,7 @@ class AgentRunner:
|
|||||||
if response.finish_reason == "length" and not is_blank_text(clean):
|
if response.finish_reason == "length" and not is_blank_text(clean):
|
||||||
if len(length_recovery_parts) < _MAX_LENGTH_RECOVERIES:
|
if len(length_recovery_parts) < _MAX_LENGTH_RECOVERIES:
|
||||||
length_recovery_parts.append(
|
length_recovery_parts.append(
|
||||||
_restore_outer_whitespace(clean, original_content)
|
_restore_outer_whitespace(clean or "", original_content)
|
||||||
)
|
)
|
||||||
logger.info(
|
logger.info(
|
||||||
"Output truncated on turn {} for {} ({}/{}); continuing",
|
"Output truncated on turn {} for {} ({}/{}); continuing",
|
||||||
@ -609,7 +628,7 @@ class AgentRunner:
|
|||||||
reasoning_content=response.reasoning_content,
|
reasoning_content=response.reasoning_content,
|
||||||
thinking_blocks=response.thinking_blocks,
|
thinking_blocks=response.thinking_blocks,
|
||||||
))
|
))
|
||||||
messages.append(build_length_recovery_message(clean))
|
messages.append(build_length_recovery_message(clean or ""))
|
||||||
await hook.after_iteration(context)
|
await hook.after_iteration(context)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
@ -626,7 +645,7 @@ class AgentRunner:
|
|||||||
):
|
):
|
||||||
await hook.on_stream(
|
await hook.on_stream(
|
||||||
context,
|
context,
|
||||||
_restore_outer_whitespace(clean, original_content),
|
_restore_outer_whitespace(clean or "", original_content),
|
||||||
)
|
)
|
||||||
context.streamed_content = True
|
context.streamed_content = True
|
||||||
|
|
||||||
@ -717,7 +736,7 @@ class AgentRunner:
|
|||||||
if length_recovery_parts:
|
if length_recovery_parts:
|
||||||
final_content = (
|
final_content = (
|
||||||
"".join(length_recovery_parts)
|
"".join(length_recovery_parts)
|
||||||
+ _restore_outer_whitespace(clean, original_content)
|
+ _restore_outer_whitespace(clean or "", original_content)
|
||||||
).strip()
|
).strip()
|
||||||
else:
|
else:
|
||||||
final_content = clean
|
final_content = clean
|
||||||
@ -798,7 +817,7 @@ class AgentRunner:
|
|||||||
context: AgentHookContext,
|
context: AgentHookContext,
|
||||||
*,
|
*,
|
||||||
malformed_retry: bool = False,
|
malformed_retry: bool = False,
|
||||||
):
|
) -> LLMResponse:
|
||||||
timeout_s: float | None = spec.llm_timeout_s
|
timeout_s: float | None = spec.llm_timeout_s
|
||||||
if timeout_s is None:
|
if timeout_s is None:
|
||||||
# Default to a finite timeout to avoid per-session lock starvation when an LLM
|
# Default to a finite timeout to avoid per-session lock starvation when an LLM
|
||||||
@ -809,7 +828,7 @@ class AgentRunner:
|
|||||||
timeout_s = float(raw)
|
timeout_s = float(raw)
|
||||||
except (TypeError, ValueError):
|
except (TypeError, ValueError):
|
||||||
timeout_s = 300.0
|
timeout_s = 300.0
|
||||||
if timeout_s is not None and timeout_s <= 0:
|
if timeout_s <= 0:
|
||||||
timeout_s = None
|
timeout_s = None
|
||||||
|
|
||||||
kwargs = self._build_request_kwargs(
|
kwargs = self._build_request_kwargs(
|
||||||
@ -818,10 +837,11 @@ class AgentRunner:
|
|||||||
tools=spec.tools.get_definitions(),
|
tools=spec.tools.get_definitions(),
|
||||||
)
|
)
|
||||||
wants_streaming = hook.wants_streaming()
|
wants_streaming = hook.wants_streaming()
|
||||||
|
progress_callback = spec.progress_callback
|
||||||
wants_progress_streaming = (
|
wants_progress_streaming = (
|
||||||
not wants_streaming
|
not wants_streaming
|
||||||
and spec.stream_progress_deltas
|
and spec.stream_progress_deltas
|
||||||
and spec.progress_callback is not None
|
and progress_callback is not None
|
||||||
and getattr(spec.runtime.provider, "supports_progress_deltas", False) is True
|
and getattr(spec.runtime.provider, "supports_progress_deltas", False) is True
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -894,7 +914,9 @@ class AgentRunner:
|
|||||||
await hook.emit_reasoning_end()
|
await hook.emit_reasoning_end()
|
||||||
progress_state["reasoning_open"] = False
|
progress_state["reasoning_open"] = False
|
||||||
context.streamed_content = True
|
context.streamed_content = True
|
||||||
await spec.progress_callback(incremental)
|
callback = progress_callback
|
||||||
|
if callback is not None:
|
||||||
|
await callback(incremental)
|
||||||
|
|
||||||
coro = spec.runtime.provider.chat_stream_with_retry(
|
coro = spec.runtime.provider.chat_stream_with_retry(
|
||||||
**kwargs,
|
**kwargs,
|
||||||
@ -1038,7 +1060,7 @@ class AgentRunner:
|
|||||||
self,
|
self,
|
||||||
spec: AgentRunSpec,
|
spec: AgentRunSpec,
|
||||||
messages: list[dict[str, Any]],
|
messages: list[dict[str, Any]],
|
||||||
):
|
) -> LLMResponse:
|
||||||
retry_messages = self._finalization_retry_messages(messages)
|
retry_messages = self._finalization_retry_messages(messages)
|
||||||
return await self._request_no_tools(spec, retry_messages)
|
return await self._request_no_tools(spec, retry_messages)
|
||||||
|
|
||||||
@ -1224,7 +1246,7 @@ class AgentRunner:
|
|||||||
))
|
))
|
||||||
tool_results.extend(batch_results)
|
tool_results.extend(batch_results)
|
||||||
else:
|
else:
|
||||||
batch_results = []
|
batch_results: list[tuple[Any, dict[str, str], BaseException | None]] = []
|
||||||
for tool_call in batch:
|
for tool_call in batch:
|
||||||
result = await self._run_tool(
|
result = await self._run_tool(
|
||||||
spec,
|
spec,
|
||||||
@ -1273,12 +1295,17 @@ class AgentRunner:
|
|||||||
if spec.fail_on_tool_error:
|
if spec.fail_on_tool_error:
|
||||||
return lookup_error + hint, event, RuntimeError(lookup_error)
|
return lookup_error + hint, event, RuntimeError(lookup_error)
|
||||||
return lookup_error + hint, event, None
|
return lookup_error + hint, event, None
|
||||||
prepare_call = getattr(spec.tools, "prepare_call", None)
|
prepare_call = cast(
|
||||||
|
Callable[[str, Any], object] | None,
|
||||||
|
getattr(spec.tools, "prepare_call", None),
|
||||||
|
)
|
||||||
tool, params, prep_error = None, tool_call.arguments, None
|
tool, params, prep_error = None, tool_call.arguments, None
|
||||||
if callable(prepare_call):
|
if callable(prepare_call):
|
||||||
prepared = prepare_call(tool_call.name, tool_call.arguments)
|
prepared = prepare_call(tool_call.name, tool_call.arguments)
|
||||||
if isinstance(prepared, tuple) and len(prepared) == 3:
|
if isinstance(prepared, tuple):
|
||||||
tool, params, prep_error = prepared
|
prepared_tuple = cast(tuple[object, ...], prepared)
|
||||||
|
if len(prepared_tuple) == 3:
|
||||||
|
tool, params, prep_error = cast(tuple[Any, Any, str | None], prepared_tuple)
|
||||||
if prep_error:
|
if prep_error:
|
||||||
event = {
|
event = {
|
||||||
"name": tool_call.name,
|
"name": tool_call.name,
|
||||||
@ -1490,7 +1517,7 @@ class AgentRunner:
|
|||||||
batches: list[list[ToolCallRequest]] = []
|
batches: list[list[ToolCallRequest]] = []
|
||||||
current: list[ToolCallRequest] = []
|
current: list[ToolCallRequest] = []
|
||||||
for tool_call in tool_calls:
|
for tool_call in tool_calls:
|
||||||
get_tool = getattr(spec.tools, "get", None)
|
get_tool = cast(Callable[[str], Any] | None, getattr(spec.tools, "get", None))
|
||||||
tool = get_tool(tool_call.name) if callable(get_tool) else None
|
tool = get_tool(tool_call.name) if callable(get_tool) else None
|
||||||
can_batch = bool(tool and tool.concurrency_safe)
|
can_batch = bool(tool and tool.concurrency_safe)
|
||||||
if can_batch:
|
if can_batch:
|
||||||
|
|||||||
@ -5,6 +5,7 @@ import os
|
|||||||
import re
|
import re
|
||||||
import shutil
|
import shutil
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
from typing import Any, cast
|
||||||
|
|
||||||
import yaml
|
import yaml
|
||||||
|
|
||||||
@ -144,7 +145,7 @@ class SkillsLoader:
|
|||||||
skill_name = entry["name"]
|
skill_name = entry["name"]
|
||||||
meta = self._get_skill_meta(skill_name)
|
meta = self._get_skill_meta(skill_name)
|
||||||
available = self._check_requirements(meta)
|
available = self._check_requirements(meta)
|
||||||
desc = self._get_skill_description(skill_name)
|
desc = self.get_skill_description(skill_name)
|
||||||
suffix = ""
|
suffix = ""
|
||||||
if not available:
|
if not available:
|
||||||
missing = self._get_missing_requirements(meta)
|
missing = self._get_missing_requirements(meta)
|
||||||
@ -155,18 +156,18 @@ class SkillsLoader:
|
|||||||
return "\n\n".join(sections)
|
return "\n\n".join(sections)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _requirement_lists(skill_meta: dict) -> tuple[list[str], list[str]]:
|
def _requirement_lists(skill_meta: dict[str, Any]) -> tuple[list[str], list[str]]:
|
||||||
"""Return (bins, env) lists from skill metadata, tolerating null/wrong shapes."""
|
"""Return (bins, env) lists from skill metadata, tolerating null/wrong shapes."""
|
||||||
requires = skill_meta.get("requires") or {}
|
requires = cast(dict[str, Any], skill_meta.get("requires") or {})
|
||||||
if not isinstance(requires, dict):
|
if not isinstance(skill_meta.get("requires") or {}, dict):
|
||||||
return [], []
|
return [], []
|
||||||
bins_raw = requires.get("bins") or []
|
bins_raw: object = requires.get("bins") or []
|
||||||
env_raw = requires.get("env") or []
|
env_raw: object = requires.get("env") or []
|
||||||
bins = [str(v) for v in bins_raw if isinstance(v, str) and v.strip()] if isinstance(bins_raw, list) else []
|
bins = [value for value in cast(list[object], bins_raw) if isinstance(value, str) and value.strip()] if isinstance(bins_raw, list) else []
|
||||||
env = [str(v) for v in env_raw if isinstance(v, str) and v.strip()] if isinstance(env_raw, list) else []
|
env = [value for value in cast(list[object], env_raw) if isinstance(value, str) and value.strip()] if isinstance(env_raw, list) else []
|
||||||
return bins, env
|
return bins, env
|
||||||
|
|
||||||
def _get_missing_requirements(self, skill_meta: dict) -> str:
|
def _get_missing_requirements(self, skill_meta: dict[str, Any]) -> str:
|
||||||
"""Get a description of missing requirements."""
|
"""Get a description of missing requirements."""
|
||||||
required_bins, required_env_vars = self._requirement_lists(skill_meta)
|
required_bins, required_env_vars = self._requirement_lists(skill_meta)
|
||||||
return ", ".join(
|
return ", ".join(
|
||||||
@ -190,11 +191,12 @@ class SkillsLoader:
|
|||||||
"missing_env": [value for value in env if not os.environ.get(value)],
|
"missing_env": [value for value in env if not os.environ.get(value)],
|
||||||
}
|
}
|
||||||
|
|
||||||
def _get_skill_description(self, name: str) -> str:
|
def get_skill_description(self, name: str) -> str:
|
||||||
"""Get the description of a skill from its frontmatter."""
|
"""Get the description of a skill from its frontmatter."""
|
||||||
meta = self.get_skill_metadata(name)
|
meta = self.get_skill_metadata(name)
|
||||||
if meta and meta.get("description"):
|
description = meta.get("description") if meta else None
|
||||||
return meta["description"]
|
if isinstance(description, str) and description:
|
||||||
|
return description
|
||||||
return name # Fallback to skill name
|
return name # Fallback to skill name
|
||||||
|
|
||||||
def _strip_frontmatter(self, content: str) -> str:
|
def _strip_frontmatter(self, content: str) -> str:
|
||||||
@ -206,13 +208,13 @@ class SkillsLoader:
|
|||||||
return content[match.end():].strip()
|
return content[match.end():].strip()
|
||||||
return content
|
return content
|
||||||
|
|
||||||
def _parse_nanobot_metadata(self, raw: object) -> dict:
|
def _parse_nanobot_metadata(self, raw: object) -> dict[str, Any]:
|
||||||
"""Extract nanobot/openclaw metadata from a frontmatter field.
|
"""Extract nanobot/openclaw metadata from a frontmatter field.
|
||||||
|
|
||||||
``raw`` may be a dict (already parsed by yaml.safe_load) or a JSON str.
|
``raw`` may be a dict (already parsed by yaml.safe_load) or a JSON str.
|
||||||
"""
|
"""
|
||||||
if isinstance(raw, dict):
|
if isinstance(raw, dict):
|
||||||
data = raw
|
data = cast(dict[str, Any], raw)
|
||||||
elif isinstance(raw, str):
|
elif isinstance(raw, str):
|
||||||
try:
|
try:
|
||||||
data = json.loads(raw)
|
data = json.loads(raw)
|
||||||
@ -222,17 +224,18 @@ class SkillsLoader:
|
|||||||
return {}
|
return {}
|
||||||
if not isinstance(data, dict):
|
if not isinstance(data, dict):
|
||||||
return {}
|
return {}
|
||||||
payload = data.get("nanobot", data.get("openclaw", {}))
|
data_object = cast(dict[str, Any], data)
|
||||||
return payload if isinstance(payload, dict) else {}
|
payload = data_object.get("nanobot", data_object.get("openclaw", {}))
|
||||||
|
return cast(dict[str, Any], payload) if isinstance(payload, dict) else {}
|
||||||
|
|
||||||
def _check_requirements(self, skill_meta: dict) -> bool:
|
def _check_requirements(self, skill_meta: dict[str, Any]) -> bool:
|
||||||
"""Check if skill requirements are met (bins, env vars)."""
|
"""Check if skill requirements are met (bins, env vars)."""
|
||||||
required_bins, required_env_vars = self._requirement_lists(skill_meta)
|
required_bins, required_env_vars = self._requirement_lists(skill_meta)
|
||||||
return all(shutil.which(cmd) for cmd in required_bins) and all(
|
return all(shutil.which(cmd) for cmd in required_bins) and all(
|
||||||
os.environ.get(var) for var in required_env_vars
|
os.environ.get(var) for var in required_env_vars
|
||||||
)
|
)
|
||||||
|
|
||||||
def _get_skill_meta(self, name: str) -> dict:
|
def _get_skill_meta(self, name: str) -> dict[str, Any]:
|
||||||
"""Get nanobot metadata for a skill (cached in frontmatter)."""
|
"""Get nanobot metadata for a skill (cached in frontmatter)."""
|
||||||
raw_meta = self.get_skill_metadata(name) or {}
|
raw_meta = self.get_skill_metadata(name) or {}
|
||||||
return self._parse_nanobot_metadata(raw_meta.get("metadata"))
|
return self._parse_nanobot_metadata(raw_meta.get("metadata"))
|
||||||
@ -249,7 +252,7 @@ class SkillsLoader:
|
|||||||
)
|
)
|
||||||
]
|
]
|
||||||
|
|
||||||
def get_skill_metadata(self, name: str) -> dict | None:
|
def get_skill_metadata(self, name: str) -> dict[str, object] | None:
|
||||||
"""
|
"""
|
||||||
Get metadata from a skill's frontmatter.
|
Get metadata from a skill's frontmatter.
|
||||||
|
|
||||||
@ -274,6 +277,6 @@ class SkillsLoader:
|
|||||||
# yaml.safe_load returns native types (int, bool, list, etc.);
|
# yaml.safe_load returns native types (int, bool, list, etc.);
|
||||||
# keep values as-is so downstream consumers get correct types.
|
# keep values as-is so downstream consumers get correct types.
|
||||||
metadata: dict[str, object] = {}
|
metadata: dict[str, object] = {}
|
||||||
for key, value in parsed.items():
|
for key, value in cast(dict[object, object], parsed).items():
|
||||||
metadata[str(key)] = value
|
metadata[str(key)] = value
|
||||||
return metadata
|
return metadata
|
||||||
|
|||||||
@ -7,12 +7,12 @@ import uuid
|
|||||||
import warnings
|
import warnings
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any, Callable
|
from typing import Any, Callable, TypedDict
|
||||||
|
|
||||||
from loguru import logger
|
from loguru import logger
|
||||||
|
|
||||||
from nanobot.agent.hook import AgentHook, AgentHookContext
|
from nanobot.agent.hook import AgentHook, AgentHookContext
|
||||||
from nanobot.agent.runner import AgentRunner, AgentRunSpec
|
from nanobot.agent.runner import AgentRunner, AgentRunResult, AgentRunSpec
|
||||||
from nanobot.agent.tools.base import ToolResult
|
from nanobot.agent.tools.base import ToolResult
|
||||||
from nanobot.agent.tools.context import (
|
from nanobot.agent.tools.context import (
|
||||||
RequestContext,
|
RequestContext,
|
||||||
@ -38,6 +38,12 @@ from nanobot.utils.llm_runtime import LLMRuntime
|
|||||||
from nanobot.utils.prompt_templates import render_template
|
from nanobot.utils.prompt_templates import render_template
|
||||||
|
|
||||||
|
|
||||||
|
class _SubagentOrigin(TypedDict):
|
||||||
|
channel: str
|
||||||
|
chat_id: str
|
||||||
|
session_key: str | None
|
||||||
|
|
||||||
|
|
||||||
@dataclass(slots=True)
|
@dataclass(slots=True)
|
||||||
class SubagentStatus:
|
class SubagentStatus:
|
||||||
"""Real-time status of a running subagent."""
|
"""Real-time status of a running subagent."""
|
||||||
@ -48,8 +54,8 @@ class SubagentStatus:
|
|||||||
started_at: float # time.monotonic()
|
started_at: float # time.monotonic()
|
||||||
phase: str = "initializing" # initializing | awaiting_tools | tools_completed | final_response | done | error
|
phase: str = "initializing" # initializing | awaiting_tools | tools_completed | final_response | done | error
|
||||||
iteration: int = 0
|
iteration: int = 0
|
||||||
tool_events: list = field(default_factory=list) # [{name, status, detail}, ...]
|
tool_events: list[dict[str, str]] = field(default_factory=list)
|
||||||
usage: dict = field(default_factory=dict) # token usage
|
usage: dict[str, int] = field(default_factory=dict)
|
||||||
stop_reason: str | None = None
|
stop_reason: str | None = None
|
||||||
error: str | None = None
|
error: str | None = None
|
||||||
|
|
||||||
@ -237,7 +243,11 @@ class SubagentManager:
|
|||||||
runtime = runtime.with_generation_overrides(temperature=temperature)
|
runtime = runtime.with_generation_overrides(temperature=temperature)
|
||||||
task_id = str(uuid.uuid4())[:8]
|
task_id = str(uuid.uuid4())[:8]
|
||||||
display_label = label or task[:30] + ("..." if len(task) > 30 else "")
|
display_label = label or task[:30] + ("..." if len(task) > 30 else "")
|
||||||
origin = {"channel": origin_channel, "chat_id": origin_chat_id, "session_key": session_key}
|
origin: _SubagentOrigin = {
|
||||||
|
"channel": origin_channel,
|
||||||
|
"chat_id": origin_chat_id,
|
||||||
|
"session_key": session_key,
|
||||||
|
}
|
||||||
|
|
||||||
status = SubagentStatus(
|
status = SubagentStatus(
|
||||||
task_id=task_id,
|
task_id=task_id,
|
||||||
@ -263,7 +273,7 @@ class SubagentManager:
|
|||||||
if session_key:
|
if session_key:
|
||||||
self._session_tasks.setdefault(session_key, set()).add(task_id)
|
self._session_tasks.setdefault(session_key, set()).add(task_id)
|
||||||
|
|
||||||
def _cleanup(_: asyncio.Task) -> None:
|
def _cleanup(_: asyncio.Task[str]) -> None:
|
||||||
self._running_tasks.pop(task_id, None)
|
self._running_tasks.pop(task_id, None)
|
||||||
self._task_statuses.pop(task_id, None)
|
self._task_statuses.pop(task_id, None)
|
||||||
if session_key and (ids := self._session_tasks.get(session_key)):
|
if session_key and (ids := self._session_tasks.get(session_key)):
|
||||||
@ -296,7 +306,7 @@ class SubagentManager:
|
|||||||
runtime = runtime.with_generation_overrides(temperature=temperature)
|
runtime = runtime.with_generation_overrides(temperature=temperature)
|
||||||
task_id = str(uuid.uuid4())[:8]
|
task_id = str(uuid.uuid4())[:8]
|
||||||
display_label = label or task[:30] + ("..." if len(task) > 30 else "")
|
display_label = label or task[:30] + ("..." if len(task) > 30 else "")
|
||||||
origin = {
|
origin: _SubagentOrigin = {
|
||||||
"channel": origin_channel,
|
"channel": origin_channel,
|
||||||
"chat_id": origin_chat_id,
|
"chat_id": origin_chat_id,
|
||||||
"session_key": session_key,
|
"session_key": session_key,
|
||||||
@ -343,7 +353,7 @@ class SubagentManager:
|
|||||||
task_id: str,
|
task_id: str,
|
||||||
task: str,
|
task: str,
|
||||||
label: str,
|
label: str,
|
||||||
origin: dict[str, str],
|
origin: _SubagentOrigin,
|
||||||
status: SubagentStatus,
|
status: SubagentStatus,
|
||||||
runtime: LLMRuntime,
|
runtime: LLMRuntime,
|
||||||
origin_message_id: str | None = None,
|
origin_message_id: str | None = None,
|
||||||
@ -354,7 +364,7 @@ class SubagentManager:
|
|||||||
"""Execute the subagent task and announce the result."""
|
"""Execute the subagent task and announce the result."""
|
||||||
logger.info("Subagent [{}] starting task: {}", task_id, label)
|
logger.info("Subagent [{}] starting task: {}", task_id, label)
|
||||||
|
|
||||||
async def _on_checkpoint(payload: dict) -> None:
|
async def _on_checkpoint(payload: dict[str, Any]) -> None:
|
||||||
status.phase = payload.get("phase", status.phase)
|
status.phase = payload.get("phase", status.phase)
|
||||||
status.iteration = payload.get("iteration", status.iteration)
|
status.iteration = payload.get("iteration", status.iteration)
|
||||||
|
|
||||||
@ -456,7 +466,7 @@ class SubagentManager:
|
|||||||
label: str,
|
label: str,
|
||||||
task: str,
|
task: str,
|
||||||
result: str,
|
result: str,
|
||||||
origin: dict[str, str],
|
origin: _SubagentOrigin,
|
||||||
status: str,
|
status: str,
|
||||||
origin_message_id: str | None = None,
|
origin_message_id: str | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
@ -496,7 +506,7 @@ class SubagentManager:
|
|||||||
logger.debug("Subagent [{}] announced result to {}:{}", task_id, origin['channel'], origin['chat_id'])
|
logger.debug("Subagent [{}] announced result to {}:{}", task_id, origin['channel'], origin['chat_id'])
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _format_partial_progress(result) -> str:
|
def _format_partial_progress(result: AgentRunResult) -> str:
|
||||||
completed = [e for e in result.tool_events if e["status"] == "ok"]
|
completed = [e for e in result.tool_events if e["status"] == "ok"]
|
||||||
failure = next((e for e in reversed(result.tool_events) if e["status"] == "error"), None)
|
failure = next((e for e in reversed(result.tool_events) if e["status"] == "error"), None)
|
||||||
lines: list[str] = []
|
lines: list[str] = []
|
||||||
|
|||||||
@ -5,10 +5,10 @@ from __future__ import annotations
|
|||||||
import difflib
|
import difflib
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any
|
from typing import Any, cast
|
||||||
|
|
||||||
from nanobot.agent.tools.base import ToolResult, tool_parameters
|
from nanobot.agent.tools.base import ToolResult, tool_parameters
|
||||||
from nanobot.agent.tools.filesystem import _FsTool
|
from nanobot.agent.tools.filesystem import _FsTool # pyright: ignore[reportPrivateUsage]
|
||||||
from nanobot.agent.tools.schema import (
|
from nanobot.agent.tools.schema import (
|
||||||
ArraySchema,
|
ArraySchema,
|
||||||
BooleanSchema,
|
BooleanSchema,
|
||||||
@ -134,7 +134,7 @@ class ApplyPatchTool(_FsTool):
|
|||||||
|
|
||||||
async def execute(
|
async def execute(
|
||||||
self,
|
self,
|
||||||
edits: list[dict] | None = None,
|
edits: list[object] | None = None,
|
||||||
dry_run: bool = False,
|
dry_run: bool = False,
|
||||||
**kwargs: Any,
|
**kwargs: Any,
|
||||||
) -> str:
|
) -> str:
|
||||||
@ -145,9 +145,10 @@ class ApplyPatchTool(_FsTool):
|
|||||||
writes: dict[Path, str] = {}
|
writes: dict[Path, str] = {}
|
||||||
summaries: list[_PatchSummary] = []
|
summaries: list[_PatchSummary] = []
|
||||||
|
|
||||||
for edit in edits:
|
for edit_value in edits:
|
||||||
if not isinstance(edit, dict):
|
if not isinstance(edit_value, dict):
|
||||||
raise _PatchError("each edit must be an object")
|
raise _PatchError("each edit must be an object")
|
||||||
|
edit = cast(dict[str, Any], edit_value)
|
||||||
raw_path = edit.get("path")
|
raw_path = edit.get("path")
|
||||||
if not isinstance(raw_path, str):
|
if not isinstance(raw_path, str):
|
||||||
raise _PatchError("path required for edit")
|
raise _PatchError("path required for edit")
|
||||||
@ -161,6 +162,7 @@ class ApplyPatchTool(_FsTool):
|
|||||||
new_text = edit.get("new_text")
|
new_text = edit.get("new_text")
|
||||||
if new_text is None:
|
if new_text is None:
|
||||||
raise _PatchError(f"new_text required for add: {path}")
|
raise _PatchError(f"new_text required for add: {path}")
|
||||||
|
new_text = cast(str, new_text)
|
||||||
|
|
||||||
pending = writes.get(source)
|
pending = writes.get(source)
|
||||||
if pending is not None:
|
if pending is not None:
|
||||||
@ -204,9 +206,11 @@ class ApplyPatchTool(_FsTool):
|
|||||||
old_text = edit.get("old_text") or ""
|
old_text = edit.get("old_text") or ""
|
||||||
if not old_text:
|
if not old_text:
|
||||||
raise _PatchError(f"old_text required for replace: {path}")
|
raise _PatchError(f"old_text required for replace: {path}")
|
||||||
|
old_text = cast(str, old_text)
|
||||||
new_text = edit.get("new_text")
|
new_text = edit.get("new_text")
|
||||||
if new_text is None:
|
if new_text is None:
|
||||||
raise _PatchError(f"new_text required for replace: {path}")
|
raise _PatchError(f"new_text required for replace: {path}")
|
||||||
|
new_text = cast(str, new_text)
|
||||||
|
|
||||||
pending = writes.get(source)
|
pending = writes.get(source)
|
||||||
if pending is not None:
|
if pending is not None:
|
||||||
|
|||||||
@ -5,7 +5,7 @@ import typing
|
|||||||
from abc import ABC, abstractmethod
|
from abc import ABC, abstractmethod
|
||||||
from collections.abc import Callable
|
from collections.abc import Callable
|
||||||
from copy import deepcopy
|
from copy import deepcopy
|
||||||
from typing import Any, TypeVar
|
from typing import Any, TypeVar, cast
|
||||||
|
|
||||||
if typing.TYPE_CHECKING:
|
if typing.TYPE_CHECKING:
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
@ -38,8 +38,9 @@ class Schema(ABC):
|
|||||||
def resolve_json_schema_type(t: Any) -> str | None:
|
def resolve_json_schema_type(t: Any) -> str | None:
|
||||||
"""Resolve the non-null type name from JSON Schema ``type`` (e.g. ``['string','null']`` -> ``'string'``)."""
|
"""Resolve the non-null type name from JSON Schema ``type`` (e.g. ``['string','null']`` -> ``'string'``)."""
|
||||||
if isinstance(t, list):
|
if isinstance(t, list):
|
||||||
return next((x for x in t if x != "null"), None)
|
types = cast(list[Any], t)
|
||||||
return t # type: ignore[return-value]
|
return cast(str | None, next((x for x in types if x != "null"), None))
|
||||||
|
return cast(str | None, t)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def subpath(path: str, key: str) -> str:
|
def subpath(path: str, key: str) -> str:
|
||||||
@ -76,33 +77,41 @@ class Schema(ABC):
|
|||||||
if "maximum" in schema and val > schema["maximum"]:
|
if "maximum" in schema and val > schema["maximum"]:
|
||||||
errors.append(f"{label} must be <= {schema['maximum']}")
|
errors.append(f"{label} must be <= {schema['maximum']}")
|
||||||
if t == "string":
|
if t == "string":
|
||||||
if "minLength" in schema and len(val) < schema["minLength"]:
|
string_value = cast(str, val)
|
||||||
|
if "minLength" in schema and len(string_value) < schema["minLength"]:
|
||||||
errors.append(f"{label} must be at least {schema['minLength']} chars")
|
errors.append(f"{label} must be at least {schema['minLength']} chars")
|
||||||
if "maxLength" in schema and len(val) > schema["maxLength"]:
|
if "maxLength" in schema and len(string_value) > schema["maxLength"]:
|
||||||
errors.append(f"{label} must be at most {schema['maxLength']} chars")
|
errors.append(f"{label} must be at most {schema['maxLength']} chars")
|
||||||
if t == "object":
|
if t == "object":
|
||||||
props = schema.get("properties", {})
|
object_value = cast(dict[str, Any], val)
|
||||||
for k in schema.get("required", []):
|
props = cast(dict[str, Any], schema.get("properties", {}))
|
||||||
if k not in val:
|
required = cast(list[Any], schema.get("required", []))
|
||||||
|
for k in required:
|
||||||
|
if k not in object_value:
|
||||||
errors.append(f"missing required {Schema.subpath(path, k)}")
|
errors.append(f"missing required {Schema.subpath(path, k)}")
|
||||||
additional = schema.get("additionalProperties", True)
|
additional = schema.get("additionalProperties", True)
|
||||||
for k, v in val.items():
|
for k, v in object_value.items():
|
||||||
if k in props:
|
if k in props:
|
||||||
errors.extend(Schema.validate_json_schema_value(v, props[k], Schema.subpath(path, k)))
|
errors.extend(Schema.validate_json_schema_value(v, props[k], Schema.subpath(path, k)))
|
||||||
elif additional is False:
|
elif additional is False:
|
||||||
errors.append(f"unexpected parameter {Schema.subpath(path, k)}")
|
errors.append(f"unexpected parameter {Schema.subpath(path, k)}")
|
||||||
elif isinstance(additional, dict):
|
elif isinstance(additional, dict):
|
||||||
errors.extend(
|
errors.extend(
|
||||||
Schema.validate_json_schema_value(v, additional, Schema.subpath(path, k))
|
Schema.validate_json_schema_value(
|
||||||
|
v,
|
||||||
|
cast(dict[str, Any], additional),
|
||||||
|
Schema.subpath(path, k),
|
||||||
|
)
|
||||||
)
|
)
|
||||||
if t == "array":
|
if t == "array":
|
||||||
if "minItems" in schema and len(val) < schema["minItems"]:
|
array_value = cast(list[Any], val)
|
||||||
|
if "minItems" in schema and len(array_value) < schema["minItems"]:
|
||||||
errors.append(f"{label} must have at least {schema['minItems']} items")
|
errors.append(f"{label} must have at least {schema['minItems']} items")
|
||||||
if "maxItems" in schema and len(val) > schema["maxItems"]:
|
if "maxItems" in schema and len(array_value) > schema["maxItems"]:
|
||||||
errors.append(f"{label} must be at most {schema['maxItems']} items")
|
errors.append(f"{label} must be at most {schema['maxItems']} items")
|
||||||
if "items" in schema:
|
if "items" in schema:
|
||||||
prefix = f"{path}[{{}}]" if path else "[{}]"
|
prefix = f"{path}[{{}}]" if path else "[{}]"
|
||||||
for i, item in enumerate(val):
|
for i, item in enumerate(array_value):
|
||||||
errors.extend(
|
errors.extend(
|
||||||
Schema.validate_json_schema_value(item, schema["items"], prefix.format(i))
|
Schema.validate_json_schema_value(item, schema["items"], prefix.format(i))
|
||||||
)
|
)
|
||||||
@ -114,9 +123,9 @@ class Schema(ABC):
|
|||||||
# Try to_json_schema first: Schema instances must be distinguished from dicts that are already JSON Schema
|
# Try to_json_schema first: Schema instances must be distinguished from dicts that are already JSON Schema
|
||||||
to_js = getattr(value, "to_json_schema", None)
|
to_js = getattr(value, "to_json_schema", None)
|
||||||
if callable(to_js):
|
if callable(to_js):
|
||||||
return to_js()
|
return cast(dict[str, Any], to_js())
|
||||||
if isinstance(value, dict):
|
if isinstance(value, dict):
|
||||||
return value
|
return cast(dict[str, Any], value)
|
||||||
raise TypeError(f"Expected schema object or dict, got {type(value).__name__}")
|
raise TypeError(f"Expected schema object or dict, got {type(value).__name__}")
|
||||||
|
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
@ -223,14 +232,15 @@ class Tool(ABC):
|
|||||||
def _cast_object(self, obj: Any, schema: dict[str, Any]) -> dict[str, Any]:
|
def _cast_object(self, obj: Any, schema: dict[str, Any]) -> dict[str, Any]:
|
||||||
if not isinstance(obj, dict):
|
if not isinstance(obj, dict):
|
||||||
return obj
|
return obj
|
||||||
props = schema.get("properties", {})
|
props = cast(dict[str, Any], schema.get("properties", {}))
|
||||||
additional = schema.get("additionalProperties")
|
additional = schema.get("additionalProperties")
|
||||||
casted: dict[str, Any] = {}
|
casted: dict[str, Any] = {}
|
||||||
for k, v in obj.items():
|
object_value = cast(dict[str, Any], obj)
|
||||||
|
for k, v in object_value.items():
|
||||||
if k in props:
|
if k in props:
|
||||||
casted[k] = self._cast_value(v, props[k])
|
casted[k] = self._cast_value(v, props[k])
|
||||||
elif isinstance(additional, dict):
|
elif isinstance(additional, dict):
|
||||||
casted[k] = self._cast_value(v, additional)
|
casted[k] = self._cast_value(v, cast(dict[str, Any], additional))
|
||||||
else:
|
else:
|
||||||
casted[k] = v
|
casted[k] = v
|
||||||
return casted
|
return casted
|
||||||
@ -273,7 +283,8 @@ class Tool(ABC):
|
|||||||
|
|
||||||
if t == "array" and isinstance(val, list):
|
if t == "array" and isinstance(val, list):
|
||||||
items = schema.get("items")
|
items = schema.get("items")
|
||||||
return [self._cast_value(x, items) for x in val] if items else val
|
array_value = cast(list[Any], val)
|
||||||
|
return [self._cast_value(x, items) for x in array_value] if items else array_value
|
||||||
|
|
||||||
if t == "object" and isinstance(val, dict):
|
if t == "object" and isinstance(val, dict):
|
||||||
return self._cast_object(val, schema)
|
return self._cast_object(val, schema)
|
||||||
@ -282,7 +293,7 @@ class Tool(ABC):
|
|||||||
|
|
||||||
def validate_params(self, params: dict[str, Any]) -> list[str]:
|
def validate_params(self, params: dict[str, Any]) -> list[str]:
|
||||||
"""Validate against JSON schema; empty list means valid."""
|
"""Validate against JSON schema; empty list means valid."""
|
||||||
if not isinstance(params, dict):
|
if not isinstance(cast(object, params), dict):
|
||||||
return [f"parameters must be an object, got {type(params).__name__}"]
|
return [f"parameters must be an object, got {type(params).__name__}"]
|
||||||
schema = self.parameters or {}
|
schema = self.parameters or {}
|
||||||
if schema.get("type", "object") != "object":
|
if schema.get("type", "object") != "object":
|
||||||
|
|||||||
@ -1,14 +1,15 @@
|
|||||||
"""Controlled runner for installed CLI Apps."""
|
"""Controlled runner for installed CLI Apps."""
|
||||||
|
|
||||||
|
# pyright: reportIncompatibleMethodOverride=false
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
from pydantic import Field
|
from pydantic import Field
|
||||||
|
|
||||||
from nanobot.agent.tools.base import Tool, ToolResult, tool_parameters
|
from nanobot.agent.tools.base import Tool, ToolResult, tool_parameters
|
||||||
from nanobot.agent.tools.context import RequestContext
|
from nanobot.agent.tools.context import RequestContext, ToolContext
|
||||||
from nanobot.agent.tools.schema import (
|
from nanobot.agent.tools.schema import (
|
||||||
ArraySchema,
|
ArraySchema,
|
||||||
BooleanSchema,
|
BooleanSchema,
|
||||||
@ -66,11 +67,11 @@ class CliAppsTool(Tool):
|
|||||||
return CliAppsToolConfig
|
return CliAppsToolConfig
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def enabled(cls, ctx: Any) -> bool:
|
def enabled(cls, ctx: ToolContext) -> bool:
|
||||||
return ctx.config.cli_apps.enable
|
return ctx.config.cli_apps.enable
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def create(cls, ctx: Any) -> Tool:
|
def create(cls, ctx: ToolContext) -> Tool:
|
||||||
cfg = ctx.config.cli_apps
|
cfg = ctx.config.cli_apps
|
||||||
return cls(
|
return cls(
|
||||||
workspace=Path(ctx.workspace),
|
workspace=Path(ctx.workspace),
|
||||||
|
|||||||
@ -8,6 +8,16 @@ from pathlib import Path
|
|||||||
from typing import TYPE_CHECKING, Any, Callable, Protocol, runtime_checkable
|
from typing import TYPE_CHECKING, Any, Callable, Protocol, runtime_checkable
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
|
from nanobot.agent.subagent import SubagentManager
|
||||||
|
from nanobot.agent.tools.exec_session import ExecSessionManager
|
||||||
|
from nanobot.agent.tools.file_state import FileStates
|
||||||
|
from nanobot.bus.queue import MessageBus
|
||||||
|
from nanobot.bus.runtime_events import RuntimeEventBus
|
||||||
|
from nanobot.config.schema import ProviderConfig, ToolsConfig
|
||||||
|
from nanobot.cron.service import CronService
|
||||||
|
from nanobot.providers.factory import ProviderSnapshot
|
||||||
|
from nanobot.security.workspace_access import WorkspaceSandboxStatus
|
||||||
|
from nanobot.session.manager import SessionManager
|
||||||
from nanobot.utils.llm_runtime import LLMRuntime
|
from nanobot.utils.llm_runtime import LLMRuntime
|
||||||
|
|
||||||
_CURRENT_REQUEST_CONTEXT: ContextVar["RequestContext | None"] = ContextVar(
|
_CURRENT_REQUEST_CONTEXT: ContextVar["RequestContext | None"] = ContextVar(
|
||||||
@ -67,16 +77,16 @@ def current_request_session_key() -> str | None:
|
|||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class ToolContext:
|
class ToolContext:
|
||||||
config: Any
|
config: ToolsConfig
|
||||||
workspace: str
|
workspace: str
|
||||||
bus: Any | None = None
|
bus: MessageBus | None = None
|
||||||
subagent_manager: Any | None = None
|
subagent_manager: SubagentManager | None = None
|
||||||
cron_service: Any | None = None
|
cron_service: CronService | None = None
|
||||||
exec_session_manager: Any | None = None
|
exec_session_manager: ExecSessionManager | None = None
|
||||||
sessions: Any | None = None
|
sessions: SessionManager | None = None
|
||||||
file_state_store: Any = field(default=None)
|
file_state_store: FileStates | None = None
|
||||||
provider_snapshot_loader: Callable[[], Any] | None = None
|
provider_snapshot_loader: Callable[..., ProviderSnapshot] | None = None
|
||||||
image_generation_provider_configs: dict[str, Any] | None = None
|
image_generation_provider_configs: dict[str, ProviderConfig] | None = None
|
||||||
timezone: str = "UTC"
|
timezone: str = "UTC"
|
||||||
workspace_sandbox: Any | None = None
|
workspace_sandbox: WorkspaceSandboxStatus | None = None
|
||||||
runtime_events: Any | None = None
|
runtime_events: RuntimeEventBus | None = None
|
||||||
|
|||||||
@ -1,13 +1,15 @@
|
|||||||
"""Cron tool for scheduling reminders and tasks."""
|
"""Cron tool for scheduling reminders and tasks."""
|
||||||
|
|
||||||
|
# pyright: reportIncompatibleMethodOverride=false
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from contextvars import ContextVar
|
from contextvars import ContextVar, Token
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from nanobot.agent.tools.base import Tool, ToolResult, tool_parameters
|
from nanobot.agent.tools.base import Tool, ToolResult, tool_parameters
|
||||||
from nanobot.agent.tools.context import current_request_context
|
from nanobot.agent.tools.context import ToolContext, current_request_context
|
||||||
from nanobot.agent.tools.schema import (
|
from nanobot.agent.tools.schema import (
|
||||||
IntegerSchema,
|
IntegerSchema,
|
||||||
StringSchema,
|
StringSchema,
|
||||||
@ -60,12 +62,15 @@ class CronTool(Tool):
|
|||||||
self._in_cron_context: ContextVar[bool] = ContextVar("cron_in_context", default=False)
|
self._in_cron_context: ContextVar[bool] = ContextVar("cron_in_context", default=False)
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def enabled(cls, ctx: Any) -> bool:
|
def enabled(cls, ctx: ToolContext) -> bool:
|
||||||
return ctx.cron_service is not None
|
return ctx.cron_service is not None
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def create(cls, ctx: Any) -> Tool:
|
def create(cls, ctx: ToolContext) -> Tool:
|
||||||
return cls(cron_service=ctx.cron_service, default_timezone=ctx.timezone)
|
cron_service = ctx.cron_service
|
||||||
|
if cron_service is None:
|
||||||
|
raise RuntimeError("CronTool requires an initialized cron service")
|
||||||
|
return cls(cron_service=cron_service, default_timezone=ctx.timezone)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _request_route() -> tuple[str, str, str, dict[str, Any]]:
|
def _request_route() -> tuple[str, str, str, dict[str, Any]]:
|
||||||
@ -79,11 +84,11 @@ class CronTool(Tool):
|
|||||||
)
|
)
|
||||||
return session_key, ctx.channel or "", ctx.chat_id or "", dict(ctx.metadata or {})
|
return session_key, ctx.channel or "", ctx.chat_id or "", dict(ctx.metadata or {})
|
||||||
|
|
||||||
def set_cron_context(self, active: bool):
|
def set_cron_context(self, active: bool) -> Token[bool]:
|
||||||
"""Mark whether the tool is executing inside a cron job callback."""
|
"""Mark whether the tool is executing inside a cron job callback."""
|
||||||
return self._in_cron_context.set(active)
|
return self._in_cron_context.set(active)
|
||||||
|
|
||||||
def reset_cron_context(self, token) -> None:
|
def reset_cron_context(self, token: Token[bool]) -> None:
|
||||||
"""Restore previous cron context."""
|
"""Restore previous cron context."""
|
||||||
self._in_cron_context.reset(token)
|
self._in_cron_context.reset(token)
|
||||||
|
|
||||||
@ -257,7 +262,7 @@ class CronTool(Tool):
|
|||||||
jobs = self._cron.list_jobs()
|
jobs = self._cron.list_jobs()
|
||||||
if not jobs:
|
if not jobs:
|
||||||
return "No scheduled jobs."
|
return "No scheduled jobs."
|
||||||
lines = []
|
lines: list[str] = []
|
||||||
for j in jobs:
|
for j in jobs:
|
||||||
timing = self._format_timing(j.schedule)
|
timing = self._format_timing(j.schedule)
|
||||||
parts = [f"- {j.name} (id: {j.id}, {timing})"]
|
parts = [f"- {j.name} (id: {j.id}, {timing})"]
|
||||||
|
|||||||
@ -10,7 +10,7 @@ from dataclasses import dataclass
|
|||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from nanobot.agent.tools.base import Tool, ToolResult, tool_parameters
|
from nanobot.agent.tools.base import Tool, ToolResult, tool_parameters
|
||||||
from nanobot.agent.tools.context import current_request_session_key
|
from nanobot.agent.tools.context import ToolContext, current_request_session_key
|
||||||
from nanobot.agent.tools.schema import (
|
from nanobot.agent.tools.schema import (
|
||||||
BooleanSchema,
|
BooleanSchema,
|
||||||
IntegerSchema,
|
IntegerSchema,
|
||||||
@ -151,8 +151,8 @@ class _ExecSession:
|
|||||||
timeout=2.0,
|
timeout=2.0,
|
||||||
)
|
)
|
||||||
# Safety-net reap after normal exit.
|
# Safety-net reap after normal exit.
|
||||||
from nanobot.agent.tools.shell import _reap_pid
|
from nanobot.agent.tools.shell import _reap_pid # pyright: ignore[reportPrivateUsage]
|
||||||
_reap_pid(self.process.pid)
|
_reap_pid(self.process.pid) # pyright: ignore[reportPrivateUsage]
|
||||||
elif yield_time_ms > 0:
|
elif yield_time_ms > 0:
|
||||||
await self._wait_for_buffered_output()
|
await self._wait_for_buffered_output()
|
||||||
|
|
||||||
@ -177,9 +177,9 @@ class _ExecSession:
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
if self._process_tree:
|
if self._process_tree:
|
||||||
await ExecTool._kill_process_tree(self.process)
|
await ExecTool._kill_process_tree(self.process) # pyright: ignore[reportPrivateUsage]
|
||||||
else:
|
else:
|
||||||
await ExecTool._kill_process(self.process)
|
await ExecTool._kill_process(self.process) # pyright: ignore[reportPrivateUsage]
|
||||||
finally:
|
finally:
|
||||||
with suppress(asyncio.TimeoutError):
|
with suppress(asyncio.TimeoutError):
|
||||||
await asyncio.wait_for(
|
await asyncio.wait_for(
|
||||||
@ -311,13 +311,13 @@ class ExecSessionManager:
|
|||||||
"""Terminate and remove all active sessions during shutdown."""
|
"""Terminate and remove all active sessions during shutdown."""
|
||||||
async with self._lock:
|
async with self._lock:
|
||||||
self._closed = True
|
self._closed = True
|
||||||
sessions = list(self._sessions.values())
|
sessions: list[_ExecSession] = list(self._sessions.values())
|
||||||
self._sessions.clear()
|
self._sessions.clear()
|
||||||
results = await asyncio.gather(
|
results: list[None | BaseException] = list(await asyncio.gather(
|
||||||
*(session.kill() for session in sessions),
|
*(session.kill() for session in sessions),
|
||||||
return_exceptions=True,
|
return_exceptions=True,
|
||||||
)
|
))
|
||||||
failures = [
|
failures: list[tuple[_ExecSession, BaseException]] = [
|
||||||
(session, result)
|
(session, result)
|
||||||
for session, result in zip(sessions, results, strict=True)
|
for session, result in zip(sessions, results, strict=True)
|
||||||
if isinstance(result, BaseException)
|
if isinstance(result, BaseException)
|
||||||
@ -337,15 +337,15 @@ class ExecSessionManager:
|
|||||||
async def terminate_by_owner(self, owner_session_key: str) -> int:
|
async def terminate_by_owner(self, owner_session_key: str) -> int:
|
||||||
"""Terminate all sessions owned by owner_session_key. Returns count."""
|
"""Terminate all sessions owned by owner_session_key. Returns count."""
|
||||||
async with self._lock:
|
async with self._lock:
|
||||||
victims = []
|
victims: list[_ExecSession] = []
|
||||||
for sid, s in list(self._sessions.items()):
|
for sid, s in list(self._sessions.items()):
|
||||||
if s.owner_session_key == owner_session_key:
|
if s.owner_session_key == owner_session_key:
|
||||||
victims.append(self._sessions.pop(sid))
|
victims.append(self._sessions.pop(sid))
|
||||||
results = await asyncio.gather(
|
results: list[None | BaseException] = list(await asyncio.gather(
|
||||||
*(s.kill() for s in victims),
|
*(s.kill() for s in victims),
|
||||||
return_exceptions=True,
|
return_exceptions=True,
|
||||||
)
|
))
|
||||||
failures = [
|
failures: list[tuple[_ExecSession, BaseException]] = [
|
||||||
(session, result)
|
(session, result)
|
||||||
for session, result in zip(victims, results, strict=True)
|
for session, result in zip(victims, results, strict=True)
|
||||||
if isinstance(result, BaseException)
|
if isinstance(result, BaseException)
|
||||||
@ -384,7 +384,7 @@ class ExecSessionManager:
|
|||||||
) -> asyncio.subprocess.Process:
|
) -> asyncio.subprocess.Process:
|
||||||
from nanobot.agent.tools.shell import ExecTool
|
from nanobot.agent.tools.shell import ExecTool
|
||||||
|
|
||||||
return await ExecTool._spawn(
|
return await ExecTool._spawn( # pyright: ignore[reportPrivateUsage]
|
||||||
command, cwd, env, shell_program, login,
|
command, cwd, env, shell_program, login,
|
||||||
stdin=asyncio.subprocess.PIPE,
|
stdin=asyncio.subprocess.PIPE,
|
||||||
process_tree=True,
|
process_tree=True,
|
||||||
@ -489,7 +489,7 @@ class WriteStdinTool(Tool):
|
|||||||
return ExecToolConfig
|
return ExecToolConfig
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def enabled(cls, ctx: Any) -> bool:
|
def enabled(cls, ctx: ToolContext) -> bool:
|
||||||
return ctx.config.exec.enable
|
return ctx.config.exec.enable
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
@ -500,8 +500,8 @@ class WriteStdinTool(Tool):
|
|||||||
self._manager = manager or DEFAULT_EXEC_SESSION_MANAGER
|
self._manager = manager or DEFAULT_EXEC_SESSION_MANAGER
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def create(cls, ctx: Any) -> Tool:
|
def create(cls, ctx: ToolContext) -> Tool:
|
||||||
return cls(manager=getattr(ctx, "exec_session_manager", None))
|
return cls(manager=ctx.exec_session_manager)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def exclusive(self) -> bool:
|
def exclusive(self) -> bool:
|
||||||
@ -522,7 +522,7 @@ class WriteStdinTool(Tool):
|
|||||||
"Do not use this to start new commands; start them with exec."
|
"Do not use this to start new commands; start them with exec."
|
||||||
)
|
)
|
||||||
|
|
||||||
async def execute(
|
async def execute( # pyright: ignore[reportIncompatibleMethodOverride]
|
||||||
self,
|
self,
|
||||||
session_id: str,
|
session_id: str,
|
||||||
chars: str | None = None,
|
chars: str | None = None,
|
||||||
@ -633,7 +633,7 @@ class ListExecSessionsTool(Tool):
|
|||||||
return ExecToolConfig
|
return ExecToolConfig
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def enabled(cls, ctx: Any) -> bool:
|
def enabled(cls, ctx: ToolContext) -> bool:
|
||||||
return ctx.config.exec.enable
|
return ctx.config.exec.enable
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
@ -644,8 +644,8 @@ class ListExecSessionsTool(Tool):
|
|||||||
self._manager = manager or DEFAULT_EXEC_SESSION_MANAGER
|
self._manager = manager or DEFAULT_EXEC_SESSION_MANAGER
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def create(cls, ctx: Any) -> Tool:
|
def create(cls, ctx: ToolContext) -> Tool:
|
||||||
return cls(manager=getattr(ctx, "exec_session_manager", None))
|
return cls(manager=ctx.exec_session_manager)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def name(self) -> str:
|
def name(self) -> str:
|
||||||
@ -671,7 +671,7 @@ class ListExecSessionsTool(Tool):
|
|||||||
)
|
)
|
||||||
if not sessions:
|
if not sessions:
|
||||||
return "No active exec sessions."
|
return "No active exec sessions."
|
||||||
lines = []
|
lines: list[str] = []
|
||||||
for info in sessions:
|
for info in sessions:
|
||||||
command = " ".join(info.command.split())
|
command = " ".join(info.command.split())
|
||||||
if len(command) > 120:
|
if len(command) > 120:
|
||||||
|
|||||||
@ -125,6 +125,10 @@ class FileStates:
|
|||||||
"""Return the raw ReadState entry for a path, or None."""
|
"""Return the raw ReadState entry for a path, or None."""
|
||||||
return self._state.get(str(Path(path).resolve()))
|
return self._state.get(str(Path(path).resolve()))
|
||||||
|
|
||||||
|
def raw_state(self) -> dict[str, ReadState]:
|
||||||
|
"""Return the mutable backing map for legacy compatibility."""
|
||||||
|
return self._state
|
||||||
|
|
||||||
def clear(self) -> None:
|
def clear(self) -> None:
|
||||||
"""Clear all tracked state (useful for testing)."""
|
"""Clear all tracked state (useful for testing)."""
|
||||||
self._state.clear()
|
self._state.clear()
|
||||||
@ -201,5 +205,5 @@ def clear() -> None:
|
|||||||
# so existing imports keep working.
|
# so existing imports keep working.
|
||||||
def __getattr__(name: str):
|
def __getattr__(name: str):
|
||||||
if name == "_state":
|
if name == "_state":
|
||||||
return _default._state
|
return _default.raw_state()
|
||||||
raise AttributeError(name)
|
raise AttributeError(name)
|
||||||
|
|||||||
@ -1,5 +1,7 @@
|
|||||||
"""File system tools: read, write, edit, list."""
|
"""File system tools: read, write, edit, list."""
|
||||||
|
|
||||||
|
# pyright: reportPrivateUsage=false, reportUnusedFunction=false
|
||||||
|
|
||||||
import difflib
|
import difflib
|
||||||
import mimetypes
|
import mimetypes
|
||||||
import os
|
import os
|
||||||
@ -8,6 +10,7 @@ from pathlib import Path
|
|||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from nanobot.agent.tools.base import Tool, ToolResult, tool_parameters
|
from nanobot.agent.tools.base import Tool, ToolResult, tool_parameters
|
||||||
|
from nanobot.agent.tools.context import ToolContext
|
||||||
from nanobot.agent.tools.file_state import FileStates, _hash_file, current_file_states
|
from nanobot.agent.tools.file_state import FileStates, _hash_file, current_file_states
|
||||||
from nanobot.agent.tools.path_utils import resolve_workspace_path
|
from nanobot.agent.tools.path_utils import resolve_workspace_path
|
||||||
from nanobot.agent.tools.schema import (
|
from nanobot.agent.tools.schema import (
|
||||||
@ -37,7 +40,7 @@ class _FsTool(Tool):
|
|||||||
return FileToolsConfig
|
return FileToolsConfig
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def enabled(cls, ctx: Any) -> bool:
|
def enabled(cls, ctx: ToolContext) -> bool:
|
||||||
return ctx.config.file.enable
|
return ctx.config.file.enable
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
@ -77,7 +80,7 @@ class _FsTool(Tool):
|
|||||||
self._fallback_file_states = FileStates()
|
self._fallback_file_states = FileStates()
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def create(cls, ctx: Any) -> Tool:
|
def create(cls, ctx: ToolContext) -> Tool:
|
||||||
from nanobot.agent.skills import BUILTIN_SKILLS_DIR
|
from nanobot.agent.skills import BUILTIN_SKILLS_DIR
|
||||||
|
|
||||||
agent_workspace = Path(ctx.workspace)
|
agent_workspace = Path(ctx.workspace)
|
||||||
@ -408,7 +411,8 @@ class ReadFileTool(_FsTool):
|
|||||||
result = "\n".join(numbered)
|
result = "\n".join(numbered)
|
||||||
|
|
||||||
if len(result) > self._MAX_CHARS:
|
if len(result) > self._MAX_CHARS:
|
||||||
trimmed, chars = [], 0
|
trimmed: list[str] = []
|
||||||
|
chars = 0
|
||||||
for line in numbered:
|
for line in numbered:
|
||||||
chars += len(line) + 1
|
chars += len(line) + 1
|
||||||
if chars > self._MAX_CHARS:
|
if chars > self._MAX_CHARS:
|
||||||
|
|||||||
@ -4,7 +4,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import TYPE_CHECKING, Any
|
from typing import TYPE_CHECKING, Any, cast
|
||||||
|
|
||||||
from loguru import logger
|
from loguru import logger
|
||||||
from pydantic import Field
|
from pydantic import Field
|
||||||
@ -23,6 +23,7 @@ from nanobot.bus.events import (
|
|||||||
RUNTIME_CONTROL_IMAGE_GENERATION_RELOAD,
|
RUNTIME_CONTROL_IMAGE_GENERATION_RELOAD,
|
||||||
InboundMessage,
|
InboundMessage,
|
||||||
)
|
)
|
||||||
|
from nanobot.bus.queue import MessageBus
|
||||||
from nanobot.config.paths import get_media_dir
|
from nanobot.config.paths import get_media_dir
|
||||||
from nanobot.config_base import Base
|
from nanobot.config_base import Base
|
||||||
from nanobot.providers.image_generation import (
|
from nanobot.providers.image_generation import (
|
||||||
@ -41,6 +42,7 @@ from nanobot.utils.artifacts import (
|
|||||||
from nanobot.utils.helpers import detect_image_mime
|
from nanobot.utils.helpers import detect_image_mime
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
|
from nanobot.agent.tools.context import ToolContext
|
||||||
from nanobot.config.schema import ProviderConfig
|
from nanobot.config.schema import ProviderConfig
|
||||||
|
|
||||||
|
|
||||||
@ -89,11 +91,11 @@ class ImageGenerationTool(Tool):
|
|||||||
return ImageGenerationToolConfig
|
return ImageGenerationToolConfig
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def enabled(cls, ctx: Any) -> bool:
|
def enabled(cls, ctx: ToolContext) -> bool:
|
||||||
return ctx.config.image_generation.enabled
|
return ctx.config.image_generation.enabled
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def create(cls, ctx: Any) -> Tool:
|
def create(cls, ctx: ToolContext) -> Tool:
|
||||||
return cls(
|
return cls(
|
||||||
workspace=ctx.workspace,
|
workspace=ctx.workspace,
|
||||||
config=ctx.config.image_generation,
|
config=ctx.config.image_generation,
|
||||||
@ -134,12 +136,14 @@ class ImageGenerationTool(Tool):
|
|||||||
cls = get_image_gen_provider(self.config.provider)
|
cls = get_image_gen_provider(self.config.provider)
|
||||||
if cls is None:
|
if cls is None:
|
||||||
return None
|
return None
|
||||||
kwargs = {
|
kwargs: dict[str, Any] = {
|
||||||
"api_key": provider.api_key if provider else None,
|
"api_key": provider.api_key if provider and isinstance(provider.api_key, str) else None,
|
||||||
"api_base": provider.api_base if provider else None,
|
"api_base": provider.api_base if provider and isinstance(provider.api_base, str) else None,
|
||||||
"extra_headers": provider.extra_headers if provider else None,
|
"extra_headers": provider.extra_headers
|
||||||
"extra_body": provider.extra_body if provider else None,
|
if provider and isinstance(provider.extra_headers, dict) else None,
|
||||||
"proxy": provider.proxy if provider else None,
|
"extra_body": provider.extra_body
|
||||||
|
if provider and isinstance(provider.extra_body, dict) else None,
|
||||||
|
"proxy": provider.proxy if provider and isinstance(provider.proxy, str) else None,
|
||||||
}
|
}
|
||||||
return cls(**kwargs)
|
return cls(**kwargs)
|
||||||
|
|
||||||
@ -172,7 +176,7 @@ class ImageGenerationTool(Tool):
|
|||||||
return []
|
return []
|
||||||
return [self._resolve_reference_image(value) for value in values if value]
|
return [self._resolve_reference_image(value) for value in values if value]
|
||||||
|
|
||||||
async def execute(
|
async def execute( # pyright: ignore[reportIncompatibleMethodOverride]
|
||||||
self,
|
self,
|
||||||
prompt: str,
|
prompt: str,
|
||||||
reference_images: list[str] | None = None,
|
reference_images: list[str] | None = None,
|
||||||
@ -238,7 +242,7 @@ async def reload_image_generation_tool(state: Any, registry: ToolRegistry) -> di
|
|||||||
}
|
}
|
||||||
|
|
||||||
next_tool = (
|
next_tool = (
|
||||||
ImageGenerationTool(
|
ImageGenerationTool( # pyright: ignore[reportAbstractUsage]
|
||||||
workspace=state.workspace,
|
workspace=state.workspace,
|
||||||
config=tool_config,
|
config=tool_config,
|
||||||
provider_configs=provider_configs,
|
provider_configs=provider_configs,
|
||||||
@ -271,7 +275,7 @@ async def reload_image_generation_tool(state: Any, registry: ToolRegistry) -> di
|
|||||||
|
|
||||||
|
|
||||||
async def request_image_generation_reload(
|
async def request_image_generation_reload(
|
||||||
bus: Any,
|
bus: MessageBus,
|
||||||
*,
|
*,
|
||||||
timeout: float = 5.0,
|
timeout: float = 5.0,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
@ -298,11 +302,13 @@ async def request_image_generation_reload(
|
|||||||
"message": "Image generation hot reload timed out.",
|
"message": "Image generation hot reload timed out.",
|
||||||
"requires_restart": True,
|
"requires_restart": True,
|
||||||
}
|
}
|
||||||
return result if isinstance(result, dict) else {
|
if not isinstance(cast(object, result), dict):
|
||||||
"ok": False,
|
return {
|
||||||
"message": "Image generation hot reload returned an unexpected response.",
|
"ok": False,
|
||||||
"requires_restart": True,
|
"message": "Image generation hot reload returned an unexpected response.",
|
||||||
}
|
"requires_restart": True,
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
async def handle_runtime_control(
|
async def handle_runtime_control(
|
||||||
@ -311,7 +317,7 @@ async def handle_runtime_control(
|
|||||||
registry: ToolRegistry,
|
registry: ToolRegistry,
|
||||||
) -> bool:
|
) -> bool:
|
||||||
"""Handle an in-process image generation reload request."""
|
"""Handle an in-process image generation reload request."""
|
||||||
metadata = msg.metadata if isinstance(msg.metadata, dict) else {}
|
metadata = msg.metadata
|
||||||
if metadata.get(INBOUND_META_RUNTIME_CONTROL) != RUNTIME_CONTROL_IMAGE_GENERATION_RELOAD:
|
if metadata.get(INBOUND_META_RUNTIME_CONTROL) != RUNTIME_CONTROL_IMAGE_GENERATION_RELOAD:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
@ -327,5 +333,5 @@ async def handle_runtime_control(
|
|||||||
"error": str(exc),
|
"error": str(exc),
|
||||||
}
|
}
|
||||||
if isinstance(ack, asyncio.Future) and not ack.done():
|
if isinstance(ack, asyncio.Future) and not ack.done():
|
||||||
ack.set_result(result)
|
cast(asyncio.Future[Any], ack).set_result(result)
|
||||||
return True
|
return True
|
||||||
|
|||||||
@ -1,16 +1,22 @@
|
|||||||
"""Tool discovery and registration via package scanning."""
|
"""Tool discovery and registration via package scanning."""
|
||||||
|
|
||||||
|
# pyright: reportIncompatibleVariableOverride=false
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import importlib
|
import importlib
|
||||||
import pkgutil
|
import pkgutil
|
||||||
from importlib.metadata import entry_points
|
from importlib.metadata import entry_points
|
||||||
from typing import Any
|
from typing import TYPE_CHECKING, Any
|
||||||
|
|
||||||
from loguru import logger
|
from loguru import logger
|
||||||
|
|
||||||
from nanobot.agent.tools.base import Tool, ToolResult
|
from nanobot.agent.tools.base import Tool, ToolResult
|
||||||
from nanobot.agent.tools.registry import ToolRegistry
|
from nanobot.agent.tools.registry import ToolRegistry
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from nanobot.agent.tools.context import RequestContext, ToolContext
|
||||||
|
|
||||||
_SKIP_MODULES = frozenset({
|
_SKIP_MODULES = frozenset({
|
||||||
"base", "schema", "registry", "context", "loader", "config",
|
"base", "schema", "registry", "context", "loader", "config",
|
||||||
"file_state", "sandbox", "mcp", "__init__", "runtime_state",
|
"file_state", "sandbox", "mcp", "__init__", "runtime_state",
|
||||||
@ -83,7 +89,7 @@ class ToolLoader:
|
|||||||
self._plugins = plugins
|
self._plugins = plugins
|
||||||
return plugins
|
return plugins
|
||||||
|
|
||||||
def load(self, ctx: Any, registry: ToolRegistry, *, scope: str = "core") -> list[str]:
|
def load(self, ctx: ToolContext, registry: ToolRegistry, *, scope: str = "core") -> list[str]:
|
||||||
registered: list[str] = []
|
registered: list[str] = []
|
||||||
builtin_names: set[str] = set()
|
builtin_names: set[str] = set()
|
||||||
sources = [(self.discover(), False), (self._discover_plugins().values(), True)]
|
sources = [(self.discover(), False), (self._discover_plugins().values(), True)]
|
||||||
@ -157,7 +163,7 @@ class _LegacyErrorPrefixTool(Tool):
|
|||||||
def config_key(self) -> str:
|
def config_key(self) -> str:
|
||||||
return getattr(self._wrapped, "config_key", "")
|
return getattr(self._wrapped, "config_key", "")
|
||||||
|
|
||||||
def set_context(self, ctx: Any) -> None:
|
def set_context(self, ctx: RequestContext) -> None:
|
||||||
set_context = getattr(self._wrapped, "set_context", None)
|
set_context = getattr(self._wrapped, "set_context", None)
|
||||||
if callable(set_context):
|
if callable(set_context):
|
||||||
set_context(ctx)
|
set_context(ctx)
|
||||||
|
|||||||
@ -1,5 +1,7 @@
|
|||||||
"""Sustained-goal tools with explicit user opt-in at the execution boundary."""
|
"""Sustained-goal tools with explicit user opt-in at the execution boundary."""
|
||||||
|
|
||||||
|
# pyright: reportIncompatibleMethodOverride=false
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from copy import deepcopy
|
from copy import deepcopy
|
||||||
@ -11,7 +13,7 @@ from nanobot.agent.goal_permission import (
|
|||||||
revoke_goal_mutation_permission,
|
revoke_goal_mutation_permission,
|
||||||
)
|
)
|
||||||
from nanobot.agent.tools.base import Tool, ToolResult, tool_parameters
|
from nanobot.agent.tools.base import Tool, ToolResult, tool_parameters
|
||||||
from nanobot.agent.tools.context import RequestContext, current_request_context
|
from nanobot.agent.tools.context import RequestContext, ToolContext, current_request_context
|
||||||
from nanobot.agent.tools.schema import StringSchema, tool_parameters_schema
|
from nanobot.agent.tools.schema import StringSchema, tool_parameters_schema
|
||||||
from nanobot.bus.runtime_events import GoalStateChanged, RuntimeEventBus, RuntimeEventContext
|
from nanobot.bus.runtime_events import GoalStateChanged, RuntimeEventBus, RuntimeEventContext
|
||||||
from nanobot.runtime_context import RuntimeContextBlock, wrap_runtime_context_lines
|
from nanobot.runtime_context import RuntimeContextBlock, wrap_runtime_context_lines
|
||||||
@ -132,23 +134,24 @@ class CreateGoalTool(Tool, _GoalToolsMixin):
|
|||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
sessions: Any,
|
sessions: SessionManager,
|
||||||
runtime_events: RuntimeEventBus | None = None,
|
runtime_events: RuntimeEventBus | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
_GoalToolsMixin.__init__(self, sessions, runtime_events)
|
_GoalToolsMixin.__init__(self, sessions, runtime_events)
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def create(cls, ctx: Any) -> Tool:
|
def create(cls, ctx: ToolContext) -> Tool:
|
||||||
sess = getattr(ctx, "sessions", None)
|
sess = ctx.sessions
|
||||||
assert sess is not None
|
if sess is None:
|
||||||
|
raise RuntimeError("CreateGoalTool requires an initialized session manager")
|
||||||
return cls(
|
return cls(
|
||||||
sessions=sess,
|
sessions=sess,
|
||||||
runtime_events=getattr(ctx, "runtime_events", None),
|
runtime_events=ctx.runtime_events,
|
||||||
)
|
)
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def enabled(cls, ctx: Any) -> bool:
|
def enabled(cls, ctx: ToolContext) -> bool:
|
||||||
return getattr(ctx, "sessions", None) is not None
|
return ctx.sessions is not None
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def name(self) -> str:
|
def name(self) -> str:
|
||||||
@ -262,23 +265,24 @@ class UpdateGoalTool(Tool, _GoalToolsMixin):
|
|||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
sessions: Any,
|
sessions: SessionManager,
|
||||||
runtime_events: RuntimeEventBus | None = None,
|
runtime_events: RuntimeEventBus | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
_GoalToolsMixin.__init__(self, sessions, runtime_events)
|
_GoalToolsMixin.__init__(self, sessions, runtime_events)
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def create(cls, ctx: Any) -> Tool:
|
def create(cls, ctx: ToolContext) -> Tool:
|
||||||
sess = getattr(ctx, "sessions", None)
|
sess = ctx.sessions
|
||||||
assert sess is not None
|
if sess is None:
|
||||||
|
raise RuntimeError("UpdateGoalTool requires an initialized session manager")
|
||||||
return cls(
|
return cls(
|
||||||
sessions=sess,
|
sessions=sess,
|
||||||
runtime_events=getattr(ctx, "runtime_events", None),
|
runtime_events=ctx.runtime_events,
|
||||||
)
|
)
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def enabled(cls, ctx: Any) -> bool:
|
def enabled(cls, ctx: ToolContext) -> bool:
|
||||||
return getattr(ctx, "sessions", None) is not None
|
return ctx.sessions is not None
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def name(self) -> str:
|
def name(self) -> str:
|
||||||
|
|||||||
@ -7,9 +7,9 @@ import os
|
|||||||
import re
|
import re
|
||||||
import shutil
|
import shutil
|
||||||
import urllib.parse
|
import urllib.parse
|
||||||
from collections.abc import Awaitable, Callable
|
from collections.abc import AsyncIterator, Awaitable, Callable
|
||||||
from contextlib import AsyncExitStack, suppress
|
from contextlib import AsyncExitStack, suppress
|
||||||
from typing import Any, Mapping, Protocol
|
from typing import TYPE_CHECKING, Any, Mapping, Protocol, cast
|
||||||
from weakref import WeakKeyDictionary
|
from weakref import WeakKeyDictionary
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
@ -23,6 +23,7 @@ from nanobot.bus.events import (
|
|||||||
RUNTIME_CONTROL_MCP_RELOAD,
|
RUNTIME_CONTROL_MCP_RELOAD,
|
||||||
InboundMessage,
|
InboundMessage,
|
||||||
)
|
)
|
||||||
|
from nanobot.bus.queue import MessageBus
|
||||||
from nanobot.security.network import (
|
from nanobot.security.network import (
|
||||||
PinnedDNSAsyncTransport,
|
PinnedDNSAsyncTransport,
|
||||||
env_proxy_applies_to_url,
|
env_proxy_applies_to_url,
|
||||||
@ -32,6 +33,13 @@ from nanobot.security.network import (
|
|||||||
)
|
)
|
||||||
from nanobot.utils.cancellation import task_is_cancelling
|
from nanobot.utils.cancellation import task_is_cancelling
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from mcp import ClientSession
|
||||||
|
from mcp.types import Prompt, Resource
|
||||||
|
from mcp.types import Tool as MCPToolDefinition
|
||||||
|
|
||||||
|
from nanobot.config.schema import MCPServerConfig
|
||||||
|
|
||||||
# Transient connection errors that warrant a single retry.
|
# Transient connection errors that warrant a single retry.
|
||||||
# These typically happen when an MCP server restarts or a network
|
# These typically happen when an MCP server restarts or a network
|
||||||
# connection is interrupted between calls.
|
# connection is interrupted between calls.
|
||||||
@ -92,7 +100,7 @@ def _mcp_jsonrpc_payload(message: Any) -> Any:
|
|||||||
|
|
||||||
def _payload_value(payload: Any, key: str) -> Any:
|
def _payload_value(payload: Any, key: str) -> Any:
|
||||||
if isinstance(payload, Mapping):
|
if isinstance(payload, Mapping):
|
||||||
return payload.get(key)
|
return cast(Mapping[str, Any], payload).get(key)
|
||||||
return getattr(payload, key, None)
|
return getattr(payload, key, None)
|
||||||
|
|
||||||
|
|
||||||
@ -106,7 +114,7 @@ class _MalformedProgressNotificationFilter:
|
|||||||
def __init__(self, read_stream: Any, server_name: str) -> None:
|
def __init__(self, read_stream: Any, server_name: str) -> None:
|
||||||
self._read_stream = read_stream
|
self._read_stream = read_stream
|
||||||
self._server_name = server_name
|
self._server_name = server_name
|
||||||
self._iterator: Any | None = None
|
self._iterator: AsyncIterator[Any] | None = None
|
||||||
|
|
||||||
async def __aenter__(self) -> "_MalformedProgressNotificationFilter":
|
async def __aenter__(self) -> "_MalformedProgressNotificationFilter":
|
||||||
await self._read_stream.__aenter__()
|
await self._read_stream.__aenter__()
|
||||||
@ -120,11 +128,13 @@ class _MalformedProgressNotificationFilter:
|
|||||||
return self
|
return self
|
||||||
|
|
||||||
async def __anext__(self) -> Any:
|
async def __anext__(self) -> Any:
|
||||||
if self._iterator is None:
|
iterator = self._iterator
|
||||||
self._iterator = self._read_stream.__aiter__()
|
if iterator is None:
|
||||||
|
iterator = self._read_stream.__aiter__()
|
||||||
|
self._iterator = iterator
|
||||||
|
|
||||||
while True:
|
while True:
|
||||||
message = await self._iterator.__anext__()
|
message = await anext(iterator)
|
||||||
if _is_malformed_mcp_progress_notification(message):
|
if _is_malformed_mcp_progress_notification(message):
|
||||||
logger.debug(
|
logger.debug(
|
||||||
"MCP server '{}': dropped progress notification without progressToken",
|
"MCP server '{}': dropped progress notification without progressToken",
|
||||||
@ -241,8 +251,8 @@ def _redact_url(url: str) -> str:
|
|||||||
return "<redacted-url>"
|
return "<redacted-url>"
|
||||||
|
|
||||||
|
|
||||||
def _pinned_transport_kwargs() -> dict[str, object]:
|
def _pinned_transport_kwargs() -> dict[str, Any]:
|
||||||
kwargs: dict[str, object] = {"transport": PinnedDNSAsyncTransport()}
|
kwargs: dict[str, Any] = {"transport": PinnedDNSAsyncTransport()}
|
||||||
mounts = httpx_env_proxy_mounts()
|
mounts = httpx_env_proxy_mounts()
|
||||||
if mounts:
|
if mounts:
|
||||||
kwargs["mounts"] = mounts
|
kwargs["mounts"] = mounts
|
||||||
@ -302,13 +312,14 @@ def _extract_nullable_branch(options: Any) -> tuple[dict[str, Any], bool] | None
|
|||||||
|
|
||||||
non_null: list[dict[str, Any]] = []
|
non_null: list[dict[str, Any]] = []
|
||||||
saw_null = False
|
saw_null = False
|
||||||
for option in options:
|
for option in cast(list[object], options):
|
||||||
if not isinstance(option, dict):
|
if not isinstance(option, dict):
|
||||||
return None
|
return None
|
||||||
if option.get("type") == "null":
|
option_schema = cast(dict[str, Any], option)
|
||||||
|
if option_schema.get("type") == "null":
|
||||||
saw_null = True
|
saw_null = True
|
||||||
continue
|
continue
|
||||||
non_null.append(option)
|
non_null.append(option_schema)
|
||||||
|
|
||||||
if saw_null and len(non_null) == 1:
|
if saw_null and len(non_null) == 1:
|
||||||
return non_null[0], True
|
return non_null[0], True
|
||||||
@ -330,9 +341,9 @@ def _resolve_local_schema_ref(root: dict[str, Any], ref: str) -> Any:
|
|||||||
for raw_part in pointer[1:].split("/"):
|
for raw_part in pointer[1:].split("/"):
|
||||||
part = raw_part.replace("~1", "/").replace("~0", "~")
|
part = raw_part.replace("~1", "/").replace("~0", "~")
|
||||||
if isinstance(current, dict):
|
if isinstance(current, dict):
|
||||||
current = current[part]
|
current = cast(dict[str, Any], current)[part]
|
||||||
elif isinstance(current, list):
|
elif isinstance(current, list):
|
||||||
current = current[int(part)]
|
current = cast(list[Any], current)[int(part)]
|
||||||
else:
|
else:
|
||||||
raise KeyError(part)
|
raise KeyError(part)
|
||||||
return current
|
return current
|
||||||
@ -345,14 +356,15 @@ def _rewrite_local_schema_refs(schema: dict[str, Any]) -> dict[str, Any]:
|
|||||||
|
|
||||||
def rewrite(value: Any) -> Any:
|
def rewrite(value: Any) -> Any:
|
||||||
if isinstance(value, list):
|
if isinstance(value, list):
|
||||||
return [rewrite(item) for item in value]
|
return [rewrite(item) for item in cast(list[Any], value)]
|
||||||
if not isinstance(value, dict):
|
if not isinstance(value, dict):
|
||||||
return value
|
return value
|
||||||
|
|
||||||
rewritten = dict(value)
|
rewritten = dict(cast(dict[str, Any], value))
|
||||||
ref = rewritten.get("$ref")
|
raw_ref = rewritten.get("$ref")
|
||||||
|
ref = raw_ref if isinstance(raw_ref, str) else None
|
||||||
is_rewritable_ref = False
|
is_rewritable_ref = False
|
||||||
if isinstance(ref, str) and not ref.startswith("#/$defs/"):
|
if ref is not None and not ref.startswith("#/$defs/"):
|
||||||
try:
|
try:
|
||||||
pointer = urllib.parse.unquote(ref[1:], errors="strict")
|
pointer = urllib.parse.unquote(ref[1:], errors="strict")
|
||||||
except (UnicodeDecodeError, ValueError):
|
except (UnicodeDecodeError, ValueError):
|
||||||
@ -362,6 +374,7 @@ def _rewrite_local_schema_refs(schema: dict[str, Any]) -> dict[str, Any]:
|
|||||||
not pointer or pointer.startswith("/")
|
not pointer or pointer.startswith("/")
|
||||||
)
|
)
|
||||||
if is_rewritable_ref:
|
if is_rewritable_ref:
|
||||||
|
assert ref is not None
|
||||||
name = rewritten_refs.get(ref)
|
name = rewritten_refs.get(ref)
|
||||||
if name is None:
|
if name is None:
|
||||||
try:
|
try:
|
||||||
@ -369,7 +382,6 @@ def _rewrite_local_schema_refs(schema: dict[str, Any]) -> dict[str, Any]:
|
|||||||
except (KeyError, IndexError, TypeError, UnicodeDecodeError, ValueError):
|
except (KeyError, IndexError, TypeError, UnicodeDecodeError, ValueError):
|
||||||
logger.warning("MCP tool schema contains an unresolved local $ref: {}", ref)
|
logger.warning("MCP tool schema contains an unresolved local $ref: {}", ref)
|
||||||
else:
|
else:
|
||||||
assert isinstance(ref, str)
|
|
||||||
name = f"ref_{hashlib.sha256(ref.encode()).hexdigest()[:12]}"
|
name = f"ref_{hashlib.sha256(ref.encode()).hexdigest()[:12]}"
|
||||||
existing_defs = schema.get("$defs")
|
existing_defs = schema.get("$defs")
|
||||||
while isinstance(existing_defs, dict) and name in existing_defs:
|
while isinstance(existing_defs, dict) and name in existing_defs:
|
||||||
@ -383,7 +395,7 @@ def _rewrite_local_schema_refs(schema: dict[str, Any]) -> dict[str, Any]:
|
|||||||
|
|
||||||
return {key: rewrite(item) for key, item in rewritten.items()}
|
return {key: rewrite(item) for key, item in rewritten.items()}
|
||||||
|
|
||||||
result = rewrite(schema)
|
result = cast(dict[str, Any], rewrite(schema))
|
||||||
if generated_defs:
|
if generated_defs:
|
||||||
existing_defs = result.get("$defs")
|
existing_defs = result.get("$defs")
|
||||||
result["$defs"] = {
|
result["$defs"] = {
|
||||||
@ -398,8 +410,9 @@ def _normalize_nullable_schema(schema: dict[str, Any]) -> dict[str, Any]:
|
|||||||
normalized = dict(schema)
|
normalized = dict(schema)
|
||||||
raw_type = normalized.get("type")
|
raw_type = normalized.get("type")
|
||||||
if isinstance(raw_type, list):
|
if isinstance(raw_type, list):
|
||||||
non_null = [item for item in raw_type if item != "null"]
|
type_values = cast(list[Any], raw_type)
|
||||||
if "null" in raw_type and len(non_null) == 1:
|
non_null = [item for item in type_values if item != "null"]
|
||||||
|
if "null" in type_values and len(non_null) == 1:
|
||||||
normalized["type"] = non_null[0]
|
normalized["type"] = non_null[0]
|
||||||
normalized["nullable"] = True
|
normalized["nullable"] = True
|
||||||
|
|
||||||
@ -413,19 +426,28 @@ def _normalize_nullable_schema(schema: dict[str, Any]) -> dict[str, Any]:
|
|||||||
normalized["nullable"] = True
|
normalized["nullable"] = True
|
||||||
break
|
break
|
||||||
|
|
||||||
if isinstance(normalized.get("properties"), dict):
|
properties = normalized.get("properties")
|
||||||
|
if isinstance(properties, dict):
|
||||||
|
property_schemas = cast(dict[str, Any], properties)
|
||||||
normalized["properties"] = {
|
normalized["properties"] = {
|
||||||
name: _normalize_nullable_schema(prop) if isinstance(prop, dict) else prop
|
name: (
|
||||||
for name, prop in normalized["properties"].items()
|
_normalize_nullable_schema(cast(dict[str, Any], prop))
|
||||||
|
if isinstance(prop, dict)
|
||||||
|
else prop
|
||||||
|
)
|
||||||
|
for name, prop in property_schemas.items()
|
||||||
}
|
}
|
||||||
if isinstance(normalized.get("items"), dict):
|
items = normalized.get("items")
|
||||||
normalized["items"] = _normalize_nullable_schema(normalized["items"])
|
if isinstance(items, dict):
|
||||||
if isinstance(normalized.get("$defs"), dict):
|
normalized["items"] = _normalize_nullable_schema(cast(dict[str, Any], items))
|
||||||
|
definitions = normalized.get("$defs")
|
||||||
|
if isinstance(definitions, dict):
|
||||||
|
definition_schemas = cast(dict[str, Any], definitions)
|
||||||
normalized["$defs"] = {
|
normalized["$defs"] = {
|
||||||
name: _normalize_nullable_schema(definition)
|
name: _normalize_nullable_schema(cast(dict[str, Any], definition))
|
||||||
if isinstance(definition, dict)
|
if isinstance(definition, dict)
|
||||||
else definition
|
else definition
|
||||||
for name, definition in normalized["$defs"].items()
|
for name, definition in definition_schemas.items()
|
||||||
}
|
}
|
||||||
|
|
||||||
if normalized.get("type") == "object":
|
if normalized.get("type") == "object":
|
||||||
@ -438,15 +460,19 @@ def _normalize_schema_for_openai(schema: Any) -> dict[str, Any]:
|
|||||||
"""Normalize MCP JSON Schema patterns for tool definitions."""
|
"""Normalize MCP JSON Schema patterns for tool definitions."""
|
||||||
if not isinstance(schema, dict):
|
if not isinstance(schema, dict):
|
||||||
return {"type": "object", "properties": {}}
|
return {"type": "object", "properties": {}}
|
||||||
return _normalize_nullable_schema(_rewrite_local_schema_refs(schema))
|
schema_mapping = cast(dict[str, Any], schema)
|
||||||
|
return _normalize_nullable_schema(_rewrite_local_schema_refs(schema_mapping))
|
||||||
|
|
||||||
|
|
||||||
class _MCPWrapperBase(Tool):
|
class _MCPWrapperBase(Tool):
|
||||||
"""Common reconnect handling for wrappers bound to one MCP server session."""
|
"""Common reconnect handling for wrappers bound to one MCP server session."""
|
||||||
|
|
||||||
_plugin_discoverable = False
|
_plugin_discoverable = False
|
||||||
|
_session: "ClientSession"
|
||||||
|
_server_name: str
|
||||||
|
_name: str
|
||||||
|
|
||||||
def _set_mcp_connection(self, session: Any, server_name: str) -> None:
|
def _set_mcp_connection(self, session: "ClientSession", server_name: str) -> None:
|
||||||
self._session = session
|
self._session = session
|
||||||
self._server_name = server_name
|
self._server_name = server_name
|
||||||
self._reconnect: _ReconnectCallback | None = None
|
self._reconnect: _ReconnectCallback | None = None
|
||||||
@ -500,9 +526,10 @@ def _image_block_data_url(block: Any, types: Any) -> str | None:
|
|||||||
if embedded_cls is not None and isinstance(block, embedded_cls):
|
if embedded_cls is not None and isinstance(block, embedded_cls):
|
||||||
resource = getattr(block, "resource", None)
|
resource = getattr(block, "resource", None)
|
||||||
if blob_cls is not None and isinstance(resource, blob_cls):
|
if blob_cls is not None and isinstance(resource, blob_cls):
|
||||||
mime = getattr(resource, "mimeType", None) or ""
|
blob_resource = cast(Any, resource)
|
||||||
|
mime = getattr(blob_resource, "mimeType", None) or ""
|
||||||
if isinstance(mime, str) and mime.startswith("image/"):
|
if isinstance(mime, str) and mime.startswith("image/"):
|
||||||
return f"data:{mime};base64,{resource.blob}"
|
return f"data:{mime};base64,{blob_resource.blob}"
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
@ -533,7 +560,13 @@ class MCPToolWrapper(_MCPWrapperBase):
|
|||||||
|
|
||||||
_plugin_discoverable = False
|
_plugin_discoverable = False
|
||||||
|
|
||||||
def __init__(self, session, server_name: str, tool_def, tool_timeout: int = 30):
|
def __init__(
|
||||||
|
self,
|
||||||
|
session: "ClientSession",
|
||||||
|
server_name: str,
|
||||||
|
tool_def: "MCPToolDefinition",
|
||||||
|
tool_timeout: int = 30,
|
||||||
|
):
|
||||||
self._set_mcp_connection(session, server_name)
|
self._set_mcp_connection(session, server_name)
|
||||||
self._original_name = tool_def.name
|
self._original_name = tool_def.name
|
||||||
self._name = _sanitize_mcp_tool_name(f"mcp_{server_name}_{tool_def.name}")
|
self._name = _sanitize_mcp_tool_name(f"mcp_{server_name}_{tool_def.name}")
|
||||||
@ -689,7 +722,13 @@ class MCPResourceWrapper(_MCPWrapperBase):
|
|||||||
|
|
||||||
_plugin_discoverable = False
|
_plugin_discoverable = False
|
||||||
|
|
||||||
def __init__(self, session, server_name: str, resource_def, resource_timeout: int = 30):
|
def __init__(
|
||||||
|
self,
|
||||||
|
session: "ClientSession",
|
||||||
|
server_name: str,
|
||||||
|
resource_def: "Resource",
|
||||||
|
resource_timeout: int = 30,
|
||||||
|
):
|
||||||
self._set_mcp_connection(session, server_name)
|
self._set_mcp_connection(session, server_name)
|
||||||
self._uri = resource_def.uri
|
self._uri = resource_def.uri
|
||||||
self._name = _sanitize_mcp_tool_name(f"mcp_{server_name}_resource_{resource_def.name}")
|
self._name = _sanitize_mcp_tool_name(f"mcp_{server_name}_resource_{resource_def.name}")
|
||||||
@ -775,7 +814,7 @@ class MCPResourceWrapper(_MCPWrapperBase):
|
|||||||
for block in result.contents:
|
for block in result.contents:
|
||||||
if isinstance(block, types.TextResourceContents):
|
if isinstance(block, types.TextResourceContents):
|
||||||
parts.append(block.text)
|
parts.append(block.text)
|
||||||
elif isinstance(block, types.BlobResourceContents):
|
elif isinstance(cast(object, block), types.BlobResourceContents):
|
||||||
parts.append(f"[Binary resource: {len(block.blob)} bytes]")
|
parts.append(f"[Binary resource: {len(block.blob)} bytes]")
|
||||||
else:
|
else:
|
||||||
parts.append(str(block))
|
parts.append(str(block))
|
||||||
@ -787,7 +826,13 @@ class MCPPromptWrapper(_MCPWrapperBase):
|
|||||||
|
|
||||||
_plugin_discoverable = False
|
_plugin_discoverable = False
|
||||||
|
|
||||||
def __init__(self, session, server_name: str, prompt_def, prompt_timeout: int = 30):
|
def __init__(
|
||||||
|
self,
|
||||||
|
session: "ClientSession",
|
||||||
|
server_name: str,
|
||||||
|
prompt_def: "Prompt",
|
||||||
|
prompt_timeout: int = 30,
|
||||||
|
):
|
||||||
self._set_mcp_connection(session, server_name)
|
self._set_mcp_connection(session, server_name)
|
||||||
self._prompt_name = prompt_def.name
|
self._prompt_name = prompt_def.name
|
||||||
self._name = _sanitize_mcp_tool_name(f"mcp_{server_name}_prompt_{prompt_def.name}")
|
self._name = _sanitize_mcp_tool_name(f"mcp_{server_name}_prompt_{prompt_def.name}")
|
||||||
@ -916,7 +961,7 @@ class MCPPromptWrapper(_MCPWrapperBase):
|
|||||||
|
|
||||||
|
|
||||||
async def connect_mcp_servers(
|
async def connect_mcp_servers(
|
||||||
mcp_servers: dict, registry: ToolRegistry
|
mcp_servers: "dict[str, MCPServerConfig]", registry: ToolRegistry
|
||||||
) -> dict[str, MCPConnection]:
|
) -> dict[str, MCPConnection]:
|
||||||
"""Connect to configured MCP servers and register their tools, resources, prompts.
|
"""Connect to configured MCP servers and register their tools, resources, prompts.
|
||||||
|
|
||||||
@ -929,7 +974,9 @@ async def connect_mcp_servers(
|
|||||||
from mcp.client.stdio import stdio_client
|
from mcp.client.stdio import stdio_client
|
||||||
from mcp.client.streamable_http import streamable_http_client
|
from mcp.client.streamable_http import streamable_http_client
|
||||||
|
|
||||||
async def open_single_server(name: str, cfg) -> tuple[str, AsyncExitStack | None]:
|
async def open_single_server(
|
||||||
|
name: str, cfg: "MCPServerConfig"
|
||||||
|
) -> tuple[str, AsyncExitStack | None]:
|
||||||
server_stack = AsyncExitStack()
|
server_stack = AsyncExitStack()
|
||||||
await server_stack.__aenter__()
|
await server_stack.__aenter__()
|
||||||
|
|
||||||
@ -1148,7 +1195,9 @@ async def connect_mcp_servers(
|
|||||||
await server_stack.aclose()
|
await server_stack.aclose()
|
||||||
return name, None
|
return name, None
|
||||||
|
|
||||||
async def connect_single_server(name: str, cfg) -> tuple[str, MCPConnection | None]:
|
async def connect_single_server(
|
||||||
|
name: str, cfg: "MCPServerConfig"
|
||||||
|
) -> tuple[str, MCPConnection | None]:
|
||||||
loop = asyncio.get_running_loop()
|
loop = asyncio.get_running_loop()
|
||||||
ready: asyncio.Future[bool] = loop.create_future()
|
ready: asyncio.Future[bool] = loop.create_future()
|
||||||
close_requested = asyncio.Event()
|
close_requested = asyncio.Event()
|
||||||
@ -1192,7 +1241,7 @@ async def connect_mcp_servers(
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.exception("MCP server '{}' connection failed: {}", name, e)
|
logger.exception("MCP server '{}' connection failed: {}", name, e)
|
||||||
continue
|
continue
|
||||||
if result is not None and result[1] is not None:
|
if result[1] is not None:
|
||||||
server_stacks[result[0]] = result[1]
|
server_stacks[result[0]] = result[1]
|
||||||
|
|
||||||
return server_stacks
|
return server_stacks
|
||||||
@ -1335,7 +1384,11 @@ async def reload_servers(state: Any, registry: ToolRegistry) -> dict[str, Any]:
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
async def request_mcp_reload(bus: Any, *, timeout: float = 15.0) -> dict[str, Any]:
|
async def request_mcp_reload(
|
||||||
|
bus: MessageBus,
|
||||||
|
*,
|
||||||
|
timeout: float = 15.0,
|
||||||
|
) -> dict[str, Any]:
|
||||||
"""Ask the running agent loop to reconcile live MCP connections."""
|
"""Ask the running agent loop to reconcile live MCP connections."""
|
||||||
loop = asyncio.get_running_loop()
|
loop = asyncio.get_running_loop()
|
||||||
ack: asyncio.Future[dict[str, Any]] = loop.create_future()
|
ack: asyncio.Future[dict[str, Any]] = loop.create_future()
|
||||||
@ -1359,7 +1412,7 @@ async def request_mcp_reload(bus: Any, *, timeout: float = 15.0) -> dict[str, An
|
|||||||
"message": "MCP hot reload timed out. Restart nanobot to pick up changes.",
|
"message": "MCP hot reload timed out. Restart nanobot to pick up changes.",
|
||||||
"requires_restart": True,
|
"requires_restart": True,
|
||||||
}
|
}
|
||||||
return result if isinstance(result, dict) else {
|
return result if isinstance(cast(object, result), dict) else {
|
||||||
"ok": False,
|
"ok": False,
|
||||||
"message": "MCP hot reload returned an unexpected response.",
|
"message": "MCP hot reload returned an unexpected response.",
|
||||||
"requires_restart": True,
|
"requires_restart": True,
|
||||||
@ -1367,7 +1420,7 @@ async def request_mcp_reload(bus: Any, *, timeout: float = 15.0) -> dict[str, An
|
|||||||
|
|
||||||
|
|
||||||
async def handle_runtime_control(state: Any, msg: InboundMessage, registry: ToolRegistry) -> bool:
|
async def handle_runtime_control(state: Any, msg: InboundMessage, registry: ToolRegistry) -> bool:
|
||||||
metadata = msg.metadata if isinstance(msg.metadata, dict) else {}
|
metadata = msg.metadata if isinstance(cast(object, msg.metadata), dict) else {}
|
||||||
control = metadata.get(INBOUND_META_RUNTIME_CONTROL)
|
control = metadata.get(INBOUND_META_RUNTIME_CONTROL)
|
||||||
if control != RUNTIME_CONTROL_MCP_RELOAD:
|
if control != RUNTIME_CONTROL_MCP_RELOAD:
|
||||||
return False
|
return False
|
||||||
@ -1384,7 +1437,7 @@ async def handle_runtime_control(state: Any, msg: InboundMessage, registry: Tool
|
|||||||
"error": str(exc),
|
"error": str(exc),
|
||||||
}
|
}
|
||||||
if isinstance(ack, asyncio.Future) and not ack.done():
|
if isinstance(ack, asyncio.Future) and not ack.done():
|
||||||
ack.set_result(result)
|
cast(asyncio.Future[dict[str, Any]], ack).set_result(result)
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -1,13 +1,15 @@
|
|||||||
"""Message tool for sending messages to users."""
|
"""Message tool for sending messages to users."""
|
||||||
|
|
||||||
from contextvars import ContextVar
|
# pyright: reportIncompatibleMethodOverride=false
|
||||||
|
|
||||||
|
from contextvars import ContextVar, Token
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any, Awaitable, Callable
|
from typing import Any, Awaitable, Callable, cast
|
||||||
|
|
||||||
from loguru import logger
|
from loguru import logger
|
||||||
|
|
||||||
from nanobot.agent.tools.base import Tool, ToolResult, tool_parameters
|
from nanobot.agent.tools.base import Tool, ToolResult, tool_parameters
|
||||||
from nanobot.agent.tools.context import current_request_context
|
from nanobot.agent.tools.context import ToolContext, current_request_context
|
||||||
from nanobot.agent.tools.path_utils import resolve_workspace_path
|
from nanobot.agent.tools.path_utils import resolve_workspace_path
|
||||||
from nanobot.agent.tools.schema import ArraySchema, StringSchema, tool_parameters_schema
|
from nanobot.agent.tools.schema import ArraySchema, StringSchema, tool_parameters_schema
|
||||||
from nanobot.bus.events import OutboundMessage
|
from nanobot.bus.events import OutboundMessage
|
||||||
@ -73,7 +75,7 @@ class MessageTool(Tool):
|
|||||||
)
|
)
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def create(cls, ctx: Any) -> Tool:
|
def create(cls, ctx: ToolContext) -> Tool:
|
||||||
send_callback = ctx.bus.publish_outbound if ctx.bus else None
|
send_callback = ctx.bus.publish_outbound if ctx.bus else None
|
||||||
return cls(
|
return cls(
|
||||||
send_callback=send_callback,
|
send_callback=send_callback,
|
||||||
@ -89,11 +91,11 @@ class MessageTool(Tool):
|
|||||||
"""Reset per-turn send tracking."""
|
"""Reset per-turn send tracking."""
|
||||||
self._sent_in_turn = False
|
self._sent_in_turn = False
|
||||||
|
|
||||||
def set_suppress_delivery(self, active: bool):
|
def set_suppress_delivery(self, active: bool) -> Token[bool]:
|
||||||
"""Acknowledge but don't deliver tool sends (heartbeat internal check)."""
|
"""Acknowledge but don't deliver tool sends (heartbeat internal check)."""
|
||||||
return self._suppress_delivery_var.set(active)
|
return self._suppress_delivery_var.set(active)
|
||||||
|
|
||||||
def reset_suppress_delivery(self, token) -> None:
|
def reset_suppress_delivery(self, token: Token[bool]) -> None:
|
||||||
"""Restore previous delivery-suppression state."""
|
"""Restore previous delivery-suppression state."""
|
||||||
self._suppress_delivery_var.reset(token)
|
self._suppress_delivery_var.reset(token)
|
||||||
|
|
||||||
@ -148,19 +150,23 @@ class MessageTool(Tool):
|
|||||||
chat_id: str | None = None,
|
chat_id: str | None = None,
|
||||||
message_id: str | None = None,
|
message_id: str | None = None,
|
||||||
media: list[str] | None = None,
|
media: list[str] | None = None,
|
||||||
buttons: list[list[str]] | None = None,
|
buttons: Any = None,
|
||||||
**kwargs: Any,
|
**kwargs: Any,
|
||||||
) -> str:
|
) -> str: # pyright: ignore[reportIncompatibleMethodOverride]
|
||||||
from nanobot.utils.helpers import strip_think
|
from nanobot.utils.helpers import strip_think
|
||||||
|
|
||||||
content = strip_think(content)
|
content = strip_think(content)
|
||||||
|
|
||||||
|
button_rows: list[list[str]] | None = None
|
||||||
if buttons is not None:
|
if buttons is not None:
|
||||||
if not isinstance(buttons, list) or any(
|
raw_buttons = cast(list[Any], buttons) if isinstance(buttons, list) else None
|
||||||
not isinstance(row, list) or any(not isinstance(label, str) for label in row)
|
if raw_buttons is None or any(
|
||||||
for row in buttons
|
not isinstance(row, list)
|
||||||
|
or any(not isinstance(label, str) for label in cast(list[Any], row))
|
||||||
|
for row in raw_buttons
|
||||||
):
|
):
|
||||||
return ToolResult.error("Error: buttons must be a list of list of strings")
|
return ToolResult.error("Error: buttons must be a list of list of strings")
|
||||||
|
button_rows = cast(list[list[str]], raw_buttons)
|
||||||
request_ctx = current_request_context()
|
request_ctx = current_request_context()
|
||||||
default_channel = (
|
default_channel = (
|
||||||
request_ctx.channel if request_ctx is not None else self._fallback_channel
|
request_ctx.channel if request_ctx is not None else self._fallback_channel
|
||||||
@ -228,7 +234,7 @@ class MessageTool(Tool):
|
|||||||
chat_id=chat_id,
|
chat_id=chat_id,
|
||||||
content=content,
|
content=content,
|
||||||
media=media or [],
|
media=media or [],
|
||||||
buttons=buttons or [],
|
buttons=button_rows or [],
|
||||||
metadata=metadata,
|
metadata=metadata,
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -241,7 +247,11 @@ class MessageTool(Tool):
|
|||||||
if channel == default_channel and chat_id == default_chat_id:
|
if channel == default_channel and chat_id == default_chat_id:
|
||||||
self._sent_in_turn = True
|
self._sent_in_turn = True
|
||||||
media_info = f" with {len(media)} attachments" if media else ""
|
media_info = f" with {len(media)} attachments" if media else ""
|
||||||
button_info = f" with {sum(len(row) for row in buttons)} button(s)" if buttons else ""
|
button_info = (
|
||||||
|
f" with {sum(len(row) for row in button_rows)} button(s)"
|
||||||
|
if button_rows
|
||||||
|
else ""
|
||||||
|
)
|
||||||
return f"Message sent to {channel}:{chat_id}{media_info}{button_info}"
|
return f"Message sent to {channel}:{chat_id}{media_info}{button_info}"
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return ToolResult.error(f"Error sending message: {str(e)}")
|
return ToolResult.error(f"Error sending message: {str(e)}")
|
||||||
|
|||||||
@ -3,7 +3,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import json
|
import json
|
||||||
from typing import TYPE_CHECKING, Any
|
from typing import TYPE_CHECKING, Any, cast
|
||||||
|
|
||||||
from nanobot.agent.tools.base import Tool, ToolResult
|
from nanobot.agent.tools.base import Tool, ToolResult
|
||||||
from nanobot.agent.tools.context import ContextAware, current_request_context
|
from nanobot.agent.tools.context import ContextAware, current_request_context
|
||||||
@ -77,7 +77,7 @@ class ToolRegistry:
|
|||||||
"""Extract a normalized tool name from either OpenAI or flat schemas."""
|
"""Extract a normalized tool name from either OpenAI or flat schemas."""
|
||||||
fn = schema.get("function")
|
fn = schema.get("function")
|
||||||
if isinstance(fn, dict):
|
if isinstance(fn, dict):
|
||||||
name = fn.get("name")
|
name = cast(dict[str, Any], fn).get("name")
|
||||||
if isinstance(name, str):
|
if isinstance(name, str):
|
||||||
return name
|
return name
|
||||||
name = schema.get("name")
|
name = schema.get("name")
|
||||||
@ -140,7 +140,7 @@ class ToolRegistry:
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
cast_params = tool.cast_params(params)
|
cast_params = tool.cast_params(cast(dict[str, Any], params))
|
||||||
errors = tool.validate_params(cast_params)
|
errors = tool.validate_params(cast_params)
|
||||||
if errors:
|
if errors:
|
||||||
return tool, cast_params, (
|
return tool, cast_params, (
|
||||||
@ -176,12 +176,15 @@ class ToolRegistry:
|
|||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def _unwrap_arguments_payload(cls, tool: Tool, params: Any) -> Any:
|
def _unwrap_arguments_payload(cls, tool: Tool, params: Any) -> Any:
|
||||||
if not isinstance(params, dict) or set(params) != {"arguments"}:
|
if not isinstance(params, dict):
|
||||||
return params
|
return params
|
||||||
|
arguments_payload = cast(dict[str, Any], params)
|
||||||
|
if set(arguments_payload) != {"arguments"}:
|
||||||
|
return arguments_payload
|
||||||
properties = (tool.parameters or {}).get("properties", {})
|
properties = (tool.parameters or {}).get("properties", {})
|
||||||
if isinstance(properties, dict) and "arguments" in properties:
|
if isinstance(properties, dict) and "arguments" in properties:
|
||||||
return params
|
return arguments_payload
|
||||||
return cls._coerce_argument_value(params.get("arguments"))
|
return cls._coerce_argument_value(arguments_payload.get("arguments"))
|
||||||
|
|
||||||
async def execute(self, name: str, params: Any) -> Any:
|
async def execute(self, name: str, params: Any) -> Any:
|
||||||
"""Execute a tool by name with given parameters."""
|
"""Execute a tool by name with given parameters."""
|
||||||
|
|||||||
@ -1,6 +1,15 @@
|
|||||||
"""RuntimeState protocol: agent loop state exposed to MyTool."""
|
"""RuntimeState protocol: agent loop state exposed to MyTool."""
|
||||||
|
|
||||||
from typing import Any, Protocol
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import TYPE_CHECKING, Any, Protocol
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from nanobot.agent.subagent import SubagentManager
|
||||||
|
from nanobot.agent.tools.shell import ExecToolConfig
|
||||||
|
from nanobot.agent.tools.web import WebToolsConfig
|
||||||
|
from nanobot.utils.llm_runtime import LLMRuntime
|
||||||
|
|
||||||
|
|
||||||
class RuntimeState(Protocol):
|
class RuntimeState(Protocol):
|
||||||
@ -25,7 +34,7 @@ class RuntimeState(Protocol):
|
|||||||
def tool_names(self) -> list[str]: ...
|
def tool_names(self) -> list[str]: ...
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def workspace(self) -> str: ...
|
def workspace(self) -> Path: ...
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def provider_retry_mode(self) -> str: ...
|
def provider_retry_mode(self) -> str: ...
|
||||||
@ -37,34 +46,31 @@ class RuntimeState(Protocol):
|
|||||||
def context_window_tokens(self) -> int: ...
|
def context_window_tokens(self) -> int: ...
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def web_config(self) -> Any: ...
|
def web_config(self) -> WebToolsConfig: ...
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def exec_config(self) -> Any: ...
|
def exec_config(self) -> ExecToolConfig: ...
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def workspace_sandbox(self) -> Any: ...
|
def subagents(self) -> SubagentManager: ...
|
||||||
|
|
||||||
@property
|
|
||||||
def subagents(self) -> Any: ...
|
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def _runtime_vars(self) -> dict[str, Any]: ...
|
def _runtime_vars(self) -> dict[str, Any]: ...
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def _last_usage(self) -> Any: ...
|
def _last_usage(self) -> dict[str, int]: ...
|
||||||
|
|
||||||
def _sync_subagent_runtime_limits(self) -> None: ...
|
def _sync_subagent_runtime_limits(self) -> None: ...
|
||||||
|
|
||||||
def set_runtime_model(self, model: str) -> Any: ...
|
def set_runtime_model(self, model: str) -> LLMRuntime: ...
|
||||||
|
|
||||||
def set_runtime_context_window(self, context_window_tokens: int) -> Any: ...
|
def set_runtime_context_window(self, context_window_tokens: int) -> LLMRuntime: ...
|
||||||
|
|
||||||
def set_session_model_preset(
|
def set_session_model_preset(
|
||||||
self,
|
self,
|
||||||
session_key: str,
|
session_key: str,
|
||||||
name: str,
|
name: str,
|
||||||
) -> Any: ...
|
) -> LLMRuntime: ...
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def model_preset(self) -> str | None: ...
|
def model_preset(self) -> str | None: ...
|
||||||
|
|||||||
@ -1,5 +1,7 @@
|
|||||||
"""Search tools: file discovery and grep."""
|
"""Search tools: file discovery and grep."""
|
||||||
|
|
||||||
|
# pyright: reportIncompatibleMethodOverride=false, reportPrivateUsage=false
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import fnmatch
|
import fnmatch
|
||||||
|
|||||||
@ -1,10 +1,14 @@
|
|||||||
"""MyTool: runtime state inspection and configuration for the agent loop."""
|
"""MyTool: runtime state inspection and configuration for the agent loop."""
|
||||||
|
|
||||||
|
# RuntimeState intentionally exposes a narrow set of AgentLoop internals to
|
||||||
|
# this manually registered tool. Tool.execute accepts heterogeneous schemas.
|
||||||
|
# pyright: reportPrivateUsage=false, reportIncompatibleMethodOverride=false
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import time
|
import time
|
||||||
from collections.abc import Mapping
|
from collections.abc import Mapping
|
||||||
from typing import TYPE_CHECKING, Any
|
from typing import TYPE_CHECKING, Any, TypeGuard, cast
|
||||||
|
|
||||||
from loguru import logger
|
from loguru import logger
|
||||||
|
|
||||||
@ -15,6 +19,7 @@ from nanobot.config_base import Base
|
|||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from nanobot.agent.subagent import SubagentStatus
|
from nanobot.agent.subagent import SubagentStatus
|
||||||
|
from nanobot.agent.tools.context import ToolContext
|
||||||
|
|
||||||
|
|
||||||
class MyToolConfig(Base):
|
class MyToolConfig(Base):
|
||||||
@ -36,7 +41,7 @@ def _has_real_attr(obj: Any, key: str) -> bool:
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
def _is_subagent_status(value: Any) -> bool:
|
def _is_subagent_status(value: object) -> TypeGuard[SubagentStatus]:
|
||||||
from nanobot.agent.subagent import SubagentStatus
|
from nanobot.agent.subagent import SubagentStatus
|
||||||
|
|
||||||
return isinstance(value, SubagentStatus)
|
return isinstance(value, SubagentStatus)
|
||||||
@ -53,7 +58,7 @@ class MyTool(Tool):
|
|||||||
return MyToolConfig
|
return MyToolConfig
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def enabled(cls, ctx: Any) -> bool:
|
def enabled(cls, ctx: ToolContext) -> bool:
|
||||||
return ctx.config.my.enable
|
return ctx.config.my.enable
|
||||||
|
|
||||||
BLOCKED = frozenset({
|
BLOCKED = frozenset({
|
||||||
@ -205,7 +210,7 @@ class MyTool(Tool):
|
|||||||
|
|
||||||
def _resolve_path(self, path: str) -> tuple[Any, str | None]:
|
def _resolve_path(self, path: str) -> tuple[Any, str | None]:
|
||||||
parts = path.split(".")
|
parts = path.split(".")
|
||||||
obj = self._runtime_state
|
obj: Any = self._runtime_state
|
||||||
for part in parts:
|
for part in parts:
|
||||||
if part in self._DENIED_ATTRS or part.startswith("__"):
|
if part in self._DENIED_ATTRS or part.startswith("__"):
|
||||||
return None, f"'{part}' is not accessible"
|
return None, f"'{part}' is not accessible"
|
||||||
@ -215,8 +220,9 @@ class MyTool(Tool):
|
|||||||
return None, f"'{part}' is not accessible"
|
return None, f"'{part}' is not accessible"
|
||||||
try:
|
try:
|
||||||
if isinstance(obj, Mapping):
|
if isinstance(obj, Mapping):
|
||||||
if part in obj:
|
mapping = cast(Mapping[str, Any], obj)
|
||||||
obj = obj[part]
|
if part in mapping:
|
||||||
|
obj = mapping[part]
|
||||||
else:
|
else:
|
||||||
return None, f"'{part}' not found in mapping"
|
return None, f"'{part}' not found in mapping"
|
||||||
else:
|
else:
|
||||||
@ -259,28 +265,40 @@ class MyTool(Tool):
|
|||||||
detail = MyTool._format_status(val, " ")
|
detail = MyTool._format_status(val, " ")
|
||||||
return f"{header}\n task: {val.task_description}\n{detail}"
|
return f"{header}\n task: {val.task_description}\n{detail}"
|
||||||
# SubagentManager: delegate to its _task_statuses dict
|
# SubagentManager: delegate to its _task_statuses dict
|
||||||
if hasattr(val, "_task_statuses") and isinstance(val._task_statuses, dict):
|
task_statuses = getattr(val, "_task_statuses", None)
|
||||||
return MyTool._format_value(val._task_statuses, key)
|
if isinstance(task_statuses, dict):
|
||||||
if isinstance(val, Mapping) and val and _is_subagent_status(next(iter(val.values()))):
|
return MyTool._format_value(task_statuses, key)
|
||||||
|
if isinstance(val, Mapping):
|
||||||
|
mapping = cast(Mapping[object, object], val)
|
||||||
|
else:
|
||||||
|
mapping = None
|
||||||
|
if (
|
||||||
|
mapping
|
||||||
|
and _is_subagent_status(next(iter(mapping.values())))
|
||||||
|
):
|
||||||
|
status_mapping: Mapping[object, SubagentStatus] = cast(Any, mapping)
|
||||||
prefix = f"{key}: " if key else ""
|
prefix = f"{key}: " if key else ""
|
||||||
lines = [f"{prefix}{len(val)} subagent(s):"]
|
lines = [f"{prefix}{len(status_mapping)} subagent(s):"]
|
||||||
for tid, st in val.items():
|
for tid, st in status_mapping.items():
|
||||||
detail = MyTool._format_status(st, " ")
|
detail = MyTool._format_status(st, " ")
|
||||||
lines.append(f" [{tid}] '{st.label}'\n{detail}")
|
lines.append(f" [{tid}] '{st.label}'\n{detail}")
|
||||||
return "\n".join(lines)
|
return "\n".join(lines)
|
||||||
if hasattr(val, "tool_names"):
|
dynamic_value = cast(Any, val)
|
||||||
return f"tools: {len(val.tool_names)} registered — {val.tool_names}"
|
if hasattr(dynamic_value, "tool_names"):
|
||||||
|
tool_names: Any = getattr(dynamic_value, "tool_names")
|
||||||
|
return f"tools: {len(tool_names)} registered — {tool_names}"
|
||||||
# Scalar types — repr is fine
|
# Scalar types — repr is fine
|
||||||
if isinstance(val, (str, int, float, bool, type(None))):
|
if isinstance(val, (str, int, float, bool, type(None))):
|
||||||
r = repr(val)
|
r = repr(val)
|
||||||
return f"{key}: {r}" if key else r
|
return f"{key}: {r}" if key else r
|
||||||
# Mapping — small: show content; large: show keys for dot-path navigation
|
# Mapping — small: show content; large: show keys for dot-path navigation
|
||||||
if isinstance(val, Mapping):
|
if isinstance(val, Mapping):
|
||||||
ks = list(val.keys())
|
value_mapping = cast(Mapping[object, object], val)
|
||||||
|
ks = list(value_mapping.keys())
|
||||||
if not ks:
|
if not ks:
|
||||||
return f"{key}: {{}}" if key else "{}"
|
return f"{key}: {{}}" if key else "{}"
|
||||||
if len(ks) <= 5:
|
if len(ks) <= 5:
|
||||||
r = repr(val)
|
r = repr(value_mapping)
|
||||||
if len(r) <= 200:
|
if len(r) <= 200:
|
||||||
return f"{key}: {r}" if key else r
|
return f"{key}: {r}" if key else r
|
||||||
preview = ", ".join(str(k) for k in ks[:15])
|
preview = ", ".join(str(k) for k in ks[:15])
|
||||||
@ -288,18 +306,20 @@ class MyTool(Tool):
|
|||||||
return f"{key}: {{{preview}{suffix}}}" if key else f"{{{preview}{suffix}}}"
|
return f"{key}: {{{preview}{suffix}}}" if key else f"{{{preview}{suffix}}}"
|
||||||
# List/tuple — count for large, repr for small
|
# List/tuple — count for large, repr for small
|
||||||
if isinstance(val, (list, tuple)):
|
if isinstance(val, (list, tuple)):
|
||||||
if len(val) > 20:
|
sequence = cast(list[object] | tuple[object, ...], val)
|
||||||
return f"{key}: [{len(val)} items]" if key else f"[{len(val)} items]"
|
if len(sequence) > 20:
|
||||||
r = repr(val)
|
return f"{key}: [{len(sequence)} items]" if key else f"[{len(sequence)} items]"
|
||||||
|
r = repr(sequence)
|
||||||
return f"{key}: {r}" if key else r
|
return f"{key}: {r}" if key else r
|
||||||
# Complex object — small Pydantic models: show values; others: show field names for navigation
|
# Complex object — small Pydantic models: show values; others: show field names for navigation
|
||||||
cls_name = type(val).__name__
|
value_type = type(cast(object, val))
|
||||||
model_fields = getattr(type(val), "model_fields", None)
|
cls_name = value_type.__name__
|
||||||
if model_fields:
|
model_fields = cast(object, getattr(value_type, "model_fields", None))
|
||||||
fields = list(model_fields.keys())
|
if isinstance(model_fields, Mapping) and model_fields:
|
||||||
|
fields = list(cast(Mapping[str, object], model_fields).keys())
|
||||||
if len(fields) <= 8:
|
if len(fields) <= 8:
|
||||||
# Small config objects: show field=value pairs
|
# Small config objects: show field=value pairs
|
||||||
pairs = []
|
pairs: list[str] = []
|
||||||
for f in fields:
|
for f in fields:
|
||||||
fv = getattr(val, f, "?")
|
fv = getattr(val, f, "?")
|
||||||
if MyTool._is_sensitive_field_name(f):
|
if MyTool._is_sensitive_field_name(f):
|
||||||
@ -311,7 +331,8 @@ class MyTool(Tool):
|
|||||||
preview = ", ".join(pairs)
|
preview = ", ".join(pairs)
|
||||||
return f"{key}: {preview}" if key else preview
|
return f"{key}: {preview}" if key else preview
|
||||||
else:
|
else:
|
||||||
fields = [a for a in getattr(val, "__dict__", {}) if not a.startswith("__")]
|
attributes = cast(dict[str, Any], getattr(val, "__dict__", {}))
|
||||||
|
fields = [name for name in attributes if not name.startswith("__")]
|
||||||
if fields:
|
if fields:
|
||||||
preview = ", ".join(str(f) for f in fields[:20])
|
preview = ", ".join(str(f) for f in fields[:20])
|
||||||
suffix = ", ..." if len(fields) > 20 else ""
|
suffix = ", ..." if len(fields) > 20 else ""
|
||||||
@ -417,6 +438,7 @@ class MyTool(Tool):
|
|||||||
def _modify(self, key: str | None, value: Any) -> str:
|
def _modify(self, key: str | None, value: Any) -> str:
|
||||||
if err := self._validate_key(key):
|
if err := self._validate_key(key):
|
||||||
return err
|
return err
|
||||||
|
key = cast(str, key)
|
||||||
top = key.split(".")[0]
|
top = key.split(".")[0]
|
||||||
if top in self.BLOCKED or top in self._DENIED_ATTRS or top.startswith("__") or top.lower() in self._SENSITIVE_NAMES:
|
if top in self.BLOCKED or top in self._DENIED_ATTRS or top.startswith("__") or top.lower() in self._SENSITIVE_NAMES:
|
||||||
self._audit("modify", f"BLOCKED {key}")
|
self._audit("modify", f"BLOCKED {key}")
|
||||||
@ -478,7 +500,7 @@ class MyTool(Tool):
|
|||||||
|
|
||||||
def _modify_restricted(self, key: str, value: Any) -> str:
|
def _modify_restricted(self, key: str, value: Any) -> str:
|
||||||
spec = self.RESTRICTED[key]
|
spec = self.RESTRICTED[key]
|
||||||
expected = spec["type"]
|
expected = cast(type[Any], spec["type"])
|
||||||
if expected is int and isinstance(value, bool):
|
if expected is int and isinstance(value, bool):
|
||||||
return ToolResult.error(f"Error: '{key}' must be {expected.__name__}, got bool")
|
return ToolResult.error(f"Error: '{key}' must be {expected.__name__}, got bool")
|
||||||
if not isinstance(value, expected):
|
if not isinstance(value, expected):
|
||||||
@ -499,9 +521,9 @@ class MyTool(Tool):
|
|||||||
"during an active session; use a configured model_preset"
|
"during an active session; use a configured model_preset"
|
||||||
)
|
)
|
||||||
if key == "model":
|
if key == "model":
|
||||||
self._runtime_state.set_runtime_model(value)
|
self._runtime_state.set_runtime_model(cast(str, value))
|
||||||
elif key == "context_window_tokens":
|
elif key == "context_window_tokens":
|
||||||
self._runtime_state.set_runtime_context_window(value)
|
self._runtime_state.set_runtime_context_window(cast(int, value))
|
||||||
else:
|
else:
|
||||||
setattr(self._runtime_state, key, value)
|
setattr(self._runtime_state, key, value)
|
||||||
if key == "max_iterations" and hasattr(
|
if key == "max_iterations" and hasattr(
|
||||||
@ -516,7 +538,8 @@ class MyTool(Tool):
|
|||||||
if _has_real_attr(self._runtime_state, key):
|
if _has_real_attr(self._runtime_state, key):
|
||||||
old = getattr(self._runtime_state, key)
|
old = getattr(self._runtime_state, key)
|
||||||
if isinstance(old, (str, int, float, bool)):
|
if isinstance(old, (str, int, float, bool)):
|
||||||
old_t, new_t = type(old), type(value)
|
old_t: type[Any] = type(old)
|
||||||
|
new_t = cast(type[Any], type(value))
|
||||||
if old_t is float and new_t is int:
|
if old_t is float and new_t is int:
|
||||||
pass # int → float coercion allowed
|
pass # int → float coercion allowed
|
||||||
elif old_t is not new_t:
|
elif old_t is not new_t:
|
||||||
@ -555,12 +578,12 @@ class MyTool(Tool):
|
|||||||
if isinstance(value, (str, int, float, bool, type(None))):
|
if isinstance(value, (str, int, float, bool, type(None))):
|
||||||
return None
|
return None
|
||||||
if isinstance(value, list):
|
if isinstance(value, list):
|
||||||
for i, item in enumerate(value):
|
for i, item in enumerate(cast(list[Any], value)):
|
||||||
if err := cls._validate_json_safe(item, depth + 1):
|
if err := cls._validate_json_safe(item, depth + 1):
|
||||||
return f"list[{i}] contains {err}"
|
return f"list[{i}] contains {err}"
|
||||||
return None
|
return None
|
||||||
if isinstance(value, dict):
|
if isinstance(value, dict):
|
||||||
for k, v in value.items():
|
for k, v in cast(dict[Any, Any], value).items():
|
||||||
if not isinstance(k, str):
|
if not isinstance(k, str):
|
||||||
return f"dict key must be str, got {type(k).__name__}"
|
return f"dict key must be str, got {type(k).__name__}"
|
||||||
if err := cls._validate_json_safe(v, depth + 1):
|
if err := cls._validate_json_safe(v, depth + 1):
|
||||||
|
|||||||
@ -18,13 +18,14 @@ from loguru import logger
|
|||||||
from pydantic import Field
|
from pydantic import Field
|
||||||
|
|
||||||
from nanobot.agent.tools.base import Tool, ToolResult, tool_parameters
|
from nanobot.agent.tools.base import Tool, ToolResult, tool_parameters
|
||||||
from nanobot.agent.tools.context import current_request_session_key
|
from nanobot.agent.tools.context import ToolContext, current_request_session_key
|
||||||
from nanobot.agent.tools.exec_session import (
|
from nanobot.agent.tools.exec_session import (
|
||||||
DEFAULT_EXEC_SESSION_MANAGER,
|
DEFAULT_EXEC_SESSION_MANAGER,
|
||||||
DEFAULT_MAX_OUTPUT_CHARS,
|
DEFAULT_MAX_OUTPUT_CHARS,
|
||||||
DEFAULT_YIELD_MS,
|
DEFAULT_YIELD_MS,
|
||||||
MAX_OUTPUT_CHARS,
|
MAX_OUTPUT_CHARS,
|
||||||
MAX_YIELD_MS,
|
MAX_YIELD_MS,
|
||||||
|
ExecSessionManager,
|
||||||
clamp_session_int,
|
clamp_session_int,
|
||||||
format_session_poll,
|
format_session_poll,
|
||||||
)
|
)
|
||||||
@ -174,11 +175,11 @@ class ExecTool(Tool):
|
|||||||
return ExecToolConfig
|
return ExecToolConfig
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def enabled(cls, ctx: Any) -> bool:
|
def enabled(cls, ctx: ToolContext) -> bool:
|
||||||
return ctx.config.exec.enable
|
return ctx.config.exec.enable
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def create(cls, ctx: Any) -> Tool:
|
def create(cls, ctx: ToolContext) -> Tool:
|
||||||
cfg = ctx.config.exec
|
cfg = ctx.config.exec
|
||||||
return cls(
|
return cls(
|
||||||
working_dir=ctx.workspace,
|
working_dir=ctx.workspace,
|
||||||
@ -193,7 +194,7 @@ class ExecTool(Tool):
|
|||||||
allowed_env_keys=cfg.allowed_env_keys,
|
allowed_env_keys=cfg.allowed_env_keys,
|
||||||
allow_patterns=cfg.allow_patterns,
|
allow_patterns=cfg.allow_patterns,
|
||||||
deny_patterns=cfg.deny_patterns,
|
deny_patterns=cfg.deny_patterns,
|
||||||
session_manager=getattr(ctx, "exec_session_manager", None),
|
session_manager=ctx.exec_session_manager,
|
||||||
)
|
)
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
@ -211,7 +212,7 @@ class ExecTool(Tool):
|
|||||||
sandbox_ro_binds: list[str] | None = None,
|
sandbox_ro_binds: list[str] | None = None,
|
||||||
sandbox_rw_binds: list[str] | None = None,
|
sandbox_rw_binds: list[str] | None = None,
|
||||||
allowed_env_keys: list[str] | None = None,
|
allowed_env_keys: list[str] | None = None,
|
||||||
session_manager: Any | None = None,
|
session_manager: ExecSessionManager | None = None,
|
||||||
):
|
):
|
||||||
self.timeout = timeout
|
self.timeout = timeout
|
||||||
self.working_dir = working_dir
|
self.working_dir = working_dir
|
||||||
@ -344,7 +345,7 @@ class ExecTool(Tool):
|
|||||||
# misses it, leaving a zombie.
|
# misses it, leaving a zombie.
|
||||||
_reap_pid(process.pid)
|
_reap_pid(process.pid)
|
||||||
|
|
||||||
output_parts = []
|
output_parts: list[str] = []
|
||||||
|
|
||||||
if stdout:
|
if stdout:
|
||||||
output_parts.append(stdout.decode("utf-8", errors="replace"))
|
output_parts.append(stdout.decode("utf-8", errors="replace"))
|
||||||
@ -504,7 +505,7 @@ class ExecTool(Tool):
|
|||||||
)
|
)
|
||||||
|
|
||||||
def _compose_path(self, current_path: str) -> str:
|
def _compose_path(self, current_path: str) -> str:
|
||||||
parts = []
|
parts: list[str] = []
|
||||||
if self.path_prepend:
|
if self.path_prepend:
|
||||||
parts.append(self.path_prepend)
|
parts.append(self.path_prepend)
|
||||||
if current_path:
|
if current_path:
|
||||||
@ -514,7 +515,7 @@ class ExecTool(Tool):
|
|||||||
return os.pathsep.join(parts)
|
return os.pathsep.join(parts)
|
||||||
|
|
||||||
def _wrap_path_export(self, command: str, env: dict[str, str]) -> str:
|
def _wrap_path_export(self, command: str, env: dict[str, str]) -> str:
|
||||||
segments = []
|
segments: list[str] = []
|
||||||
if self.path_prepend:
|
if self.path_prepend:
|
||||||
env["NANOBOT_PATH_PREPEND"] = self.path_prepend
|
env["NANOBOT_PATH_PREPEND"] = self.path_prepend
|
||||||
segments.append("$NANOBOT_PATH_PREPEND")
|
segments.append("$NANOBOT_PATH_PREPEND")
|
||||||
@ -568,11 +569,21 @@ class ExecTool(Tool):
|
|||||||
env=env,
|
env=env,
|
||||||
)
|
)
|
||||||
shell_program = shell_program or shutil.which("bash") or "/bin/bash"
|
shell_program = shell_program or shutil.which("bash") or "/bin/bash"
|
||||||
args = [shell_program]
|
args: list[str] = [shell_program]
|
||||||
shell_name = Path(shell_program).name.lower()
|
shell_name = Path(shell_program).name.lower()
|
||||||
if login and shell_name in {"bash", "bash.exe", "zsh", "zsh.exe"}:
|
if login and shell_name in {"bash", "bash.exe", "zsh", "zsh.exe"}:
|
||||||
args.append("-l")
|
args.append("-l")
|
||||||
args.extend(["-c", command])
|
args.extend(["-c", command])
|
||||||
|
if process_tree:
|
||||||
|
return await asyncio.create_subprocess_exec(
|
||||||
|
*args,
|
||||||
|
stdin=stdin,
|
||||||
|
stdout=asyncio.subprocess.PIPE,
|
||||||
|
stderr=asyncio.subprocess.PIPE,
|
||||||
|
cwd=cwd,
|
||||||
|
env=env,
|
||||||
|
start_new_session=True,
|
||||||
|
)
|
||||||
return await asyncio.create_subprocess_exec(
|
return await asyncio.create_subprocess_exec(
|
||||||
*args,
|
*args,
|
||||||
stdin=stdin,
|
stdin=stdin,
|
||||||
@ -580,7 +591,6 @@ class ExecTool(Tool):
|
|||||||
stderr=asyncio.subprocess.PIPE,
|
stderr=asyncio.subprocess.PIPE,
|
||||||
cwd=cwd,
|
cwd=cwd,
|
||||||
env=env,
|
env=env,
|
||||||
**({"start_new_session": True} if process_tree else {}),
|
|
||||||
)
|
)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
|
|||||||
@ -1,5 +1,7 @@
|
|||||||
"""Spawn tool for creating background subagents."""
|
"""Spawn tool for creating background subagents."""
|
||||||
|
|
||||||
|
# pyright: reportIncompatibleMethodOverride=false
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from typing import TYPE_CHECKING, Any
|
from typing import TYPE_CHECKING, Any
|
||||||
@ -16,6 +18,7 @@ from nanobot.security.workspace_access import current_workspace_scope
|
|||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from nanobot.agent.subagent import SubagentManager
|
from nanobot.agent.subagent import SubagentManager
|
||||||
|
from nanobot.agent.tools.context import ToolContext
|
||||||
|
|
||||||
|
|
||||||
@tool_parameters(
|
@tool_parameters(
|
||||||
@ -49,8 +52,11 @@ class SpawnTool(Tool):
|
|||||||
self._manager = manager
|
self._manager = manager
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def create(cls, ctx: Any) -> Tool:
|
def create(cls, ctx: ToolContext) -> Tool:
|
||||||
return cls(manager=ctx.subagent_manager)
|
manager = ctx.subagent_manager
|
||||||
|
if manager is None:
|
||||||
|
raise RuntimeError("SpawnTool requires an initialized subagent manager")
|
||||||
|
return cls(manager=manager)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def name(self) -> str:
|
def name(self) -> str:
|
||||||
|
|||||||
@ -1,5 +1,7 @@
|
|||||||
"""Web tools: web_search and web_fetch."""
|
"""Web tools: web_search and web_fetch."""
|
||||||
|
|
||||||
|
# pyright: reportIncompatibleMethodOverride=false
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
@ -7,7 +9,8 @@ import html
|
|||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
from typing import Any, Callable
|
from collections.abc import Callable
|
||||||
|
from typing import Any, cast
|
||||||
from urllib.parse import quote, urljoin, urlparse
|
from urllib.parse import quote, urljoin, urlparse
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
@ -15,6 +18,7 @@ from loguru import logger
|
|||||||
from pydantic import Field
|
from pydantic import Field
|
||||||
|
|
||||||
from nanobot.agent.tools.base import Tool, ToolResult, tool_parameters
|
from nanobot.agent.tools.base import Tool, ToolResult, tool_parameters
|
||||||
|
from nanobot.agent.tools.context import ToolContext
|
||||||
from nanobot.agent.tools.schema import (
|
from nanobot.agent.tools.schema import (
|
||||||
BooleanSchema,
|
BooleanSchema,
|
||||||
IntegerSchema,
|
IntegerSchema,
|
||||||
@ -291,8 +295,8 @@ class WebSearchTool(Tool):
|
|||||||
"""Search the web using configured provider."""
|
"""Search the web using configured provider."""
|
||||||
_scopes = {"core", "subagent"}
|
_scopes = {"core", "subagent"}
|
||||||
|
|
||||||
name = "web_search"
|
name = "web_search" # pyright: ignore[reportIncompatibleMethodOverride, reportAssignmentType]
|
||||||
description = (
|
description = ( # pyright: ignore[reportIncompatibleMethodOverride, reportAssignmentType]
|
||||||
"Search the web. Returns titles, URLs, and snippets. "
|
"Search the web. Returns titles, URLs, and snippets. "
|
||||||
"count defaults to 5 (max 10). "
|
"count defaults to 5 (max 10). "
|
||||||
"Some providers support timeRange, authLevel, and queryRewrite. "
|
"Some providers support timeRange, authLevel, and queryRewrite. "
|
||||||
@ -302,20 +306,21 @@ class WebSearchTool(Tool):
|
|||||||
config_key = "web"
|
config_key = "web"
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def config_cls(cls):
|
def config_cls(cls) -> type[WebToolsConfig]:
|
||||||
return WebToolsConfig
|
return WebToolsConfig
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def enabled(cls, ctx: Any) -> bool:
|
def enabled(cls, ctx: ToolContext) -> bool:
|
||||||
return ctx.config.web.enable
|
return ctx.config.web.enable
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def create(cls, ctx: Any) -> Tool:
|
def create(cls, ctx: ToolContext) -> Tool:
|
||||||
config_loader = None
|
config_loader: Callable[[], WebSearchConfig] | None = None
|
||||||
if ctx.provider_snapshot_loader is not None:
|
if ctx.provider_snapshot_loader is not None:
|
||||||
def config_loader():
|
def _load_search_config() -> WebSearchConfig:
|
||||||
from nanobot.config.loader import load_config, resolve_config_env_vars
|
from nanobot.config.loader import load_config, resolve_config_env_vars
|
||||||
return resolve_config_env_vars(load_config()).tools.web.search
|
return resolve_config_env_vars(load_config()).tools.web.search
|
||||||
|
config_loader = _load_search_config
|
||||||
return cls(
|
return cls(
|
||||||
config=ctx.config.web.search,
|
config=ctx.config.web.search,
|
||||||
proxy=ctx.config.web.proxy,
|
proxy=ctx.config.web.proxy,
|
||||||
@ -404,7 +409,7 @@ class WebSearchTool(Tool):
|
|||||||
auth_level: int | None = None,
|
auth_level: int | None = None,
|
||||||
query_rewrite: bool | None = None,
|
query_rewrite: bool | None = None,
|
||||||
**kwargs: Any,
|
**kwargs: Any,
|
||||||
) -> str:
|
) -> str: # pyright: ignore[reportIncompatibleMethodOverride]
|
||||||
self._refresh_config()
|
self._refresh_config()
|
||||||
provider = self.config.provider.strip().lower() or "brave"
|
provider = self.config.provider.strip().lower() or "brave"
|
||||||
n = min(max(count or self.config.max_results, 1), 10)
|
n = min(max(count or self.config.max_results, 1), 10)
|
||||||
@ -448,15 +453,20 @@ class WebSearchTool(Tool):
|
|||||||
|
|
||||||
async def _search_olostep(self, query: str, n: int) -> str:
|
async def _search_olostep(self, query: str, n: int) -> str:
|
||||||
try:
|
try:
|
||||||
from olostep import AsyncOlostep, Olostep_BaseError
|
from olostep import ( # pyright: ignore[reportMissingImports]
|
||||||
|
AsyncOlostep, # pyright: ignore[reportUnknownVariableType]
|
||||||
|
Olostep_BaseError, # pyright: ignore[reportUnknownVariableType]
|
||||||
|
)
|
||||||
except ImportError:
|
except ImportError:
|
||||||
return ToolResult.error("Error: olostep package not installed. Run: pip install olostep")
|
return ToolResult.error("Error: olostep package not installed. Run: pip install olostep")
|
||||||
|
async_olostep = cast(Any, AsyncOlostep)
|
||||||
|
olostep_base_error = cast(type[Exception], Olostep_BaseError)
|
||||||
api_key = self.config.api_key or os.environ.get("OLOSTEP_API_KEY", "")
|
api_key = self.config.api_key or os.environ.get("OLOSTEP_API_KEY", "")
|
||||||
if not api_key:
|
if not api_key:
|
||||||
logger.warning("OLOSTEP_API_KEY not set, falling back to DuckDuckGo")
|
logger.warning("OLOSTEP_API_KEY not set, falling back to DuckDuckGo")
|
||||||
return await self._search_duckduckgo(query, n)
|
return await self._search_duckduckgo(query, n)
|
||||||
try:
|
try:
|
||||||
async with AsyncOlostep(api_key=api_key) as client:
|
async with async_olostep(api_key=api_key) as client:
|
||||||
if self.proxy:
|
if self.proxy:
|
||||||
transport = getattr(client, "_transport", None)
|
transport = getattr(client, "_transport", None)
|
||||||
http_client = getattr(transport, "_client", None)
|
http_client = getattr(transport, "_client", None)
|
||||||
@ -472,14 +482,16 @@ class WebSearchTool(Tool):
|
|||||||
),
|
),
|
||||||
http2=True,
|
http2=True,
|
||||||
)
|
)
|
||||||
result = await client.answers.create(task=query)
|
result: Any = await client.answers.create(task=query)
|
||||||
|
|
||||||
sources = getattr(result, "sources", None) or []
|
sources = cast(list[Any], getattr(result, "sources", None) or [])
|
||||||
source_lines = []
|
source_lines: list[str] = []
|
||||||
for i, source in enumerate(sources[:n], 1):
|
for i, source_value in enumerate(sources[:n], 1):
|
||||||
|
source: Any = source_value
|
||||||
if isinstance(source, dict):
|
if isinstance(source, dict):
|
||||||
title = source.get("title", "")
|
source_dict = cast(dict[str, Any], source)
|
||||||
url = source.get("url", "")
|
title = source_dict.get("title", "")
|
||||||
|
url = source_dict.get("url", "")
|
||||||
else:
|
else:
|
||||||
title = getattr(source, "title", "")
|
title = getattr(source, "title", "")
|
||||||
url = getattr(source, "url", "")
|
url = getattr(source, "url", "")
|
||||||
@ -493,7 +505,7 @@ class WebSearchTool(Tool):
|
|||||||
answer_text = getattr(result, "answer", "") or ""
|
answer_text = getattr(result, "answer", "") or ""
|
||||||
items = [{"title": answer_text or "Olostep answer", "url": "", "content": "\n".join(source_lines)}]
|
items = [{"title": answer_text or "Olostep answer", "url": "", "content": "\n".join(source_lines)}]
|
||||||
return _format_results(query, items, n)
|
return _format_results(query, items, n)
|
||||||
except Olostep_BaseError as e:
|
except olostep_base_error as e:
|
||||||
return ToolResult.error(f"Error: Olostep search error: {type(e).__name__}: {e}")
|
return ToolResult.error(f"Error: Olostep search error: {type(e).__name__}: {e}")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return ToolResult.error(f"Error: Olostep search error: {type(e).__name__}: {e}")
|
return ToolResult.error(f"Error: Olostep search error: {type(e).__name__}: {e}")
|
||||||
@ -510,6 +522,7 @@ class WebSearchTool(Tool):
|
|||||||
"User-Agent": self.user_agent,
|
"User-Agent": self.user_agent,
|
||||||
}
|
}
|
||||||
async with httpx.AsyncClient(proxy=self.proxy) as client:
|
async with httpx.AsyncClient(proxy=self.proxy) as client:
|
||||||
|
r: httpx.Response | None = None
|
||||||
for attempt in range(2):
|
for attempt in range(2):
|
||||||
r = await client.get(
|
r = await client.get(
|
||||||
"https://api.search.brave.com/res/v1/web/search",
|
"https://api.search.brave.com/res/v1/web/search",
|
||||||
@ -522,6 +535,7 @@ class WebSearchTool(Tool):
|
|||||||
if attempt == 0:
|
if attempt == 0:
|
||||||
logger.warning("Brave search rate limited; retrying once in 1.0s")
|
logger.warning("Brave search rate limited; retrying once in 1.0s")
|
||||||
await asyncio.sleep(1.0)
|
await asyncio.sleep(1.0)
|
||||||
|
assert r is not None
|
||||||
r.raise_for_status()
|
r.raise_for_status()
|
||||||
items = [
|
items = [
|
||||||
{"title": x.get("title", ""), "url": x.get("url", ""), "content": x.get("description", "")}
|
{"title": x.get("title", ""), "url": x.get("url", ""), "content": x.get("description", "")}
|
||||||
@ -691,13 +705,19 @@ class WebSearchTool(Tool):
|
|||||||
timeout=float(self.config.timeout),
|
timeout=float(self.config.timeout),
|
||||||
)
|
)
|
||||||
r.raise_for_status()
|
r.raise_for_status()
|
||||||
items = []
|
data = cast(dict[str, Any], r.json())
|
||||||
for result in r.json().get("results", []):
|
items: list[dict[str, Any]] = []
|
||||||
if not isinstance(result, dict):
|
for result_value in cast(list[object], data.get("results", [])):
|
||||||
|
if not isinstance(result_value, dict):
|
||||||
continue
|
continue
|
||||||
highlights = result.get("highlights") or []
|
result = cast(dict[str, Any], result_value)
|
||||||
|
highlights: Any = result.get("highlights") or []
|
||||||
if isinstance(highlights, list):
|
if isinstance(highlights, list):
|
||||||
content = "\n".join(str(highlight) for highlight in highlights if highlight)
|
content = "\n".join(
|
||||||
|
str(highlight)
|
||||||
|
for highlight in cast(list[object], highlights)
|
||||||
|
if highlight
|
||||||
|
)
|
||||||
else:
|
else:
|
||||||
content = str(highlights)
|
content = str(highlights)
|
||||||
if not content:
|
if not content:
|
||||||
@ -737,14 +757,17 @@ class WebSearchTool(Tool):
|
|||||||
timeout=float(self.config.timeout),
|
timeout=float(self.config.timeout),
|
||||||
)
|
)
|
||||||
r.raise_for_status()
|
r.raise_for_status()
|
||||||
items = [
|
data = cast(dict[str, Any], r.json())
|
||||||
|
organic = cast(list[object], data.get("organic", []))
|
||||||
|
items: list[dict[str, Any]] = [
|
||||||
{
|
{
|
||||||
"title": result.get("title", ""),
|
"title": result.get("title", ""),
|
||||||
"url": result.get("link", ""),
|
"url": result.get("link", ""),
|
||||||
"content": result.get("snippet", ""),
|
"content": result.get("snippet", ""),
|
||||||
}
|
}
|
||||||
for result in r.json().get("organic", [])
|
for result_value in organic
|
||||||
if isinstance(result, dict)
|
if isinstance(result_value, dict)
|
||||||
|
for result in (cast(dict[str, Any], result_value),)
|
||||||
]
|
]
|
||||||
return _format_results(query, items, n)
|
return _format_results(query, items, n)
|
||||||
except httpx.HTTPStatusError as e:
|
except httpx.HTTPStatusError as e:
|
||||||
@ -806,7 +829,7 @@ class WebSearchTool(Tool):
|
|||||||
timeout=float(self.config.timeout),
|
timeout=float(self.config.timeout),
|
||||||
)
|
)
|
||||||
r.raise_for_status()
|
r.raise_for_status()
|
||||||
data = r.json()
|
data = cast(dict[str, Any], r.json())
|
||||||
except httpx.HTTPStatusError as e:
|
except httpx.HTTPStatusError as e:
|
||||||
if e.response.status_code == 429:
|
if e.response.status_code == 429:
|
||||||
return ToolResult.error("Error: Volcengine search rate limited. Try again later or reduce search frequency.")
|
return ToolResult.error("Error: Volcengine search rate limited. Try again later or reduce search frequency.")
|
||||||
@ -814,20 +837,36 @@ class WebSearchTool(Tool):
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
return ToolResult.error(f"Error: Volcengine search failed: {e}")
|
return ToolResult.error(f"Error: Volcengine search failed: {e}")
|
||||||
|
|
||||||
error = (data.get("ResponseMetadata") or {}).get("Error") or data.get("Error") or data.get("error")
|
response_metadata = cast(
|
||||||
|
dict[str, Any],
|
||||||
|
data.get("ResponseMetadata") or {},
|
||||||
|
)
|
||||||
|
error = (
|
||||||
|
response_metadata.get("Error")
|
||||||
|
or data.get("Error")
|
||||||
|
or data.get("error")
|
||||||
|
)
|
||||||
if error:
|
if error:
|
||||||
if isinstance(error, dict):
|
if isinstance(error, dict):
|
||||||
|
error = cast(dict[str, Any], error)
|
||||||
code = error.get("Code") or error.get("code") or "unknown"
|
code = error.get("Code") or error.get("code") or "unknown"
|
||||||
message = error.get("Message") or error.get("message") or error
|
message = error.get("Message") or error.get("message") or error
|
||||||
return ToolResult.error(f"Error: Volcengine search error {code}: {message}")
|
return ToolResult.error(f"Error: Volcengine search error {code}: {message}")
|
||||||
return ToolResult.error(f"Error: Volcengine search error: {error}")
|
return ToolResult.error(f"Error: Volcengine search error: {error}")
|
||||||
|
|
||||||
result = data.get("Result") or data
|
result = cast(dict[str, Any], data.get("Result") or data)
|
||||||
web_results = result.get("WebResults") or result.get("webResults") or result.get("results") or []
|
web_results = cast(
|
||||||
|
list[object],
|
||||||
|
result.get("WebResults")
|
||||||
|
or result.get("webResults")
|
||||||
|
or result.get("results")
|
||||||
|
or [],
|
||||||
|
)
|
||||||
items: list[dict[str, Any]] = []
|
items: list[dict[str, Any]] = []
|
||||||
for item in web_results:
|
for item_value in web_results:
|
||||||
if not isinstance(item, dict):
|
if not isinstance(item_value, dict):
|
||||||
continue
|
continue
|
||||||
|
item = cast(dict[str, Any], item_value)
|
||||||
meta_parts = [
|
meta_parts = [
|
||||||
str(part)
|
str(part)
|
||||||
for part in (
|
for part in (
|
||||||
@ -837,7 +876,7 @@ class WebSearchTool(Tool):
|
|||||||
)
|
)
|
||||||
if part
|
if part
|
||||||
]
|
]
|
||||||
summary = (
|
summary = cast(str, (
|
||||||
item.get("Summary")
|
item.get("Summary")
|
||||||
or item.get("summary")
|
or item.get("summary")
|
||||||
or item.get("Snippet")
|
or item.get("Snippet")
|
||||||
@ -845,7 +884,7 @@ class WebSearchTool(Tool):
|
|||||||
or item.get("Content")
|
or item.get("Content")
|
||||||
or item.get("content")
|
or item.get("content")
|
||||||
or ""
|
or ""
|
||||||
)
|
))
|
||||||
content = "\n".join(part for part in (" | ".join(meta_parts), summary) if part)
|
content = "\n".join(part for part in (" | ".join(meta_parts), summary) if part)
|
||||||
items.append(
|
items.append(
|
||||||
{
|
{
|
||||||
@ -861,18 +900,20 @@ class WebSearchTool(Tool):
|
|||||||
try:
|
try:
|
||||||
# Note: duckduckgo_search is synchronous and does its own requests
|
# Note: duckduckgo_search is synchronous and does its own requests
|
||||||
# We run it in a thread to avoid blocking the loop
|
# We run it in a thread to avoid blocking the loop
|
||||||
from ddgs import DDGS
|
from ddgs import DDGS # pyright: ignore[reportUnknownVariableType]
|
||||||
|
|
||||||
ddgs = DDGS(timeout=10, proxy=self.proxy)
|
ddgs_type = cast(Any, DDGS)
|
||||||
|
ddgs = ddgs_type(timeout=10, proxy=self.proxy)
|
||||||
raw = await asyncio.wait_for(
|
raw = await asyncio.wait_for(
|
||||||
asyncio.to_thread(ddgs.text, query, max_results=n),
|
asyncio.to_thread(ddgs.text, query, max_results=n),
|
||||||
timeout=self.config.timeout,
|
timeout=self.config.timeout,
|
||||||
)
|
)
|
||||||
if not raw:
|
if not raw:
|
||||||
return f"No results for: {query}"
|
return f"No results for: {query}"
|
||||||
items = [
|
raw_items = cast(list[dict[str, Any]], raw)
|
||||||
|
items: list[dict[str, Any]] = [
|
||||||
{"title": r.get("title", ""), "url": r.get("href", ""), "content": r.get("body", "")}
|
{"title": r.get("title", ""), "url": r.get("href", ""), "content": r.get("body", "")}
|
||||||
for r in raw
|
for r in raw_items
|
||||||
]
|
]
|
||||||
return _format_results(query, items, n)
|
return _format_results(query, items, n)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@ -907,15 +948,19 @@ class WebSearchTool(Tool):
|
|||||||
if r.status_code == 429:
|
if r.status_code == 429:
|
||||||
return ToolResult.error("Error: Bocha search rate-limited (HTTP 429). Wait and retry.")
|
return ToolResult.error("Error: Bocha search rate-limited (HTTP 429). Wait and retry.")
|
||||||
r.raise_for_status()
|
r.raise_for_status()
|
||||||
data = r.json()
|
data = cast(dict[str, Any], r.json())
|
||||||
wrapped_data = data.get("data") if isinstance(data, dict) else None
|
wrapped_data = data.get("data")
|
||||||
result_data = wrapped_data if isinstance(wrapped_data, dict) else data
|
result_data = (
|
||||||
web_pages = (
|
cast(dict[str, Any], wrapped_data)
|
||||||
result_data.get("webPages", {}).get("value", [])
|
if isinstance(wrapped_data, dict)
|
||||||
if isinstance(result_data, dict)
|
else data
|
||||||
else []
|
|
||||||
)
|
)
|
||||||
items = [
|
web_pages_data = cast(
|
||||||
|
dict[str, Any],
|
||||||
|
result_data.get("webPages", {}),
|
||||||
|
)
|
||||||
|
web_pages = cast(list[dict[str, Any]], web_pages_data.get("value", []))
|
||||||
|
items: list[dict[str, Any]] = [
|
||||||
{
|
{
|
||||||
"title": x.get("name", ""),
|
"title": x.get("name", ""),
|
||||||
"url": x.get("url", ""),
|
"url": x.get("url", ""),
|
||||||
@ -946,8 +991,8 @@ class WebFetchTool(Tool):
|
|||||||
"""Fetch and extract content from a URL."""
|
"""Fetch and extract content from a URL."""
|
||||||
_scopes = {"core", "subagent"}
|
_scopes = {"core", "subagent"}
|
||||||
|
|
||||||
name = "web_fetch"
|
name = "web_fetch" # pyright: ignore[reportIncompatibleMethodOverride, reportAssignmentType]
|
||||||
description = (
|
description = ( # pyright: ignore[reportIncompatibleMethodOverride, reportAssignmentType]
|
||||||
"Fetch a URL and extract readable content (HTML → markdown/text). "
|
"Fetch a URL and extract readable content (HTML → markdown/text). "
|
||||||
"Output is capped at maxChars (default 50 000). "
|
"Output is capped at maxChars (default 50 000). "
|
||||||
"Works for most web pages and docs; may fail on login-walled or JS-heavy sites."
|
"Works for most web pages and docs; may fail on login-walled or JS-heavy sites."
|
||||||
@ -956,15 +1001,15 @@ class WebFetchTool(Tool):
|
|||||||
config_key = "web"
|
config_key = "web"
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def config_cls(cls):
|
def config_cls(cls) -> type[WebToolsConfig]:
|
||||||
return WebToolsConfig
|
return WebToolsConfig
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def enabled(cls, ctx: Any) -> bool:
|
def enabled(cls, ctx: ToolContext) -> bool:
|
||||||
return ctx.config.web.enable
|
return ctx.config.web.enable
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def create(cls, ctx: Any) -> Tool:
|
def create(cls, ctx: ToolContext) -> Tool:
|
||||||
return cls(
|
return cls(
|
||||||
config=ctx.config.web.fetch,
|
config=ctx.config.web.fetch,
|
||||||
proxy=ctx.config.web.proxy,
|
proxy=ctx.config.web.proxy,
|
||||||
@ -987,10 +1032,10 @@ class WebFetchTool(Tool):
|
|||||||
extract_mode: str = "markdown",
|
extract_mode: str = "markdown",
|
||||||
max_chars: int | None = None,
|
max_chars: int | None = None,
|
||||||
**kwargs: Any,
|
**kwargs: Any,
|
||||||
) -> Any:
|
) -> Any: # pyright: ignore[reportIncompatibleMethodOverride]
|
||||||
url = url.strip(" \t\r\n`\"'")
|
url = url.strip(" \t\r\n`\"'")
|
||||||
extract_mode = kwargs.pop("extractMode", extract_mode)
|
extract_mode = kwargs.pop("extractMode", extract_mode)
|
||||||
max_chars = kwargs.pop("maxChars", max_chars) or self.max_chars
|
max_chars = cast(int, kwargs.pop("maxChars", max_chars) or self.max_chars)
|
||||||
is_valid, error_msg = _validate_url_safe(url)
|
is_valid, error_msg = _validate_url_safe(url)
|
||||||
if not is_valid:
|
if not is_valid:
|
||||||
return json.dumps({"error": f"URL validation failed: {error_msg}", "url": url}, ensure_ascii=False)
|
return json.dumps({"error": f"URL validation failed: {error_msg}", "url": url}, ensure_ascii=False)
|
||||||
@ -1119,10 +1164,10 @@ class WebFetchTool(Tool):
|
|||||||
return json.dumps({"error": str(e), "url": url}, ensure_ascii=False)
|
return json.dumps({"error": str(e), "url": url}, ensure_ascii=False)
|
||||||
|
|
||||||
def _extract_readable_html(self, html_content: str, extract_mode: str) -> str:
|
def _extract_readable_html(self, html_content: str, extract_mode: str) -> str:
|
||||||
from readability import Document
|
from readability import Document # pyright: ignore[reportMissingTypeStubs]
|
||||||
|
|
||||||
doc = Document(html_content)
|
doc = Document(html_content)
|
||||||
summary = doc.summary()
|
summary = cast(str, doc.summary())
|
||||||
content = self._to_markdown(summary) if extract_mode == "markdown" else _strip_tags(summary)
|
content = self._to_markdown(summary) if extract_mode == "markdown" else _strip_tags(summary)
|
||||||
return f"# {doc.title()}\n\n{content}" if doc.title() else content
|
return f"# {doc.title()}\n\n{content}" if doc.title() else content
|
||||||
|
|
||||||
|
|||||||
@ -6,7 +6,7 @@ import dataclasses
|
|||||||
import time
|
import time
|
||||||
from collections.abc import Awaitable, Callable
|
from collections.abc import Awaitable, Callable
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from typing import Any
|
from typing import TYPE_CHECKING, Any, cast
|
||||||
|
|
||||||
from nanobot.bus.events import InboundMessage, OutboundMessage
|
from nanobot.bus.events import InboundMessage, OutboundMessage
|
||||||
from nanobot.bus.outbound_events import (
|
from nanobot.bus.outbound_events import (
|
||||||
@ -20,6 +20,9 @@ from nanobot.bus.progress import build_bus_progress_callback
|
|||||||
from nanobot.bus.queue import MessageBus
|
from nanobot.bus.queue import MessageBus
|
||||||
from nanobot.bus.runtime_events import RuntimeEventBus, RuntimeEventPublisher
|
from nanobot.bus.runtime_events import RuntimeEventBus, RuntimeEventPublisher
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from nanobot.utils.llm_runtime import LLMRuntime
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
class TurnRoute:
|
class TurnRoute:
|
||||||
@ -62,7 +65,7 @@ class TurnDeliveryFactory:
|
|||||||
route = self._default_route(msg, session_key)
|
route = self._default_route(msg, session_key)
|
||||||
if self.route_policy is not None:
|
if self.route_policy is not None:
|
||||||
route = self.route_policy(msg, session_key, route)
|
route = self.route_policy(msg, session_key, route)
|
||||||
if not isinstance(route, TurnRoute):
|
if not isinstance(cast(object, route), TurnRoute):
|
||||||
raise TypeError("turn route policy must return TurnRoute")
|
raise TypeError("turn route policy must return TurnRoute")
|
||||||
return TurnDelivery(
|
return TurnDelivery(
|
||||||
bus=self.bus,
|
bus=self.bus,
|
||||||
@ -186,7 +189,7 @@ class TurnDelivery:
|
|||||||
started_at=started_at,
|
started_at=started_at,
|
||||||
)
|
)
|
||||||
|
|
||||||
def record_runtime(self, runtime: Any) -> None:
|
def record_runtime(self, runtime: LLMRuntime) -> None:
|
||||||
self.runtime_event_publisher.record_turn_runtime(self.session_key, runtime)
|
self.runtime_event_publisher.record_turn_runtime(self.session_key, runtime)
|
||||||
|
|
||||||
def record_latency(self, latency_ms: int | None) -> None:
|
def record_latency(self, latency_ms: int | None) -> None:
|
||||||
|
|||||||
@ -35,7 +35,7 @@ def api_runtime_paths(config_path: Path) -> ProcessRuntimePaths:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
class ApiRuntime(ManagedProcessRuntime):
|
class ApiRuntime(ManagedProcessRuntime[ApiStartOptions]):
|
||||||
"""Manage a WebUI-controlled OpenAI-compatible API process."""
|
"""Manage a WebUI-controlled OpenAI-compatible API process."""
|
||||||
|
|
||||||
service_name = "api"
|
service_name = "api"
|
||||||
|
|||||||
@ -12,7 +12,7 @@ import hmac
|
|||||||
import json as _json
|
import json as _json
|
||||||
import time
|
import time
|
||||||
import uuid
|
import uuid
|
||||||
from typing import Any
|
from typing import TYPE_CHECKING, Any, Awaitable, Callable, cast
|
||||||
|
|
||||||
from aiohttp import web
|
from aiohttp import web
|
||||||
from loguru import logger
|
from loguru import logger
|
||||||
@ -30,6 +30,9 @@ from nanobot.utils.media_decode import (
|
|||||||
)
|
)
|
||||||
from nanobot.utils.runtime import EMPTY_FINAL_RESPONSE_MESSAGE
|
from nanobot.utils.runtime import EMPTY_FINAL_RESPONSE_MESSAGE
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from nanobot.agent.loop import AgentLoop
|
||||||
|
|
||||||
__all__ = (
|
__all__ = (
|
||||||
"MAX_FILE_SIZE",
|
"MAX_FILE_SIZE",
|
||||||
"_FileSizeExceeded",
|
"_FileSizeExceeded",
|
||||||
@ -44,7 +47,7 @@ API_CHAT_ID = "default"
|
|||||||
_AGENT_LOOP_KEY = web.AppKey[Any]("agent_loop")
|
_AGENT_LOOP_KEY = web.AppKey[Any]("agent_loop")
|
||||||
_MODEL_NAME_KEY = web.AppKey[str]("model_name")
|
_MODEL_NAME_KEY = web.AppKey[str]("model_name")
|
||||||
_REQUEST_TIMEOUT_KEY = web.AppKey[float]("request_timeout")
|
_REQUEST_TIMEOUT_KEY = web.AppKey[float]("request_timeout")
|
||||||
_SESSION_LOCKS_KEY = web.AppKey[dict]("session_locks")
|
_SESSION_LOCKS_KEY = web.AppKey[dict[str, asyncio.Lock]]("session_locks")
|
||||||
_MISSING = object()
|
_MISSING = object()
|
||||||
|
|
||||||
|
|
||||||
@ -111,6 +114,26 @@ def _response_text(value: Any) -> str:
|
|||||||
return str(getattr(value, "content") or "")
|
return str(getattr(value, "content") or "")
|
||||||
return str(value)
|
return str(value)
|
||||||
|
|
||||||
|
|
||||||
|
def _as_str(value: object) -> str:
|
||||||
|
"""Return *value* when it is text, otherwise an empty string."""
|
||||||
|
return value if isinstance(value, str) else ""
|
||||||
|
|
||||||
|
|
||||||
|
def _require_json_object(value: object, field: str) -> dict[str, Any]:
|
||||||
|
"""Validate an object-valued field from an untrusted JSON request."""
|
||||||
|
if not isinstance(value, dict):
|
||||||
|
raise TypeError(f"{field} must be an object")
|
||||||
|
return cast(dict[str, Any], value)
|
||||||
|
|
||||||
|
|
||||||
|
def _require_json_string(value: object, field: str) -> str:
|
||||||
|
"""Validate a string-valued field from an untrusted JSON request."""
|
||||||
|
if not isinstance(value, str):
|
||||||
|
raise TypeError(f"{field} must be a string")
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# SSE helpers
|
# SSE helpers
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@ -141,13 +164,19 @@ _SSE_DONE = b"data: [DONE]\n\n"
|
|||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
def _parse_json_content(body: dict) -> tuple[str, list[str]]:
|
def _parse_json_content(body: dict[str, Any]) -> tuple[str, list[str]]:
|
||||||
"""Parse JSON request body. Returns (text, media_paths)."""
|
"""Parse JSON request body. Returns (text, media_paths)."""
|
||||||
messages = body.get("messages")
|
messages_value = cast(object, body.get("messages"))
|
||||||
if not isinstance(messages, list) or len(messages) != 1:
|
if not isinstance(messages_value, list):
|
||||||
raise ValueError("Only a single user message is supported")
|
raise ValueError("Only a single user message is supported")
|
||||||
message = messages[0]
|
messages = cast(list[object], messages_value)
|
||||||
if not isinstance(message, dict) or message.get("role") != "user":
|
if len(messages) != 1:
|
||||||
|
raise ValueError("Only a single user message is supported")
|
||||||
|
message_value: object = messages[0]
|
||||||
|
if not isinstance(message_value, dict):
|
||||||
|
raise ValueError("Only a single user message is supported")
|
||||||
|
message = cast(dict[str, Any], message_value)
|
||||||
|
if message.get("role") != "user":
|
||||||
raise ValueError("Only a single user message is supported")
|
raise ValueError("Only a single user message is supported")
|
||||||
|
|
||||||
user_content = message.get("content", "")
|
user_content = message.get("content", "")
|
||||||
@ -156,13 +185,26 @@ def _parse_json_content(body: dict) -> tuple[str, list[str]]:
|
|||||||
|
|
||||||
if isinstance(user_content, list):
|
if isinstance(user_content, list):
|
||||||
text_parts: list[str] = []
|
text_parts: list[str] = []
|
||||||
for part in user_content:
|
for part_value in cast(list[object], user_content):
|
||||||
if not isinstance(part, dict):
|
if not isinstance(part_value, dict):
|
||||||
continue
|
continue
|
||||||
|
part = cast(dict[str, Any], part_value)
|
||||||
if part.get("type") == "text":
|
if part.get("type") == "text":
|
||||||
text_parts.append(part.get("text", ""))
|
text_parts.append(
|
||||||
|
_require_json_string(
|
||||||
|
cast(object, part.get("text", "")),
|
||||||
|
"messages[0].content[].text",
|
||||||
|
)
|
||||||
|
)
|
||||||
elif part.get("type") == "image_url":
|
elif part.get("type") == "image_url":
|
||||||
url = part.get("image_url", {}).get("url", "")
|
image_url = _require_json_object(
|
||||||
|
cast(object, part.get("image_url", {})),
|
||||||
|
"messages[0].content[].image_url",
|
||||||
|
)
|
||||||
|
url = _require_json_string(
|
||||||
|
cast(object, image_url.get("url", "")),
|
||||||
|
"messages[0].content[].image_url.url",
|
||||||
|
)
|
||||||
if url.startswith("data:"):
|
if url.startswith("data:"):
|
||||||
saved = _save_base64_data_url(url, media_dir)
|
saved = _save_base64_data_url(url, media_dir)
|
||||||
if saved:
|
if saved:
|
||||||
@ -191,7 +233,7 @@ async def _parse_multipart(request: web.Request) -> tuple[str, list[str], str |
|
|||||||
media_paths: list[str] = []
|
media_paths: list[str] = []
|
||||||
|
|
||||||
while True:
|
while True:
|
||||||
part = await reader.next()
|
part: Any = await reader.next()
|
||||||
if part is None:
|
if part is None:
|
||||||
break
|
break
|
||||||
if part.name == "message":
|
if part.name == "message":
|
||||||
@ -223,11 +265,9 @@ async def _parse_multipart(request: web.Request) -> tuple[str, list[str], str |
|
|||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
async def handle_chat_completions(request: web.Request) -> web.Response:
|
async def handle_chat_completions(request: web.Request) -> web.Response | web.StreamResponse:
|
||||||
"""POST /v1/chat/completions — supports JSON and multipart/form-data."""
|
"""POST /v1/chat/completions — supports JSON and multipart/form-data."""
|
||||||
content_type = request.content_type or ""
|
content_type = _as_str(cast(object, request.content_type or ""))
|
||||||
if not isinstance(content_type, str):
|
|
||||||
content_type = ""
|
|
||||||
|
|
||||||
agent_loop = _app_value(request.app, _AGENT_LOOP_KEY, "agent_loop")
|
agent_loop = _app_value(request.app, _AGENT_LOOP_KEY, "agent_loop")
|
||||||
timeout_s: float = _app_value(
|
timeout_s: float = _app_value(
|
||||||
@ -247,6 +287,9 @@ async def handle_chat_completions(request: web.Request) -> web.Response:
|
|||||||
body = await request.json()
|
body = await request.json()
|
||||||
except Exception:
|
except Exception:
|
||||||
return _error_json(400, "Invalid JSON body")
|
return _error_json(400, "Invalid JSON body")
|
||||||
|
if not isinstance(body, dict):
|
||||||
|
return _error_json(400, "Invalid JSON body")
|
||||||
|
body = cast(dict[str, Any], body)
|
||||||
stream = body.get("stream", False)
|
stream = body.get("stream", False)
|
||||||
requested_model = body.get("model")
|
requested_model = body.get("model")
|
||||||
text, media_paths = _parse_json_content(body)
|
text, media_paths = _parse_json_content(body)
|
||||||
@ -405,7 +448,7 @@ async def handle_health(request: web.Request) -> web.Response:
|
|||||||
|
|
||||||
|
|
||||||
def create_app(
|
def create_app(
|
||||||
agent_loop,
|
agent_loop: "AgentLoop",
|
||||||
model_name: str = "nanobot",
|
model_name: str = "nanobot",
|
||||||
request_timeout: float = 120.0,
|
request_timeout: float = 120.0,
|
||||||
api_key: str = "",
|
api_key: str = "",
|
||||||
@ -425,7 +468,10 @@ def create_app(
|
|||||||
app[_SESSION_LOCKS_KEY] = {} # per-user locks, keyed by session_key
|
app[_SESSION_LOCKS_KEY] = {} # per-user locks, keyed by session_key
|
||||||
|
|
||||||
@web.middleware
|
@web.middleware
|
||||||
async def auth_middleware(request: web.Request, handler) -> web.StreamResponse:
|
async def auth_middleware(
|
||||||
|
request: web.Request,
|
||||||
|
handler: Callable[[web.Request], Awaitable[web.StreamResponse]],
|
||||||
|
) -> web.StreamResponse:
|
||||||
# Allow unauthenticated health checks.
|
# Allow unauthenticated health checks.
|
||||||
if request.path == "/health":
|
if request.path == "/health":
|
||||||
return await handler(request)
|
return await handler(request)
|
||||||
|
|||||||
@ -10,10 +10,11 @@ import shutil
|
|||||||
import subprocess
|
import subprocess
|
||||||
import sys
|
import sys
|
||||||
import time
|
import time
|
||||||
|
from collections.abc import Iterable
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from importlib import metadata as importlib_metadata
|
from importlib import metadata as importlib_metadata
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any
|
from typing import Any, cast
|
||||||
from urllib.parse import urlparse
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
@ -204,6 +205,11 @@ def _now() -> float:
|
|||||||
return time.time()
|
return time.time()
|
||||||
|
|
||||||
|
|
||||||
|
def _as_object_dict(value: object) -> dict[str, Any] | None:
|
||||||
|
"""Narrow a JSON-like object to the string-keyed mapping used by this module."""
|
||||||
|
return cast(dict[str, Any], value) if isinstance(value, dict) else None
|
||||||
|
|
||||||
|
|
||||||
def _safe_skill_name(name: str) -> str:
|
def _safe_skill_name(name: str) -> str:
|
||||||
clean = _SAFE_NAME_RE.sub("-", name.lower()).strip("-")
|
clean = _SAFE_NAME_RE.sub("-", name.lower()).strip("-")
|
||||||
return f"cli-app-{clean or 'app'}"
|
return f"cli-app-{clean or 'app'}"
|
||||||
@ -277,10 +283,11 @@ def _console_script_distribution(entry_point: str) -> str | None:
|
|||||||
if item.group != "console_scripts" or item.name != entry_point:
|
if item.group != "console_scripts" or item.name != entry_point:
|
||||||
continue
|
continue
|
||||||
try:
|
try:
|
||||||
name = distribution.metadata.get("Name")
|
name: object = cast(Any, distribution.metadata).get("Name")
|
||||||
except Exception:
|
except Exception:
|
||||||
name = None
|
name = None
|
||||||
return str(name or getattr(distribution, "name", "") or "").strip() or None
|
fallback_name = cast(object, getattr(distribution, "name", ""))
|
||||||
|
return str(name or fallback_name or "").strip() or None
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
@ -335,10 +342,10 @@ def _brand_payload(app: dict[str, Any]) -> tuple[str | None, str | None]:
|
|||||||
|
|
||||||
def _read_json(path: Path) -> dict[str, Any] | None:
|
def _read_json(path: Path) -> dict[str, Any] | None:
|
||||||
try:
|
try:
|
||||||
data = json.loads(path.read_text(encoding="utf-8"))
|
data: object = json.loads(path.read_text(encoding="utf-8"))
|
||||||
except (OSError, json.JSONDecodeError):
|
except (OSError, json.JSONDecodeError):
|
||||||
return None
|
return None
|
||||||
return data if isinstance(data, dict) else None
|
return _as_object_dict(data)
|
||||||
|
|
||||||
|
|
||||||
def _write_json(path: Path, data: dict[str, Any]) -> None:
|
def _write_json(path: Path, data: dict[str, Any]) -> None:
|
||||||
@ -414,8 +421,8 @@ class CliAppManager:
|
|||||||
cached = _read_json(cache_path)
|
cached = _read_json(cache_path)
|
||||||
if not cached:
|
if not cached:
|
||||||
return None, 0.0
|
return None, 0.0
|
||||||
data = cached.get("data")
|
data = _as_object_dict(cached.get("data"))
|
||||||
if not isinstance(data, dict):
|
if data is None:
|
||||||
return None, 0.0
|
return None, 0.0
|
||||||
try:
|
try:
|
||||||
cached_at = float(cached.get("_cached_at", 0))
|
cached_at = float(cached.get("_cached_at", 0))
|
||||||
@ -425,8 +432,8 @@ class CliAppManager:
|
|||||||
|
|
||||||
def _load_installed(self) -> dict[str, Any]:
|
def _load_installed(self) -> dict[str, Any]:
|
||||||
data = _read_json(self.installed_path) or {}
|
data = _read_json(self.installed_path) or {}
|
||||||
apps = data.get("apps") if isinstance(data.get("apps"), dict) else data
|
apps = _as_object_dict(data.get("apps"))
|
||||||
return apps if isinstance(apps, dict) else {}
|
return apps if apps is not None else data
|
||||||
|
|
||||||
def _save_installed(self, installed: dict[str, Any]) -> None:
|
def _save_installed(self, installed: dict[str, Any]) -> None:
|
||||||
_write_json(self.installed_path, {"schema_version": 1, "apps": installed})
|
_write_json(self.installed_path, {"schema_version": 1, "apps": installed})
|
||||||
@ -453,8 +460,8 @@ class CliAppManager:
|
|||||||
try:
|
try:
|
||||||
response = httpx.get(url, timeout=15.0, follow_redirects=True)
|
response = httpx.get(url, timeout=15.0, follow_redirects=True)
|
||||||
response.raise_for_status()
|
response.raise_for_status()
|
||||||
fetched = response.json()
|
fetched = _as_object_dict(response.json())
|
||||||
if not isinstance(fetched, dict):
|
if fetched is None:
|
||||||
raise ValueError("registry response must be an object")
|
raise ValueError("registry response must be an object")
|
||||||
except Exception:
|
except Exception:
|
||||||
if data is not None:
|
if data is not None:
|
||||||
@ -483,8 +490,8 @@ class CliAppManager:
|
|||||||
async with httpx.AsyncClient(timeout=15.0, follow_redirects=True) as client:
|
async with httpx.AsyncClient(timeout=15.0, follow_redirects=True) as client:
|
||||||
response = await client.get(url)
|
response = await client.get(url)
|
||||||
response.raise_for_status()
|
response.raise_for_status()
|
||||||
fetched = response.json()
|
fetched = _as_object_dict(response.json())
|
||||||
if not isinstance(fetched, dict):
|
if fetched is None:
|
||||||
raise ValueError("registry response must be an object")
|
raise ValueError("registry response must be an object")
|
||||||
except Exception:
|
except Exception:
|
||||||
if data is not None:
|
if data is not None:
|
||||||
@ -534,13 +541,14 @@ class CliAppManager:
|
|||||||
apps_by_name: dict[str, dict[str, Any]] = {}
|
apps_by_name: dict[str, dict[str, Any]] = {}
|
||||||
updated_values: list[str] = []
|
updated_values: list[str] = []
|
||||||
for source, raw_base, registry in registries:
|
for source, raw_base, registry in registries:
|
||||||
meta = registry.get("meta")
|
meta = _as_object_dict(registry.get("meta"))
|
||||||
if isinstance(meta, dict) and isinstance(meta.get("updated"), str):
|
if meta is not None and isinstance(meta.get("updated"), str):
|
||||||
updated_values.append(meta["updated"])
|
updated_values.append(meta["updated"])
|
||||||
for row in registry.get("clis", []):
|
for row in cast(Iterable[object], registry.get("clis", [])):
|
||||||
if not isinstance(row, dict) or not row.get("name"):
|
entry = _as_object_dict(row)
|
||||||
|
if entry is None or not entry.get("name"):
|
||||||
continue
|
continue
|
||||||
entry = dict(row)
|
entry = dict(entry)
|
||||||
entry["_source"] = source
|
entry["_source"] = source
|
||||||
entry["_raw_base"] = raw_base
|
entry["_raw_base"] = raw_base
|
||||||
key = str(entry["name"]).lower()
|
key = str(entry["name"]).lower()
|
||||||
@ -588,7 +596,7 @@ class CliAppManager:
|
|||||||
if not installed:
|
if not installed:
|
||||||
return []
|
return []
|
||||||
installed_by_name = {
|
installed_by_name = {
|
||||||
str(name).lower(): (str(name), data if isinstance(data, dict) else {})
|
str(name).lower(): (str(name), _as_object_dict(data) or {})
|
||||||
for name, data in installed.items()
|
for name, data in installed.items()
|
||||||
}
|
}
|
||||||
seen: set[str] = set()
|
seen: set[str] = set()
|
||||||
@ -769,12 +777,14 @@ class CliAppManager:
|
|||||||
for app in cached_apps
|
for app in cached_apps
|
||||||
if app.get("name")
|
if app.get("name")
|
||||||
}
|
}
|
||||||
rows = []
|
rows: list[dict[str, Any]] = []
|
||||||
for name, raw_entry in sorted(installed.items()):
|
for name, raw_entry in sorted(installed.items()):
|
||||||
entry = raw_entry if isinstance(raw_entry, dict) else {}
|
entry = _as_object_dict(raw_entry)
|
||||||
|
if entry is None:
|
||||||
|
entry = {}
|
||||||
strategy = str(entry.get("strategy") or "bundled")
|
strategy = str(entry.get("strategy") or "bundled")
|
||||||
cached_app = cached_by_name.get(str(name).lower(), {})
|
cached_app = cached_by_name.get(str(name).lower(), {})
|
||||||
app = {
|
app: dict[str, Any] = {
|
||||||
"name": str(name),
|
"name": str(name),
|
||||||
"display_name": str(
|
"display_name": str(
|
||||||
cached_app.get("display_name") or entry.get("display_name") or name
|
cached_app.get("display_name") or entry.get("display_name") or name
|
||||||
@ -1165,7 +1175,9 @@ Use the `run_cli_app` tool with `name="{name}"` for command execution. Do not in
|
|||||||
if str(app["name"]) not in installed:
|
if str(app["name"]) not in installed:
|
||||||
raise CliAppError("CLI app is not installed")
|
raise CliAppError("CLI app is not installed")
|
||||||
raw_installed_entry = installed.get(str(app["name"]))
|
raw_installed_entry = installed.get(str(app["name"]))
|
||||||
installed_entry = raw_installed_entry if isinstance(raw_installed_entry, dict) else {}
|
installed_entry = _as_object_dict(raw_installed_entry)
|
||||||
|
if installed_entry is None:
|
||||||
|
installed_entry = {}
|
||||||
strategy = self._strategy(app)
|
strategy = self._strategy(app)
|
||||||
entry_point = str(app.get("entry_point") or "").strip()
|
entry_point = str(app.get("entry_point") or "").strip()
|
||||||
managed_entry_path = str(installed_entry.get("entry_point_path") or "").strip()
|
managed_entry_path = str(installed_entry.get("entry_point_path") or "").strip()
|
||||||
|
|||||||
@ -3,7 +3,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any, Mapping
|
from typing import Any, Mapping, cast
|
||||||
|
|
||||||
|
|
||||||
def session_extra(metadata: Mapping[str, Any] | None) -> dict[str, Any]:
|
def session_extra(metadata: Mapping[str, Any] | None) -> dict[str, Any]:
|
||||||
@ -29,9 +29,11 @@ def runtime_lines_for_request(
|
|||||||
"""Return CLI App annotations from an immutable request snapshot."""
|
"""Return CLI App annotations from an immutable request snapshot."""
|
||||||
structured = metadata.get("cli_apps") if isinstance(metadata, Mapping) else None
|
structured = metadata.get("cli_apps") if isinstance(metadata, Mapping) else None
|
||||||
if isinstance(structured, list):
|
if isinstance(structured, list):
|
||||||
|
structured_items = cast(list[Any], structured)
|
||||||
mentions = [
|
mentions = [
|
||||||
item for item in structured
|
cast(Mapping[str, Any], item) for item in structured_items
|
||||||
if isinstance(item, Mapping) and isinstance(item.get("name"), str)
|
if isinstance(item, Mapping)
|
||||||
|
and isinstance(cast(Mapping[str, Any], item).get("name"), str)
|
||||||
]
|
]
|
||||||
if mentions:
|
if mentions:
|
||||||
return [
|
return [
|
||||||
@ -49,7 +51,10 @@ def runtime_lines_for_request(
|
|||||||
try:
|
try:
|
||||||
from nanobot.apps.cli import CliAppManager
|
from nanobot.apps.cli import CliAppManager
|
||||||
|
|
||||||
mentions = CliAppManager(workspace=workspace).mentioned_installed_apps(text)
|
mentions = cast(
|
||||||
|
list[dict[str, Any]],
|
||||||
|
CliAppManager(workspace=workspace).mentioned_installed_apps(text),
|
||||||
|
)
|
||||||
except Exception:
|
except Exception:
|
||||||
return []
|
return []
|
||||||
return [
|
return [
|
||||||
|
|||||||
@ -22,6 +22,7 @@ from nanobot.audio.transcription_registry import (
|
|||||||
)
|
)
|
||||||
from nanobot.config.loader import resolve_env_refs
|
from nanobot.config.loader import resolve_env_refs
|
||||||
from nanobot.config.paths import get_media_dir
|
from nanobot.config.paths import get_media_dir
|
||||||
|
from nanobot.config.schema import Config, ProviderConfig
|
||||||
from nanobot.providers.registry import find_by_name
|
from nanobot.providers.registry import find_by_name
|
||||||
from nanobot.utils.media_decode import FileSizeExceeded, save_base64_data_url
|
from nanobot.utils.media_decode import FileSizeExceeded, save_base64_data_url
|
||||||
|
|
||||||
@ -73,8 +74,9 @@ def _as_provider(value: Any) -> TranscriptionProviderName | None:
|
|||||||
return spec.name if spec else None
|
return spec.name if spec else None
|
||||||
|
|
||||||
|
|
||||||
def _provider_config(config: Any, provider: str) -> Any:
|
def _provider_config(config: Config, provider: str) -> ProviderConfig | None:
|
||||||
return getattr(getattr(config, "providers", None), provider, None)
|
value = getattr(config.providers, provider, None)
|
||||||
|
return value if isinstance(value, ProviderConfig) else None
|
||||||
|
|
||||||
|
|
||||||
def _provider_default_api_base(provider: str) -> str | None:
|
def _provider_default_api_base(provider: str) -> str | None:
|
||||||
@ -82,7 +84,10 @@ def _provider_default_api_base(provider: str) -> str | None:
|
|||||||
return spec.default_api_base if spec else None
|
return spec.default_api_base if spec else None
|
||||||
|
|
||||||
|
|
||||||
def _resolve_transcription_api_key(provider: str, provider_cfg: Any) -> str:
|
def _resolve_transcription_api_key(
|
||||||
|
provider: str,
|
||||||
|
provider_cfg: ProviderConfig | None,
|
||||||
|
) -> str:
|
||||||
api_key = resolve_env_refs(getattr(provider_cfg, "api_key", None) or "") if provider_cfg else ""
|
api_key = resolve_env_refs(getattr(provider_cfg, "api_key", None) or "") if provider_cfg else ""
|
||||||
if api_key:
|
if api_key:
|
||||||
return api_key
|
return api_key
|
||||||
@ -94,10 +99,13 @@ def _resolve_transcription_api_key(provider: str, provider_cfg: Any) -> str:
|
|||||||
return env_key
|
return env_key
|
||||||
|
|
||||||
env_key = spec.env_key if spec else ""
|
env_key = spec.env_key if spec else ""
|
||||||
return os.environ.get(env_key) if env_key else ""
|
return os.environ.get(env_key, "") if env_key else ""
|
||||||
|
|
||||||
|
|
||||||
def _resolve_transcription_api_base(provider: str, provider_cfg: Any) -> str:
|
def _resolve_transcription_api_base(
|
||||||
|
provider: str,
|
||||||
|
provider_cfg: ProviderConfig | None,
|
||||||
|
) -> str:
|
||||||
api_base = resolve_env_refs(getattr(provider_cfg, "api_base", None) or "") if provider_cfg else ""
|
api_base = resolve_env_refs(getattr(provider_cfg, "api_base", None) or "") if provider_cfg else ""
|
||||||
if api_base:
|
if api_base:
|
||||||
return api_base
|
return api_base
|
||||||
@ -111,7 +119,7 @@ def _extract_data_url_mime(url: str) -> str | None:
|
|||||||
return header[5:].split(";", 1)[0].strip().lower() or None
|
return header[5:].split(";", 1)[0].strip().lower() or None
|
||||||
|
|
||||||
|
|
||||||
def resolve_transcription_config(config: Any) -> EffectiveTranscriptionConfig:
|
def resolve_transcription_config(config: Config) -> EffectiveTranscriptionConfig:
|
||||||
"""Resolve top-level transcription settings with legacy channel fallback."""
|
"""Resolve top-level transcription settings with legacy channel fallback."""
|
||||||
top = getattr(config, "transcription", None)
|
top = getattr(config, "transcription", None)
|
||||||
channels = getattr(config, "channels", None)
|
channels = getattr(config, "channels", None)
|
||||||
|
|||||||
@ -9,7 +9,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
from collections.abc import Mapping
|
from collections.abc import Mapping
|
||||||
from dataclasses import dataclass, replace
|
from dataclasses import dataclass, replace
|
||||||
from typing import Any
|
from typing import Any, cast
|
||||||
|
|
||||||
from nanobot.bus.events import OutboundMessage
|
from nanobot.bus.events import OutboundMessage
|
||||||
|
|
||||||
@ -153,7 +153,11 @@ def _legacy_event_from_metadata(msg: OutboundMessage) -> OutboundEvent | None:
|
|||||||
)
|
)
|
||||||
if meta.get("_goal_state_sync"):
|
if meta.get("_goal_state_sync"):
|
||||||
goal_state = meta.get("goal_state")
|
goal_state = meta.get("goal_state")
|
||||||
return GoalStateSyncEvent(goal_state if isinstance(goal_state, dict) else {"active": False})
|
return GoalStateSyncEvent(
|
||||||
|
cast(dict[str, Any], goal_state)
|
||||||
|
if isinstance(goal_state, dict)
|
||||||
|
else {"active": False}
|
||||||
|
)
|
||||||
if meta.get("_goal_status"):
|
if meta.get("_goal_status"):
|
||||||
status = meta.get("goal_status")
|
status = meta.get("goal_status")
|
||||||
if not isinstance(status, str) or not status:
|
if not isinstance(status, str) or not status:
|
||||||
@ -166,7 +170,7 @@ def _legacy_event_from_metadata(msg: OutboundMessage) -> OutboundEvent | None:
|
|||||||
goal_state = meta.get("goal_state")
|
goal_state = meta.get("goal_state")
|
||||||
return TurnEndEvent(
|
return TurnEndEvent(
|
||||||
latency_ms=_metadata_int(meta, "latency_ms"),
|
latency_ms=_metadata_int(meta, "latency_ms"),
|
||||||
goal_state=goal_state if isinstance(goal_state, dict) else None,
|
goal_state=cast(dict[str, Any], goal_state) if isinstance(goal_state, dict) else None,
|
||||||
)
|
)
|
||||||
if meta.get("_session_updated"):
|
if meta.get("_session_updated"):
|
||||||
return SessionUpdatedEvent(scope=_metadata_str(meta, "_session_update_scope"))
|
return SessionUpdatedEvent(scope=_metadata_str(meta, "_session_update_scope"))
|
||||||
@ -203,8 +207,12 @@ def _legacy_event_from_metadata(msg: OutboundMessage) -> OutboundEvent | None:
|
|||||||
reasoning_delta=bool(meta.get("_reasoning_delta")),
|
reasoning_delta=bool(meta.get("_reasoning_delta")),
|
||||||
reasoning_end=bool(meta.get("_reasoning_end")),
|
reasoning_end=bool(meta.get("_reasoning_end")),
|
||||||
stream_id=_metadata_str(meta, "_stream_id"),
|
stream_id=_metadata_str(meta, "_stream_id"),
|
||||||
tool_events=tool_events if isinstance(tool_events, list) else None,
|
tool_events=cast(list[dict[str, Any]], tool_events)
|
||||||
file_edit_events=file_edit_events if isinstance(file_edit_events, list) else None,
|
if isinstance(tool_events, list)
|
||||||
|
else None,
|
||||||
|
file_edit_events=cast(list[dict[str, Any]], file_edit_events)
|
||||||
|
if isinstance(file_edit_events, list)
|
||||||
|
else None,
|
||||||
)
|
)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|||||||
@ -12,12 +12,15 @@ import contextlib
|
|||||||
import inspect
|
import inspect
|
||||||
from collections.abc import Awaitable, Callable
|
from collections.abc import Awaitable, Callable
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from typing import Any
|
from typing import TYPE_CHECKING, Any
|
||||||
|
|
||||||
from loguru import logger
|
from loguru import logger
|
||||||
|
|
||||||
from nanobot.bus.events import InboundMessage
|
from nanobot.bus.events import InboundMessage
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from nanobot.utils.llm_runtime import LLMRuntime
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
class RuntimeEventContext:
|
class RuntimeEventContext:
|
||||||
@ -52,7 +55,7 @@ class TurnCompleted:
|
|||||||
|
|
||||||
context: RuntimeEventContext
|
context: RuntimeEventContext
|
||||||
latency_ms: int | None = None
|
latency_ms: int | None = None
|
||||||
runtime: Any | None = None
|
runtime: LLMRuntime | None = None
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
@ -155,7 +158,7 @@ class RuntimeEventPublisher:
|
|||||||
def __init__(self, bus: RuntimeEventBus | None = None) -> None:
|
def __init__(self, bus: RuntimeEventBus | None = None) -> None:
|
||||||
self.bus = bus or RuntimeEventBus()
|
self.bus = bus or RuntimeEventBus()
|
||||||
self._turn_latency_ms: dict[str, int] = {}
|
self._turn_latency_ms: dict[str, int] = {}
|
||||||
self._turn_runtime: dict[str, Any] = {}
|
self._turn_runtime: dict[str, LLMRuntime] = {}
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _context(
|
def _context(
|
||||||
@ -174,7 +177,7 @@ class RuntimeEventPublisher:
|
|||||||
attributes=dict(attributes or {}),
|
attributes=dict(attributes or {}),
|
||||||
)
|
)
|
||||||
|
|
||||||
def record_turn_runtime(self, session_key: str, runtime: Any) -> None:
|
def record_turn_runtime(self, session_key: str, runtime: LLMRuntime) -> None:
|
||||||
self._turn_runtime[session_key] = runtime
|
self._turn_runtime[session_key] = runtime
|
||||||
|
|
||||||
def record_turn_latency(self, session_key: str, latency_ms: int | None) -> None:
|
def record_turn_latency(self, session_key: str, latency_ms: int | None) -> None:
|
||||||
|
|||||||
@ -4,7 +4,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
from abc import ABC, abstractmethod
|
from abc import ABC, abstractmethod
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any
|
from typing import Any, cast
|
||||||
|
|
||||||
from loguru import logger
|
from loguru import logger
|
||||||
|
|
||||||
@ -201,13 +201,21 @@ class BaseChannel(ABC):
|
|||||||
def supports_streaming(self) -> bool:
|
def supports_streaming(self) -> bool:
|
||||||
"""True when config enables streaming AND this subclass implements send_delta."""
|
"""True when config enables streaming AND this subclass implements send_delta."""
|
||||||
cfg = self.config
|
cfg = self.config
|
||||||
streaming = cfg.get("streaming", False) if isinstance(cfg, dict) else getattr(cfg, "streaming", False)
|
config_mapping = cast(dict[str, Any], cfg) if isinstance(cfg, dict) else None
|
||||||
|
streaming: Any = (
|
||||||
|
config_mapping.get("streaming", False)
|
||||||
|
if config_mapping is not None
|
||||||
|
else getattr(cast(Any, cfg), "streaming", False)
|
||||||
|
)
|
||||||
return bool(streaming) and type(self).send_delta is not BaseChannel.send_delta
|
return bool(streaming) and type(self).send_delta is not BaseChannel.send_delta
|
||||||
|
|
||||||
def is_allowed(self, sender_id: str) -> bool:
|
def is_allowed(self, sender_id: str) -> bool:
|
||||||
"""Check sender permission: star > allowlist > pairing store > deny."""
|
"""Check sender permission: star > allowlist > pairing store > deny."""
|
||||||
if isinstance(self.config, dict):
|
if isinstance(self.config, dict):
|
||||||
allow_list = self.config.get("allow_from") or self.config.get("allowFrom") or []
|
config_mapping = cast(dict[str, Any], self.config)
|
||||||
|
allow_list: Any = (
|
||||||
|
config_mapping.get("allow_from") or config_mapping.get("allowFrom") or []
|
||||||
|
)
|
||||||
else:
|
else:
|
||||||
allow_list = getattr(self.config, "allow_from", None) or []
|
allow_list = getattr(self.config, "allow_from", None) or []
|
||||||
if "*" in allow_list:
|
if "*" in allow_list:
|
||||||
|
|||||||
@ -6,7 +6,7 @@ from collections.abc import Iterable
|
|||||||
from copy import deepcopy
|
from copy import deepcopy
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import TYPE_CHECKING, Any, Callable, Literal
|
from typing import TYPE_CHECKING, Any, Callable, Literal, TypeGuard, cast
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from nanobot.channels.plugin import ChannelPlugin
|
from nanobot.channels.plugin import ChannelPlugin
|
||||||
@ -22,6 +22,8 @@ class ChannelValidationContext:
|
|||||||
allow_local_service_access: bool = False
|
allow_local_service_access: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
# Keep callback contracts precise for static consumers. The public adapters below
|
||||||
|
# still validate third-party implementations at runtime.
|
||||||
SetupValidator = Callable[[dict[str, Any], ChannelValidationContext], dict[str, Any]]
|
SetupValidator = Callable[[dict[str, Any], ChannelValidationContext], dict[str, Any]]
|
||||||
DefaultConfigFactory = Callable[[], dict[str, Any]]
|
DefaultConfigFactory = Callable[[], dict[str, Any]]
|
||||||
InstanceSpecsFactory = Callable[..., Iterable["ChannelInstanceSpec"]]
|
InstanceSpecsFactory = Callable[..., Iterable["ChannelInstanceSpec"]]
|
||||||
@ -87,7 +89,7 @@ class ChannelActivation:
|
|||||||
instances = (
|
instances = (
|
||||||
tuple(
|
tuple(
|
||||||
cls.from_config(item, include_instances=True)
|
cls.from_config(item, include_instances=True)
|
||||||
for item in raw_instances
|
for item in cast(list[Any], raw_instances)
|
||||||
if _config_mapping(item) is not None
|
if _config_mapping(item) is not None
|
||||||
)
|
)
|
||||||
if isinstance(raw_instances, list)
|
if isinstance(raw_instances, list)
|
||||||
@ -193,7 +195,7 @@ class ChannelSetupSpec:
|
|||||||
def to_public_dict(self, channel_name: str) -> dict[str, Any]:
|
def to_public_dict(self, channel_name: str) -> dict[str, Any]:
|
||||||
"""Serialize the writable setup contract for generic WebUI consumers."""
|
"""Serialize the writable setup contract for generic WebUI consumers."""
|
||||||
simple_required = set(self.simple_required_fields)
|
simple_required = set(self.simple_required_fields)
|
||||||
fields = []
|
fields: list[dict[str, Any]] = []
|
||||||
for name, field in self.fields.items():
|
for name, field in self.fields.items():
|
||||||
if not field.writable:
|
if not field.writable:
|
||||||
continue
|
continue
|
||||||
@ -268,35 +270,37 @@ def channel_default_config(plugin: ChannelPlugin) -> dict[str, Any]:
|
|||||||
defaults: dict[str, Any] = {"enabled": plugin.default_enabled}
|
defaults: dict[str, Any] = {"enabled": plugin.default_enabled}
|
||||||
if plugin.setup is not None:
|
if plugin.setup is not None:
|
||||||
for name, field in plugin.setup.fields.items():
|
for name, field in plugin.setup.fields.items():
|
||||||
value = field.default
|
value: Any = field.default
|
||||||
if value is None:
|
if value is None:
|
||||||
value = {
|
fallback_defaults: dict[str, Any] = {
|
||||||
"string": "",
|
"string": "",
|
||||||
"secret": "",
|
"secret": "",
|
||||||
"list": [],
|
"list": [],
|
||||||
"bool": False,
|
"bool": False,
|
||||||
}.get(field.kind, _MISSING)
|
}
|
||||||
|
value = fallback_defaults.get(field.kind, _MISSING)
|
||||||
if value is not _MISSING:
|
if value is not _MISSING:
|
||||||
_assign_channel_field(defaults, name, deepcopy(value))
|
_assign_channel_field(defaults, name, deepcopy(value))
|
||||||
|
|
||||||
factory = plugin.management.default_config
|
factory = plugin.management.default_config
|
||||||
if factory is None:
|
if factory is None:
|
||||||
return defaults
|
return defaults
|
||||||
values = factory()
|
values_raw = cast(object, factory())
|
||||||
if not isinstance(values, dict):
|
if not isinstance(values_raw, dict):
|
||||||
raise TypeError(f"ChannelPlugin.management.default_config for '{plugin.name}' must return a dict")
|
raise TypeError(f"ChannelPlugin.management.default_config for '{plugin.name}' must return a dict")
|
||||||
return merge_missing_defaults(values, defaults)
|
values = cast(dict[str, Any], values_raw)
|
||||||
|
return cast(dict[str, Any], merge_missing_defaults(values, defaults))
|
||||||
|
|
||||||
|
|
||||||
def _assign_channel_field(values: dict[str, Any], field: str, value: Any) -> None:
|
def _assign_channel_field(values: dict[str, Any], field: str, value: Any) -> None:
|
||||||
target = values
|
target = values
|
||||||
parts = field.split(".")
|
parts = field.split(".")
|
||||||
for part in parts[:-1]:
|
for part in parts[:-1]:
|
||||||
nested = target.get(part)
|
nested: object = target.get(part)
|
||||||
if not isinstance(nested, dict):
|
if not isinstance(nested, dict):
|
||||||
nested = {}
|
nested = {}
|
||||||
target[part] = nested
|
target[part] = nested
|
||||||
target = nested
|
target = cast(dict[str, Any], nested)
|
||||||
target[parts[-1]] = value
|
target[parts[-1]] = value
|
||||||
|
|
||||||
|
|
||||||
@ -327,27 +331,28 @@ def channel_instance_specs(
|
|||||||
factory = plugin.management.instance_specs
|
factory = plugin.management.instance_specs
|
||||||
if factory is None:
|
if factory is None:
|
||||||
activation = ChannelActivation.from_config(section)
|
activation = ChannelActivation.from_config(section)
|
||||||
raw_specs: Iterable[ChannelInstanceSpec] = (
|
raw_specs: object = (
|
||||||
[]
|
[]
|
||||||
if enabled_only and not activation.resolve(default=plugin.default_enabled)
|
if enabled_only and not activation.resolve(default=plugin.default_enabled)
|
||||||
else [ChannelInstanceSpec(instance_id="default", config=section)]
|
else [ChannelInstanceSpec(instance_id="default", config=section)]
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
raw_specs = factory(section, enabled_only=enabled_only)
|
raw_specs = cast(object, factory(section, enabled_only=enabled_only))
|
||||||
if not isinstance(raw_specs, Iterable):
|
if not isinstance(raw_specs, Iterable):
|
||||||
raise TypeError(
|
raise TypeError(
|
||||||
f"ChannelPlugin.management.instance_specs for '{plugin.name}' must return an iterable"
|
f"ChannelPlugin.management.instance_specs for '{plugin.name}' must return an iterable"
|
||||||
)
|
)
|
||||||
specs = list(raw_specs)
|
specs = list(cast(Iterable[object], raw_specs))
|
||||||
|
if not _all_channel_instance_specs(specs):
|
||||||
|
raise TypeError(
|
||||||
|
f"ChannelPlugin.management.instance_specs for '{plugin.name}' returned an invalid item"
|
||||||
|
)
|
||||||
|
|
||||||
instance_ids: set[str] = set()
|
instance_ids: set[str] = set()
|
||||||
runtime_names: set[str] = set()
|
runtime_names: set[str] = set()
|
||||||
for spec in specs:
|
for spec in specs:
|
||||||
if not isinstance(spec, ChannelInstanceSpec):
|
instance_id = cast(object, spec.instance_id)
|
||||||
raise TypeError(
|
if not isinstance(instance_id, str) or not instance_id.strip():
|
||||||
f"ChannelPlugin.management.instance_specs for '{plugin.name}' returned an invalid item"
|
|
||||||
)
|
|
||||||
if not isinstance(spec.instance_id, str) or not spec.instance_id.strip():
|
|
||||||
raise ValueError(
|
raise ValueError(
|
||||||
f"ChannelPlugin.management.instance_specs for '{plugin.name}' returned an empty instance id"
|
f"ChannelPlugin.management.instance_specs for '{plugin.name}' returned an empty instance id"
|
||||||
)
|
)
|
||||||
@ -367,6 +372,12 @@ def channel_instance_specs(
|
|||||||
return specs
|
return specs
|
||||||
|
|
||||||
|
|
||||||
|
def _all_channel_instance_specs(
|
||||||
|
values: list[object],
|
||||||
|
) -> TypeGuard[list[ChannelInstanceSpec]]:
|
||||||
|
return all(isinstance(value, ChannelInstanceSpec) for value in values)
|
||||||
|
|
||||||
|
|
||||||
def resolve_channel_action_target(
|
def resolve_channel_action_target(
|
||||||
requested_instance_id: str | None,
|
requested_instance_id: str | None,
|
||||||
) -> str:
|
) -> str:
|
||||||
@ -393,8 +404,17 @@ def channel_instance_config(
|
|||||||
return {}
|
return {}
|
||||||
config = selected.config
|
config = selected.config
|
||||||
if hasattr(config, "model_dump"):
|
if hasattr(config, "model_dump"):
|
||||||
return dict(config.model_dump(mode="json", by_alias=True))
|
dumped: dict[str, Any] = config.model_dump(mode="json", by_alias=True)
|
||||||
return dict(config) if isinstance(config, dict) else {}
|
copied: dict[str, Any] = {}
|
||||||
|
for key in dumped:
|
||||||
|
copied[key] = dumped[key]
|
||||||
|
return copied
|
||||||
|
if not isinstance(config, dict):
|
||||||
|
return {}
|
||||||
|
copied_config: dict[str, Any] = {}
|
||||||
|
for key, value in cast(dict[object, Any], config).items():
|
||||||
|
copied_config[cast(str, key)] = value
|
||||||
|
return copied_config
|
||||||
|
|
||||||
|
|
||||||
def channel_update_instance_config(
|
def channel_update_instance_config(
|
||||||
@ -409,7 +429,10 @@ def channel_update_instance_config(
|
|||||||
if instance_id not in {"", "default"}:
|
if instance_id not in {"", "default"}:
|
||||||
raise ValueError(f"{plugin.name} does not support multiple instances")
|
raise ValueError(f"{plugin.name} does not support multiple instances")
|
||||||
return values
|
return values
|
||||||
return updater(section, values, instance_id=instance_id)
|
updated = cast(object, updater(section, values, instance_id=instance_id))
|
||||||
|
if not isinstance(updated, dict):
|
||||||
|
raise TypeError(f"ChannelPlugin.management.update_instance_config for '{plugin.name}' must return a dict")
|
||||||
|
return cast(dict[str, Any], updated)
|
||||||
|
|
||||||
|
|
||||||
def channel_set_config_enabled(
|
def channel_set_config_enabled(
|
||||||
@ -423,7 +446,7 @@ def channel_set_config_enabled(
|
|||||||
from nanobot.config.loader import merge_missing_defaults
|
from nanobot.config.loader import merge_missing_defaults
|
||||||
|
|
||||||
values = channel_instance_config(plugin, section, instance_id=instance_id)
|
values = channel_instance_config(plugin, section, instance_id=instance_id)
|
||||||
values = merge_missing_defaults(values, channel_default_config(plugin))
|
values = cast(dict[str, Any], merge_missing_defaults(values, channel_default_config(plugin)))
|
||||||
values["enabled"] = enabled
|
values["enabled"] = enabled
|
||||||
return channel_update_instance_config(
|
return channel_update_instance_config(
|
||||||
plugin,
|
plugin,
|
||||||
@ -440,12 +463,16 @@ def channel_feature_instances(
|
|||||||
setup_spec: ChannelSetupSpec | None = None,
|
setup_spec: ChannelSetupSpec | None = None,
|
||||||
) -> list[dict[str, Any]] | None:
|
) -> list[dict[str, Any]] | None:
|
||||||
factory = plugin.management.feature_instances
|
factory = plugin.management.feature_instances
|
||||||
overrides = factory(section, setup_spec=setup_spec) if factory is not None else None
|
overrides = (
|
||||||
|
cast(object, factory(section, setup_spec=setup_spec))
|
||||||
|
if factory is not None
|
||||||
|
else None
|
||||||
|
)
|
||||||
if overrides is None and not plugin.management.multi_instance:
|
if overrides is None and not plugin.management.multi_instance:
|
||||||
return None
|
return None
|
||||||
if overrides is not None and (
|
if overrides is not None and (
|
||||||
not isinstance(overrides, list)
|
not isinstance(overrides, list)
|
||||||
or any(not isinstance(instance, dict) for instance in overrides)
|
or any(not isinstance(instance, dict) for instance in cast(list[object], overrides))
|
||||||
):
|
):
|
||||||
raise TypeError(
|
raise TypeError(
|
||||||
f"ChannelPlugin.management.feature_instances for '{plugin.name}' "
|
f"ChannelPlugin.management.feature_instances for '{plugin.name}' "
|
||||||
@ -470,7 +497,8 @@ def channel_feature_instances(
|
|||||||
|
|
||||||
by_id = {instance["id"]: instance for instance in instances}
|
by_id = {instance["id"]: instance for instance in instances}
|
||||||
seen: set[str] = set()
|
seen: set[str] = set()
|
||||||
for override in overrides:
|
for override_value in cast(list[object], overrides):
|
||||||
|
override = cast(dict[str, Any], override_value)
|
||||||
instance_id = override.get("id")
|
instance_id = override.get("id")
|
||||||
if not isinstance(instance_id, str) or instance_id not in by_id:
|
if not isinstance(instance_id, str) or instance_id not in by_id:
|
||||||
raise ValueError(
|
raise ValueError(
|
||||||
@ -514,20 +542,21 @@ def _validate_runtime_name(plugin: ChannelPlugin, runtime_name: Any) -> None:
|
|||||||
|
|
||||||
|
|
||||||
def channel_field_value(values: Any, field_path: str) -> Any:
|
def channel_field_value(values: Any, field_path: str) -> Any:
|
||||||
current = values
|
current: Any = values
|
||||||
for part in field_path.split("."):
|
for part in field_path.split("."):
|
||||||
candidates = (part, _camel_to_snake(part))
|
candidates = (part, _camel_to_snake(part))
|
||||||
if isinstance(current, dict):
|
if isinstance(current, dict):
|
||||||
for candidate in candidates:
|
for candidate in candidates:
|
||||||
if candidate in current:
|
if candidate in current:
|
||||||
current = current[candidate]
|
current = cast(Any, current)[candidate]
|
||||||
break
|
break
|
||||||
else:
|
else:
|
||||||
return None
|
return None
|
||||||
continue
|
continue
|
||||||
for candidate in candidates:
|
for candidate in candidates:
|
||||||
if hasattr(current, candidate):
|
current_value = current
|
||||||
current = getattr(current, candidate)
|
if hasattr(current_value, candidate):
|
||||||
|
current = getattr(current_value, candidate)
|
||||||
break
|
break
|
||||||
else:
|
else:
|
||||||
return None
|
return None
|
||||||
@ -542,7 +571,7 @@ def stringify_channel_value(value: Any) -> str:
|
|||||||
if isinstance(value, bool):
|
if isinstance(value, bool):
|
||||||
return "true" if value else "false"
|
return "true" if value else "false"
|
||||||
if isinstance(value, list):
|
if isinstance(value, list):
|
||||||
return ", ".join(str(item) for item in value)
|
return ", ".join(str(item) for item in cast(list[Any], value))
|
||||||
return str(value)
|
return str(value)
|
||||||
|
|
||||||
|
|
||||||
@ -586,8 +615,8 @@ def _channel_feature_instance(
|
|||||||
def _config_mapping(value: Any) -> dict[str, Any] | None:
|
def _config_mapping(value: Any) -> dict[str, Any] | None:
|
||||||
if hasattr(value, "model_dump"):
|
if hasattr(value, "model_dump"):
|
||||||
dumped = value.model_dump(mode="json", by_alias=True)
|
dumped = value.model_dump(mode="json", by_alias=True)
|
||||||
return dumped if isinstance(dumped, dict) else None
|
return cast(dict[str, Any], dumped) if isinstance(dumped, dict) else None
|
||||||
return value if isinstance(value, dict) else None
|
return cast(dict[str, Any], value) if isinstance(value, dict) else None
|
||||||
|
|
||||||
|
|
||||||
def _camel_to_snake(value: str) -> str:
|
def _camel_to_snake(value: str) -> str:
|
||||||
|
|||||||
@ -1,3 +1,4 @@
|
|||||||
|
# pyright: reportConstantRedefinition=false, reportMissingTypeStubs=false, reportPrivateUsage=false
|
||||||
"""DingTalk/DingDing channel implementation using Stream Mode."""
|
"""DingTalk/DingDing channel implementation using Stream Mode."""
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
@ -10,7 +11,7 @@ from contextlib import suppress
|
|||||||
from inspect import isawaitable
|
from inspect import isawaitable
|
||||||
from io import BytesIO
|
from io import BytesIO
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any
|
from typing import Any, cast
|
||||||
from urllib.parse import unquote, urljoin, urlparse
|
from urllib.parse import unquote, urljoin, urlparse
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
@ -36,11 +37,17 @@ def _escape_markdown_sender_name(value: str) -> str:
|
|||||||
for char in normalized
|
for char in normalized
|
||||||
)
|
)
|
||||||
|
|
||||||
|
DINGTALK_AVAILABLE = False
|
||||||
|
AckMessage: Any = None
|
||||||
|
CallbackHandler: Any = object
|
||||||
|
Credential: Any = None
|
||||||
|
DingTalkStreamClient: Any = None
|
||||||
|
ChatbotMessage: Any = None
|
||||||
|
|
||||||
try:
|
try:
|
||||||
from dingtalk_stream import (
|
from dingtalk_stream import (
|
||||||
AckMessage,
|
AckMessage,
|
||||||
CallbackHandler,
|
CallbackHandler,
|
||||||
CallbackMessage,
|
|
||||||
Credential,
|
Credential,
|
||||||
DingTalkStreamClient,
|
DingTalkStreamClient,
|
||||||
)
|
)
|
||||||
@ -48,41 +55,41 @@ try:
|
|||||||
|
|
||||||
DINGTALK_AVAILABLE = True
|
DINGTALK_AVAILABLE = True
|
||||||
except ImportError:
|
except ImportError:
|
||||||
DINGTALK_AVAILABLE = False
|
pass
|
||||||
# Fallback so class definitions don't crash at module level
|
|
||||||
CallbackHandler = object # type: ignore[assignment,misc]
|
|
||||||
CallbackMessage = None # type: ignore[assignment,misc]
|
|
||||||
AckMessage = None # type: ignore[assignment,misc]
|
|
||||||
ChatbotMessage = None # type: ignore[assignment,misc]
|
|
||||||
|
|
||||||
|
|
||||||
class NanobotDingTalkHandler(CallbackHandler):
|
_CallbackHandlerBase = CallbackHandler
|
||||||
|
|
||||||
|
|
||||||
|
class NanobotDingTalkHandler(_CallbackHandlerBase):
|
||||||
"""
|
"""
|
||||||
Standard DingTalk Stream SDK Callback Handler.
|
Standard DingTalk Stream SDK Callback Handler.
|
||||||
Parses incoming messages and forwards them to the Nanobot channel.
|
Parses incoming messages and forwards them to the Nanobot channel.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self, channel: "DingTalkChannel"):
|
def __init__(self, channel: "DingTalkChannel"):
|
||||||
super().__init__()
|
super().__init__() # pyright: ignore[reportUnknownMemberType]
|
||||||
self.channel = channel
|
self.channel = channel
|
||||||
|
|
||||||
async def process(self, message: CallbackMessage):
|
async def process(self, message: Any) -> tuple[Any, str]:
|
||||||
"""Process incoming stream message."""
|
"""Process incoming stream message."""
|
||||||
try:
|
try:
|
||||||
# Parse using SDK's ChatbotMessage for robust handling
|
# Parse using SDK's ChatbotMessage for robust handling
|
||||||
chatbot_msg = ChatbotMessage.from_dict(message.data)
|
chatbot_msg: Any = ChatbotMessage.from_dict(message.data)
|
||||||
|
message_data = cast(dict[str, Any], message.data)
|
||||||
|
|
||||||
# Extract text content; fall back to raw dict if SDK object is empty
|
# Extract text content; fall back to raw dict if SDK object is empty
|
||||||
content = ""
|
content = ""
|
||||||
if chatbot_msg.text:
|
if chatbot_msg.text:
|
||||||
content = chatbot_msg.text.content.strip()
|
content = cast(str, chatbot_msg.text.content).strip()
|
||||||
elif chatbot_msg.extensions.get("content", {}).get("recognition"):
|
elif chatbot_msg.extensions.get("content", {}).get("recognition"):
|
||||||
content = chatbot_msg.extensions["content"]["recognition"].strip()
|
content = cast(str, chatbot_msg.extensions["content"]["recognition"]).strip()
|
||||||
if not content:
|
if not content:
|
||||||
content = message.data.get("text", {}).get("content", "").strip()
|
text_data = cast(dict[str, Any], message_data.get("text", {}))
|
||||||
|
content = cast(str, text_data.get("content", "")).strip()
|
||||||
|
|
||||||
# Handle file/image messages
|
# Handle file/image messages
|
||||||
file_paths = []
|
file_paths: list[str] = []
|
||||||
if chatbot_msg.message_type == "picture" and chatbot_msg.image_content:
|
if chatbot_msg.message_type == "picture" and chatbot_msg.image_content:
|
||||||
download_code = chatbot_msg.image_content.download_code
|
download_code = chatbot_msg.image_content.download_code
|
||||||
if download_code:
|
if download_code:
|
||||||
@ -93,8 +100,18 @@ class NanobotDingTalkHandler(CallbackHandler):
|
|||||||
content = content or "[Image]"
|
content = content or "[Image]"
|
||||||
|
|
||||||
elif chatbot_msg.message_type == "file":
|
elif chatbot_msg.message_type == "file":
|
||||||
download_code = message.data.get("content", {}).get("downloadCode") or message.data.get("downloadCode")
|
message_content = cast(dict[str, Any], message_data.get("content", {}))
|
||||||
fname = message.data.get("content", {}).get("fileName") or message.data.get("fileName") or "file"
|
download_code = cast(
|
||||||
|
str,
|
||||||
|
message_content.get("downloadCode")
|
||||||
|
or message_data.get("downloadCode"),
|
||||||
|
)
|
||||||
|
fname = cast(
|
||||||
|
str,
|
||||||
|
message_content.get("fileName")
|
||||||
|
or message_data.get("fileName")
|
||||||
|
or "file",
|
||||||
|
)
|
||||||
if download_code:
|
if download_code:
|
||||||
sender_uid = chatbot_msg.sender_staff_id or chatbot_msg.sender_id or "unknown"
|
sender_uid = chatbot_msg.sender_staff_id or chatbot_msg.sender_id or "unknown"
|
||||||
fp = await self.channel._download_dingtalk_file(download_code, fname, sender_uid)
|
fp = await self.channel._download_dingtalk_file(download_code, fname, sender_uid)
|
||||||
@ -103,13 +120,17 @@ class NanobotDingTalkHandler(CallbackHandler):
|
|||||||
content = content or "[File]"
|
content = content or "[File]"
|
||||||
|
|
||||||
elif chatbot_msg.message_type == "richText" and chatbot_msg.rich_text_content:
|
elif chatbot_msg.message_type == "richText" and chatbot_msg.rich_text_content:
|
||||||
rich_list = chatbot_msg.rich_text_content.rich_text_list or []
|
rich_list = cast(
|
||||||
for item in rich_list:
|
list[object],
|
||||||
if not isinstance(item, dict):
|
chatbot_msg.rich_text_content.rich_text_list or [],
|
||||||
|
)
|
||||||
|
for item_value in rich_list:
|
||||||
|
if not isinstance(item_value, dict):
|
||||||
continue
|
continue
|
||||||
|
item = cast(dict[str, Any], item_value)
|
||||||
# A rich-text item may carry text and/or a downloadCode; the
|
# A rich-text item may carry text and/or a downloadCode; the
|
||||||
# DingTalk SDK treats them independently, so handle both.
|
# DingTalk SDK treats them independently, so handle both.
|
||||||
t = item.get("text", "").strip()
|
t = cast(str, item.get("text", "")).strip()
|
||||||
if t:
|
if t:
|
||||||
fmt = item.get("type", "")
|
fmt = item.get("type", "")
|
||||||
if fmt == "bold":
|
if fmt == "bold":
|
||||||
@ -124,8 +145,8 @@ class NanobotDingTalkHandler(CallbackHandler):
|
|||||||
formatted = t
|
formatted = t
|
||||||
content = (content + " " + formatted).strip() if content else formatted
|
content = (content + " " + formatted).strip() if content else formatted
|
||||||
if item.get("downloadCode"):
|
if item.get("downloadCode"):
|
||||||
dc = item["downloadCode"]
|
dc = cast(str, item["downloadCode"])
|
||||||
fname = item.get("fileName") or "file"
|
fname = cast(str, item.get("fileName") or "file")
|
||||||
sender_uid = chatbot_msg.sender_staff_id or chatbot_msg.sender_id or "unknown"
|
sender_uid = chatbot_msg.sender_staff_id or chatbot_msg.sender_id or "unknown"
|
||||||
fp = await self.channel._download_dingtalk_file(dc, fname, sender_uid)
|
fp = await self.channel._download_dingtalk_file(dc, fname, sender_uid)
|
||||||
if fp:
|
if fp:
|
||||||
@ -143,13 +164,22 @@ class NanobotDingTalkHandler(CallbackHandler):
|
|||||||
)
|
)
|
||||||
return AckMessage.STATUS_OK, "OK"
|
return AckMessage.STATUS_OK, "OK"
|
||||||
|
|
||||||
sender_id = chatbot_msg.sender_staff_id or chatbot_msg.sender_id
|
sender_id = cast(
|
||||||
sender_name = chatbot_msg.sender_nick or "Unknown"
|
str | None,
|
||||||
|
chatbot_msg.sender_staff_id or chatbot_msg.sender_id,
|
||||||
|
)
|
||||||
|
sender_name = cast(str, chatbot_msg.sender_nick or "Unknown")
|
||||||
|
|
||||||
conversation_type = message.data.get("conversationType")
|
conversation_type = cast(
|
||||||
|
str | None,
|
||||||
|
message_data.get("conversationType"),
|
||||||
|
)
|
||||||
conversation_id = (
|
conversation_id = (
|
||||||
message.data.get("conversationId")
|
cast(
|
||||||
or message.data.get("openConversationId")
|
str | None,
|
||||||
|
message_data.get("conversationId")
|
||||||
|
or message_data.get("openConversationId"),
|
||||||
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
self.channel.logger.info("Received message from {} ({}): {}", sender_name, sender_id, content)
|
self.channel.logger.info("Received message from {} ({}): {}", sender_name, sender_id, content)
|
||||||
@ -218,14 +248,14 @@ class DingTalkChannel(BaseChannel):
|
|||||||
self.config: DingTalkConfig = config
|
self.config: DingTalkConfig = config
|
||||||
self._client: Any = None
|
self._client: Any = None
|
||||||
self._http: httpx.AsyncClient | None = None
|
self._http: httpx.AsyncClient | None = None
|
||||||
self._start_task: asyncio.Task | None = None
|
self._start_task: asyncio.Task[Any] | None = None
|
||||||
|
|
||||||
# Access Token management for sending messages
|
# Access Token management for sending messages
|
||||||
self._access_token: str | None = None
|
self._access_token: str | None = None
|
||||||
self._token_expiry: float = 0
|
self._token_expiry: float = 0
|
||||||
|
|
||||||
# Hold references to background tasks to prevent GC
|
# Hold references to background tasks to prevent GC
|
||||||
self._background_tasks: set[asyncio.Task] = set()
|
self._background_tasks: set[asyncio.Task[None]] = set()
|
||||||
|
|
||||||
async def start(self) -> None:
|
async def start(self) -> None:
|
||||||
"""Start the DingTalk bot with Stream Mode."""
|
"""Start the DingTalk bot with Stream Mode."""
|
||||||
@ -575,7 +605,11 @@ class DingTalkChannel(BaseChannel):
|
|||||||
try:
|
try:
|
||||||
resp = await self._http.post(url, files=files)
|
resp = await self._http.post(url, files=files)
|
||||||
text = resp.text
|
text = resp.text
|
||||||
result = resp.json() if resp.headers.get("content-type", "").startswith("application/json") else {}
|
result = (
|
||||||
|
cast(dict[str, Any], resp.json())
|
||||||
|
if resp.headers.get("content-type", "").startswith("application/json")
|
||||||
|
else {}
|
||||||
|
)
|
||||||
if resp.status_code >= 400:
|
if resp.status_code >= 400:
|
||||||
self.logger.error("media upload failed status={} type={} body={}", resp.status_code, media_type, text[:500])
|
self.logger.error("media upload failed status={} type={} body={}", resp.status_code, media_type, text[:500])
|
||||||
return None
|
return None
|
||||||
@ -583,7 +617,7 @@ class DingTalkChannel(BaseChannel):
|
|||||||
if errcode != 0:
|
if errcode != 0:
|
||||||
self.logger.error("media upload api error type={} errcode={} body={}", media_type, errcode, text[:500])
|
self.logger.error("media upload api error type={} errcode={} body={}", media_type, errcode, text[:500])
|
||||||
return None
|
return None
|
||||||
sub = result.get("result") or {}
|
sub = cast(dict[str, Any], result.get("result") or {})
|
||||||
media_id = result.get("media_id") or result.get("mediaId") or sub.get("media_id") or sub.get("mediaId")
|
media_id = result.get("media_id") or result.get("mediaId") or sub.get("media_id") or sub.get("mediaId")
|
||||||
if not media_id:
|
if not media_id:
|
||||||
self.logger.error("media upload missing media_id body={}", text[:500])
|
self.logger.error("media upload missing media_id body={}", text[:500])
|
||||||
@ -634,7 +668,7 @@ class DingTalkChannel(BaseChannel):
|
|||||||
self.logger.error("send failed msgKey={} status={} body={}", msg_key, resp.status_code, body[:500])
|
self.logger.error("send failed msgKey={} status={} body={}", msg_key, resp.status_code, body[:500])
|
||||||
return False
|
return False
|
||||||
try:
|
try:
|
||||||
result = resp.json()
|
result = cast(dict[str, Any], resp.json())
|
||||||
except Exception:
|
except Exception:
|
||||||
result = {}
|
result = {}
|
||||||
errcode = result.get("errcode")
|
errcode = result.get("errcode")
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
"""Discord channel implementation using discord.py."""
|
"""Discord channel implementation using discord.py."""
|
||||||
|
# pyright: reportPrivateUsage=false, reportUnusedFunction=false
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
@ -8,7 +9,7 @@ import time
|
|||||||
from contextlib import suppress
|
from contextlib import suppress
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import TYPE_CHECKING, Any, Literal
|
from typing import TYPE_CHECKING, Any, Literal, cast
|
||||||
|
|
||||||
from pydantic import Field
|
from pydantic import Field
|
||||||
|
|
||||||
@ -43,7 +44,7 @@ class _StreamBuf:
|
|||||||
"""Per-chat streaming accumulator for progressive Discord message edits."""
|
"""Per-chat streaming accumulator for progressive Discord message edits."""
|
||||||
|
|
||||||
text: str = ""
|
text: str = ""
|
||||||
message: Any | None = None
|
message: discord.Message | None = None
|
||||||
last_edit: float = 0.0
|
last_edit: float = 0.0
|
||||||
stream_id: str | None = None
|
stream_id: str | None = None
|
||||||
|
|
||||||
@ -266,13 +267,14 @@ if DISCORD_AVAILABLE:
|
|||||||
self._channel.logger.warning("channel {} unavailable: {}", msg.chat_id, e)
|
self._channel.logger.warning("channel {} unavailable: {}", msg.chat_id, e)
|
||||||
raise
|
raise
|
||||||
|
|
||||||
reference, mention_settings = self._build_reply_context(channel, msg.reply_to)
|
messageable_channel = cast(Messageable, channel)
|
||||||
|
reference, mention_settings = self._build_reply_context(messageable_channel, msg.reply_to)
|
||||||
sent_media = False
|
sent_media = False
|
||||||
failed_media: list[str] = []
|
failed_media: list[str] = []
|
||||||
|
|
||||||
for index, media_path in enumerate(msg.media or []):
|
for index, media_path in enumerate(msg.media or []):
|
||||||
if await self._send_file(
|
if await self._send_file(
|
||||||
channel,
|
messageable_channel,
|
||||||
media_path,
|
media_path,
|
||||||
reference=reference if index == 0 else None,
|
reference=reference if index == 0 else None,
|
||||||
mention_settings=mention_settings,
|
mention_settings=mention_settings,
|
||||||
@ -288,7 +290,7 @@ if DISCORD_AVAILABLE:
|
|||||||
if index == 0 and reference is not None and not sent_media:
|
if index == 0 and reference is not None and not sent_media:
|
||||||
kwargs["reference"] = reference
|
kwargs["reference"] = reference
|
||||||
kwargs["allowed_mentions"] = mention_settings
|
kwargs["allowed_mentions"] = mention_settings
|
||||||
await channel.send(**kwargs)
|
await messageable_channel.send(**kwargs)
|
||||||
|
|
||||||
async def _send_file(
|
async def _send_file(
|
||||||
self,
|
self,
|
||||||
@ -344,7 +346,7 @@ if DISCORD_AVAILABLE:
|
|||||||
self._channel.logger.warning("Invalid reply target: {}", reply_to)
|
self._channel.logger.warning("Invalid reply target: {}", reply_to)
|
||||||
return None, mention_settings
|
return None, mention_settings
|
||||||
|
|
||||||
return channel.get_partial_message(message_id), mention_settings
|
return cast(Any, channel).get_partial_message(message_id), mention_settings
|
||||||
|
|
||||||
|
|
||||||
class DiscordChannel(BaseChannel):
|
class DiscordChannel(BaseChannel):
|
||||||
@ -423,8 +425,8 @@ class DiscordChannel(BaseChannel):
|
|||||||
import aiohttp
|
import aiohttp
|
||||||
|
|
||||||
proxy_auth = aiohttp.BasicAuth(
|
proxy_auth = aiohttp.BasicAuth(
|
||||||
login=self.config.proxy_username,
|
login=cast(str, self.config.proxy_username),
|
||||||
password=self.config.proxy_password,
|
password=cast(str, self.config.proxy_password),
|
||||||
)
|
)
|
||||||
elif has_user != has_pass:
|
elif has_user != has_pass:
|
||||||
self.logger.warning(
|
self.logger.warning(
|
||||||
@ -507,7 +509,7 @@ class DiscordChannel(BaseChannel):
|
|||||||
return
|
return
|
||||||
if stream_id is not None and buf.stream_id is not None and buf.stream_id != stream_id:
|
if stream_id is not None and buf.stream_id is not None and buf.stream_id != stream_id:
|
||||||
return
|
return
|
||||||
await self._finalize_stream(chat_id, buf)
|
await self._finalize_stream(chat_id, buf, buf.message)
|
||||||
return
|
return
|
||||||
|
|
||||||
buf = self._stream_bufs.get(chat_id)
|
buf = self._stream_bufs.get(chat_id)
|
||||||
@ -635,7 +637,12 @@ class DiscordChannel(BaseChannel):
|
|||||||
self.logger.warning("channel {} unavailable: {}", chat_id, e)
|
self.logger.warning("channel {} unavailable: {}", chat_id, e)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
async def _finalize_stream(self, chat_id: str, buf: _StreamBuf) -> None:
|
async def _finalize_stream(
|
||||||
|
self,
|
||||||
|
chat_id: str,
|
||||||
|
buf: _StreamBuf,
|
||||||
|
message: discord.Message,
|
||||||
|
) -> None:
|
||||||
"""Commit the final streamed content and flush overflow chunks."""
|
"""Commit the final streamed content and flush overflow chunks."""
|
||||||
chunks = DiscordBotClient._build_chunks(buf.text, [], False)
|
chunks = DiscordBotClient._build_chunks(buf.text, [], False)
|
||||||
if not chunks:
|
if not chunks:
|
||||||
@ -643,16 +650,12 @@ class DiscordChannel(BaseChannel):
|
|||||||
return
|
return
|
||||||
|
|
||||||
try:
|
try:
|
||||||
await buf.message.edit(content=chunks[0])
|
await message.edit(content=chunks[0])
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
self.logger.warning("final stream edit failed: {}", e)
|
self.logger.warning("final stream edit failed: {}", e)
|
||||||
raise
|
raise
|
||||||
|
|
||||||
target = getattr(buf.message, "channel", None) or await self._resolve_channel(chat_id)
|
target = message.channel
|
||||||
if target is None:
|
|
||||||
self.logger.warning("stream follow-up target {} unavailable", chat_id)
|
|
||||||
self._stream_bufs.pop(chat_id, None)
|
|
||||||
return
|
|
||||||
|
|
||||||
for extra_chunk in chunks[1:]:
|
for extra_chunk in chunks[1:]:
|
||||||
await target.send(content=extra_chunk)
|
await target.send(content=extra_chunk)
|
||||||
|
|||||||
@ -17,7 +17,7 @@ from email.parser import BytesParser
|
|||||||
from email.utils import parseaddr
|
from email.utils import parseaddr
|
||||||
from fnmatch import fnmatch
|
from fnmatch import fnmatch
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any, Literal
|
from typing import Any, Literal, cast
|
||||||
|
|
||||||
from loguru import logger
|
from loguru import logger
|
||||||
from pydantic import Field
|
from pydantic import Field
|
||||||
@ -188,7 +188,9 @@ class EmailChannel(BaseChannel):
|
|||||||
self.logger.exception("Error delivering email from {}", sender)
|
self.logger.exception("Error delivering email from {}", sender)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
uid = str((item.get("metadata") or {}).get("uid") or "")
|
metadata = item.get("metadata")
|
||||||
|
metadata_data = cast(dict[str, Any], metadata) if isinstance(metadata, dict) else {}
|
||||||
|
uid = str(metadata_data.get("uid") or "")
|
||||||
if uid and should_apply_post_action:
|
if uid and should_apply_post_action:
|
||||||
post_actions_uids.add(uid)
|
post_actions_uids.add(uid)
|
||||||
|
|
||||||
@ -312,7 +314,7 @@ class EmailChannel(BaseChannel):
|
|||||||
raise
|
raise
|
||||||
|
|
||||||
def _validate_config(self) -> bool:
|
def _validate_config(self) -> bool:
|
||||||
missing = []
|
missing: list[str] = []
|
||||||
if not self.config.imap_host:
|
if not self.config.imap_host:
|
||||||
missing.append("imap_host")
|
missing.append("imap_host")
|
||||||
if not self.config.imap_username:
|
if not self.config.imap_username:
|
||||||
@ -427,7 +429,7 @@ class EmailChannel(BaseChannel):
|
|||||||
messages: list[dict[str, Any]],
|
messages: list[dict[str, Any]],
|
||||||
skipped_uids: set[str],
|
skipped_uids: set[str],
|
||||||
cycle_uids: set[str],
|
cycle_uids: set[str],
|
||||||
) -> None:
|
) -> list[dict[str, Any]] | None:
|
||||||
"""Fetch messages by arbitrary IMAP search criteria."""
|
"""Fetch messages by arbitrary IMAP search criteria."""
|
||||||
mailbox = self.config.imap_mailbox or "INBOX"
|
mailbox = self.config.imap_mailbox or "INBOX"
|
||||||
|
|
||||||
@ -765,8 +767,10 @@ class EmailChannel(BaseChannel):
|
|||||||
@staticmethod
|
@staticmethod
|
||||||
def _extract_message_bytes(fetched: list[Any]) -> bytes | None:
|
def _extract_message_bytes(fetched: list[Any]) -> bytes | None:
|
||||||
for item in fetched:
|
for item in fetched:
|
||||||
if isinstance(item, tuple) and len(item) >= 2 and isinstance(item[1], (bytes, bytearray)):
|
if isinstance(item, tuple):
|
||||||
return bytes(item[1])
|
fetched_item = cast(tuple[Any, ...], item)
|
||||||
|
if len(fetched_item) >= 2 and isinstance(fetched_item[1], (bytes, bytearray)):
|
||||||
|
return bytes(fetched_item[1])
|
||||||
return None
|
return None
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
@ -837,8 +841,8 @@ class EmailChannel(BaseChannel):
|
|||||||
"""
|
"""
|
||||||
spf_pass = False
|
spf_pass = False
|
||||||
dkim_pass = False
|
dkim_pass = False
|
||||||
for ar_header in parsed_msg.get_all("Authentication-Results") or []:
|
for ar_header in cast(list[Any], parsed_msg.get_all("Authentication-Results") or []):
|
||||||
ar_lower = ar_header.lower()
|
ar_lower = str(ar_header).lower()
|
||||||
if re.search(r"\bspf\s*=\s*pass\b", ar_lower):
|
if re.search(r"\bspf\s*=\s*pass\b", ar_lower):
|
||||||
spf_pass = True
|
spf_pass = True
|
||||||
if re.search(r"\bdkim\s*=\s*pass\b", ar_lower):
|
if re.search(r"\bdkim\s*=\s*pass\b", ar_lower):
|
||||||
|
|||||||
@ -1,5 +1,7 @@
|
|||||||
"""Short-lived WebUI channel connection sessions."""
|
"""Short-lived WebUI channel connection sessions."""
|
||||||
|
|
||||||
|
# pyright: reportPrivateUsage=false
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
|
|||||||
@ -3,7 +3,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import re
|
import re
|
||||||
from typing import Any
|
from typing import Any, cast
|
||||||
|
|
||||||
from loguru import logger
|
from loguru import logger
|
||||||
|
|
||||||
@ -46,7 +46,7 @@ def update_managed_feishu_instance(
|
|||||||
*,
|
*,
|
||||||
instance_id: str = DEFAULT_INSTANCE_ID,
|
instance_id: str = DEFAULT_INSTANCE_ID,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
existing = section if isinstance(section, dict) else {}
|
existing = cast(dict[str, Any], section) if isinstance(section, dict) else {}
|
||||||
return upsert_feishu_instance(
|
return upsert_feishu_instance(
|
||||||
existing,
|
existing,
|
||||||
feishu_default_config(),
|
feishu_default_config(),
|
||||||
@ -69,8 +69,8 @@ def _normalize_feishu_instance(
|
|||||||
inherited: dict[str, Any] | None = None,
|
inherited: dict[str, Any] | None = None,
|
||||||
fallback_id: str = DEFAULT_INSTANCE_ID,
|
fallback_id: str = DEFAULT_INSTANCE_ID,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
config = merge_missing_defaults(inherited or {}, defaults)
|
config = cast(dict[str, Any], merge_missing_defaults(inherited or {}, defaults))
|
||||||
config = merge_missing_defaults(raw, config)
|
config = cast(dict[str, Any], merge_missing_defaults(raw, config))
|
||||||
|
|
||||||
raw_id = raw.get("id") or raw.get("instanceId") or raw.get("instance_id") or fallback_id
|
raw_id = raw.get("id") or raw.get("instanceId") or raw.get("instance_id") or fallback_id
|
||||||
instance_id = validate_instance_id(str(raw_id))
|
instance_id = validate_instance_id(str(raw_id))
|
||||||
@ -97,12 +97,13 @@ def _feishu_instance_inputs(
|
|||||||
section = section.model_dump(mode="json", by_alias=True)
|
section = section.model_dump(mode="json", by_alias=True)
|
||||||
if not isinstance(section, dict):
|
if not isinstance(section, dict):
|
||||||
section = {}
|
section = {}
|
||||||
|
section_data = cast(dict[str, Any], section)
|
||||||
|
|
||||||
instances = section.get("instances")
|
instances = section_data.get("instances")
|
||||||
if isinstance(instances, list):
|
if isinstance(instances, list):
|
||||||
inherited = {key: value for key, value in section.items() if key != "instances"}
|
inherited = {key: value for key, value in section_data.items() if key != "instances"}
|
||||||
return list(instances), inherited
|
return list(cast(list[Any], instances)), inherited
|
||||||
return ([section] if section else [_base_feishu_instance_config(defaults)]), None
|
return ([section_data] if section_data else [_base_feishu_instance_config(defaults)]), None
|
||||||
|
|
||||||
|
|
||||||
def feishu_instance_specs(
|
def feishu_instance_specs(
|
||||||
@ -124,7 +125,7 @@ def feishu_instance_specs(
|
|||||||
fallback_id = DEFAULT_INSTANCE_ID if index == 0 else f"assistant-{index + 1}"
|
fallback_id = DEFAULT_INSTANCE_ID if index == 0 else f"assistant-{index + 1}"
|
||||||
try:
|
try:
|
||||||
config = _normalize_feishu_instance(
|
config = _normalize_feishu_instance(
|
||||||
raw,
|
cast(dict[str, Any], raw),
|
||||||
defaults,
|
defaults,
|
||||||
inherited=inherited,
|
inherited=inherited,
|
||||||
fallback_id=fallback_id,
|
fallback_id=fallback_id,
|
||||||
@ -179,7 +180,7 @@ def canonical_feishu_section(section: Any, defaults: dict[str, Any]) -> dict[str
|
|||||||
fallback_id = DEFAULT_INSTANCE_ID if index == 0 else f"assistant-{index + 1}"
|
fallback_id = DEFAULT_INSTANCE_ID if index == 0 else f"assistant-{index + 1}"
|
||||||
try:
|
try:
|
||||||
config = _normalize_feishu_instance(
|
config = _normalize_feishu_instance(
|
||||||
raw,
|
cast(dict[str, Any], raw),
|
||||||
defaults,
|
defaults,
|
||||||
inherited=inherited,
|
inherited=inherited,
|
||||||
fallback_id=fallback_id,
|
fallback_id=fallback_id,
|
||||||
@ -238,9 +239,9 @@ def update_feishu_instance_preserving_shape(
|
|||||||
if (
|
if (
|
||||||
instance_id == DEFAULT_INSTANCE_ID
|
instance_id == DEFAULT_INSTANCE_ID
|
||||||
and isinstance(section, dict)
|
and isinstance(section, dict)
|
||||||
and not isinstance(section.get("instances"), list)
|
and not isinstance(cast(dict[str, Any], section).get("instances"), list)
|
||||||
):
|
):
|
||||||
return {**section, **values}
|
return {**cast(dict[str, Any], section), **values}
|
||||||
|
|
||||||
return upsert_feishu_instance(section, defaults, instance_id, values)
|
return upsert_feishu_instance(section, defaults, instance_id, values)
|
||||||
|
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
"""Feishu/Lark channel implementation using lark-oapi SDK with WebSocket long connection."""
|
"""Feishu/Lark channel implementation using lark-oapi SDK with WebSocket long connection."""
|
||||||
|
# pyright: reportMissingModuleSource=false, reportMissingTypeStubs=false
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
@ -14,8 +15,9 @@ from collections import OrderedDict
|
|||||||
from contextlib import suppress
|
from contextlib import suppress
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from datetime import UTC, datetime
|
from datetime import UTC, datetime
|
||||||
|
from functools import partial
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import TYPE_CHECKING, Any
|
from typing import TYPE_CHECKING, Any, TypedDict, cast
|
||||||
|
|
||||||
from rich.console import Console
|
from rich.console import Console
|
||||||
from rich.markup import escape
|
from rich.markup import escape
|
||||||
@ -44,7 +46,10 @@ from nanobot.utils.helpers import safe_filename
|
|||||||
from nanobot.utils.logging_bridge import redirect_lib_logging
|
from nanobot.utils.logging_bridge import redirect_lib_logging
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from lark_oapi.api.im.v1.model import MentionEvent, P2ImMessageReceiveV1
|
from lark_oapi.api.im.v1.model import ( # pyright: ignore[reportMissingTypeStubs]
|
||||||
|
MentionEvent,
|
||||||
|
P2ImMessageReceiveV1,
|
||||||
|
)
|
||||||
|
|
||||||
FEISHU_AVAILABLE = importlib.util.find_spec("lark_oapi") is not None
|
FEISHU_AVAILABLE = importlib.util.find_spec("lark_oapi") is not None
|
||||||
_LOGIN_CONSOLE = Console()
|
_LOGIN_CONSOLE = Console()
|
||||||
@ -55,6 +60,20 @@ def _identity_timestamp() -> str:
|
|||||||
return datetime.now(UTC).isoformat(timespec="seconds").replace("+00:00", "Z")
|
return datetime.now(UTC).isoformat(timespec="seconds").replace("+00:00", "Z")
|
||||||
|
|
||||||
|
|
||||||
|
def _as_json_object(value: Any) -> dict[str, Any] | None:
|
||||||
|
"""Narrow untyped SDK/JSON objects at the channel boundary."""
|
||||||
|
return cast(dict[str, Any], value) if isinstance(value, dict) else None
|
||||||
|
|
||||||
|
|
||||||
|
def _as_json_list(value: Any) -> list[Any] | None:
|
||||||
|
"""Narrow untyped SDK/JSON arrays at the channel boundary."""
|
||||||
|
return cast(list[Any], value) if isinstance(value, list) else None
|
||||||
|
|
||||||
|
|
||||||
|
def _ignore_event(_: Any) -> None:
|
||||||
|
"""Consume SDK events that intentionally have no channel action."""
|
||||||
|
|
||||||
|
|
||||||
def _load_lark_runtime() -> tuple[Any, str, str]:
|
def _load_lark_runtime() -> tuple[Any, str, str]:
|
||||||
"""Import the heavy Feishu SDK lazily.
|
"""Import the heavy Feishu SDK lazily.
|
||||||
|
|
||||||
@ -69,9 +88,12 @@ def _load_lark_runtime() -> tuple[Any, str, str]:
|
|||||||
# close the same loop.
|
# close the same loop.
|
||||||
with _LARK_RUNTIME_LOCK:
|
with _LARK_RUNTIME_LOCK:
|
||||||
ws_client_already_imported = "lark_oapi.ws.client" in sys.modules
|
ws_client_already_imported = "lark_oapi.ws.client" in sys.modules
|
||||||
import lark_oapi as lark
|
import lark_oapi as lark # pyright: ignore[reportMissingTypeStubs]
|
||||||
import lark_oapi.ws.client as lark_ws_client
|
import lark_oapi.ws.client as lark_ws_client # pyright: ignore[reportMissingTypeStubs]
|
||||||
from lark_oapi.core.const import FEISHU_DOMAIN, LARK_DOMAIN
|
from lark_oapi.core.const import ( # pyright: ignore[reportMissingTypeStubs]
|
||||||
|
FEISHU_DOMAIN,
|
||||||
|
LARK_DOMAIN,
|
||||||
|
)
|
||||||
|
|
||||||
if (
|
if (
|
||||||
not ws_client_already_imported
|
not ws_client_already_imported
|
||||||
@ -106,7 +128,7 @@ def fetch_feishu_app_identity(
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
lark, feishu_domain, lark_domain = _load_lark_runtime()
|
lark, feishu_domain, lark_domain = _load_lark_runtime()
|
||||||
from lark_oapi.api.application.v6.model.get_application_request import (
|
from lark_oapi.api.application.v6.model.get_application_request import ( # pyright: ignore[reportMissingTypeStubs]
|
||||||
GetApplicationRequest,
|
GetApplicationRequest,
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -151,9 +173,9 @@ MSG_TYPE_MAP = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def _extract_share_card_content(content_json: dict, msg_type: str) -> str:
|
def _extract_share_card_content(content_json: dict[str, Any], msg_type: str) -> str:
|
||||||
"""Extract text representation from share cards and interactive messages."""
|
"""Extract text representation from share cards and interactive messages."""
|
||||||
parts = []
|
parts: list[str] = []
|
||||||
|
|
||||||
if msg_type == "share_chat":
|
if msg_type == "share_chat":
|
||||||
parts.append(f"[shared chat: {content_json.get('chat_id', '')}]")
|
parts.append(f"[shared chat: {content_json.get('chat_id', '')}]")
|
||||||
@ -171,9 +193,9 @@ def _extract_share_card_content(content_json: dict, msg_type: str) -> str:
|
|||||||
return "\n".join(parts) if parts else f"[{msg_type}]"
|
return "\n".join(parts) if parts else f"[{msg_type}]"
|
||||||
|
|
||||||
|
|
||||||
def _extract_interactive_content(content: dict) -> list[str]:
|
def _extract_interactive_content(content: str | dict[str, Any]) -> list[str]:
|
||||||
"""Recursively extract text and links from interactive card content."""
|
"""Recursively extract text and links from interactive card content."""
|
||||||
parts = []
|
parts: list[str] = []
|
||||||
|
|
||||||
if isinstance(content, str):
|
if isinstance(content, str):
|
||||||
try:
|
try:
|
||||||
@ -189,8 +211,9 @@ def _extract_interactive_content(content: dict) -> list[str]:
|
|||||||
if isinstance(user_dsl, str) and user_dsl.strip():
|
if isinstance(user_dsl, str) and user_dsl.strip():
|
||||||
try:
|
try:
|
||||||
dsl = json.loads(user_dsl)
|
dsl = json.loads(user_dsl)
|
||||||
if isinstance(dsl, dict):
|
dsl_object = _as_json_object(dsl)
|
||||||
parts.extend(_extract_interactive_content(dsl))
|
if dsl_object is not None:
|
||||||
|
parts.extend(_extract_interactive_content(dsl_object))
|
||||||
if parts:
|
if parts:
|
||||||
return parts
|
return parts
|
||||||
except (json.JSONDecodeError, TypeError):
|
except (json.JSONDecodeError, TypeError):
|
||||||
@ -198,8 +221,9 @@ def _extract_interactive_content(content: dict) -> list[str]:
|
|||||||
|
|
||||||
if "title" in content:
|
if "title" in content:
|
||||||
title = content["title"]
|
title = content["title"]
|
||||||
if isinstance(title, dict):
|
title_object = _as_json_object(title)
|
||||||
title_content = title.get("content", "") or title.get("text", "")
|
if title_object is not None:
|
||||||
|
title_content = title_object.get("content", "") or title_object.get("text", "")
|
||||||
if title_content:
|
if title_content:
|
||||||
parts.append(f"title: {title_content}")
|
parts.append(f"title: {title_content}")
|
||||||
elif isinstance(title, str):
|
elif isinstance(title, str):
|
||||||
@ -207,34 +231,39 @@ def _extract_interactive_content(content: dict) -> list[str]:
|
|||||||
|
|
||||||
# Top-level elements: flat list or nested list format
|
# Top-level elements: flat list or nested list format
|
||||||
elements = content.get("elements")
|
elements = content.get("elements")
|
||||||
if isinstance(elements, list):
|
elements_list = _as_json_list(elements)
|
||||||
if elements and isinstance(elements[0], list):
|
if elements_list is not None:
|
||||||
|
if elements_list and isinstance(elements_list[0], list):
|
||||||
# Nested list: [[{tag:"text",text:"..."}], ...]
|
# Nested list: [[{tag:"text",text:"..."}], ...]
|
||||||
for row in elements:
|
for row in elements_list:
|
||||||
if isinstance(row, list):
|
row_list = _as_json_list(row)
|
||||||
for element in row:
|
if row_list is not None:
|
||||||
|
for element in row_list:
|
||||||
parts.extend(_extract_element_content(element))
|
parts.extend(_extract_element_content(element))
|
||||||
else:
|
else:
|
||||||
# Flat list: [{tag:"markdown",content:"..."}, ...]
|
# Flat list: [{tag:"markdown",content:"..."}, ...]
|
||||||
for element in elements:
|
for element in elements_list:
|
||||||
parts.extend(_extract_element_content(element))
|
parts.extend(_extract_element_content(element))
|
||||||
|
|
||||||
# Body elements (schema 2.0)
|
# Body elements (schema 2.0)
|
||||||
body = content.get("body", {})
|
body = content.get("body", {})
|
||||||
if isinstance(body, dict):
|
body_object = _as_json_object(body)
|
||||||
body_elements = body.get("elements")
|
if body_object is not None:
|
||||||
if isinstance(body_elements, list):
|
body_elements = _as_json_list(body_object.get("elements"))
|
||||||
|
if body_elements is not None:
|
||||||
for element in body_elements:
|
for element in body_elements:
|
||||||
parts.extend(_extract_element_content(element))
|
parts.extend(_extract_element_content(element))
|
||||||
|
|
||||||
card = content.get("card", {})
|
card = content.get("card", {})
|
||||||
if card:
|
card_object = _as_json_object(card)
|
||||||
parts.extend(_extract_interactive_content(card))
|
if card_object:
|
||||||
|
parts.extend(_extract_interactive_content(card_object))
|
||||||
|
|
||||||
header = content.get("header", {})
|
header = content.get("header", {})
|
||||||
if header:
|
header_object = _as_json_object(header)
|
||||||
header_title = header.get("title", {})
|
if header_object is not None:
|
||||||
if isinstance(header_title, dict):
|
header_title = _as_json_object(header_object.get("title", {}))
|
||||||
|
if header_title is not None:
|
||||||
header_text = header_title.get("content", "") or header_title.get("text", "")
|
header_text = header_title.get("content", "") or header_title.get("text", "")
|
||||||
if header_text:
|
if header_text:
|
||||||
parts.append(f"title: {header_text}")
|
parts.append(f"title: {header_text}")
|
||||||
@ -242,13 +271,16 @@ def _extract_interactive_content(content: dict) -> list[str]:
|
|||||||
return parts
|
return parts
|
||||||
|
|
||||||
|
|
||||||
def _extract_element_content(element: dict) -> list[str]:
|
def _extract_element_content(element: Any) -> list[str]:
|
||||||
"""Extract content from a single card element."""
|
"""Extract content from a single card element."""
|
||||||
parts = []
|
parts: list[str] = []
|
||||||
|
|
||||||
if not isinstance(element, dict):
|
element_object = _as_json_object(element)
|
||||||
|
if element_object is None:
|
||||||
return parts
|
return parts
|
||||||
|
|
||||||
|
element = element_object
|
||||||
|
|
||||||
tag = element.get("tag", "")
|
tag = element.get("tag", "")
|
||||||
|
|
||||||
if tag in ("markdown", "lark_md"):
|
if tag in ("markdown", "lark_md"):
|
||||||
@ -263,16 +295,18 @@ def _extract_element_content(element: dict) -> list[str]:
|
|||||||
|
|
||||||
elif tag == "div":
|
elif tag == "div":
|
||||||
text = element.get("text", {})
|
text = element.get("text", {})
|
||||||
if isinstance(text, dict):
|
text_object = _as_json_object(text)
|
||||||
text_content = text.get("content", "") or text.get("text", "")
|
if text_object is not None:
|
||||||
|
text_content = text_object.get("content", "") or text_object.get("text", "")
|
||||||
if text_content:
|
if text_content:
|
||||||
parts.append(text_content)
|
parts.append(text_content)
|
||||||
elif isinstance(text, str):
|
elif isinstance(text, str):
|
||||||
parts.append(text)
|
parts.append(text)
|
||||||
for field in element.get("fields") or []:
|
for field in _as_json_list(element.get("fields")) or []:
|
||||||
if isinstance(field, dict):
|
field_object = _as_json_object(field)
|
||||||
field_text = field.get("text", {})
|
if field_object is not None:
|
||||||
if isinstance(field_text, dict):
|
field_text = _as_json_object(field_object.get("text", {}))
|
||||||
|
if field_text is not None:
|
||||||
c = field_text.get("content", "")
|
c = field_text.get("content", "")
|
||||||
if c:
|
if c:
|
||||||
parts.append(c)
|
parts.append(c)
|
||||||
@ -287,30 +321,33 @@ def _extract_element_content(element: dict) -> list[str]:
|
|||||||
|
|
||||||
elif tag == "button":
|
elif tag == "button":
|
||||||
text = element.get("text", {})
|
text = element.get("text", {})
|
||||||
if isinstance(text, dict):
|
text_object = _as_json_object(text)
|
||||||
c = text.get("content", "")
|
if text_object is not None:
|
||||||
|
c = text_object.get("content", "")
|
||||||
if c:
|
if c:
|
||||||
parts.append(c)
|
parts.append(c)
|
||||||
multi_url = element.get("multi_url") or {}
|
multi_url: Any = element.get("multi_url") or {}
|
||||||
|
multi_url_object = _as_json_object(multi_url)
|
||||||
url = element.get("url", "") or (
|
url = element.get("url", "") or (
|
||||||
multi_url.get("url", "") if isinstance(multi_url, dict) else ""
|
multi_url_object.get("url", "") if multi_url_object is not None else ""
|
||||||
)
|
)
|
||||||
if url:
|
if url:
|
||||||
parts.append(f"link: {url}")
|
parts.append(f"link: {url}")
|
||||||
|
|
||||||
elif tag == "img":
|
elif tag == "img":
|
||||||
alt = element.get("alt", {})
|
alt = _as_json_object(element.get("alt", {}))
|
||||||
parts.append(alt.get("content", "[image]") if isinstance(alt, dict) else "[image]")
|
parts.append(alt.get("content", "[image]") if alt is not None else "[image]")
|
||||||
|
|
||||||
elif tag == "note":
|
elif tag == "note":
|
||||||
for ne in element.get("elements") or []:
|
for ne in _as_json_list(element.get("elements")) or []:
|
||||||
parts.extend(_extract_element_content(ne))
|
parts.extend(_extract_element_content(ne))
|
||||||
|
|
||||||
elif tag == "column_set":
|
elif tag == "column_set":
|
||||||
for col in element.get("columns") or []:
|
for col in _as_json_list(element.get("columns")) or []:
|
||||||
if not isinstance(col, dict):
|
col_object = _as_json_object(col)
|
||||||
|
if col_object is None:
|
||||||
continue
|
continue
|
||||||
for ce in col.get("elements") or []:
|
for ce in _as_json_list(col_object.get("elements")) or []:
|
||||||
parts.extend(_extract_element_content(ce))
|
parts.extend(_extract_element_content(ce))
|
||||||
|
|
||||||
elif tag == "plain_text":
|
elif tag == "plain_text":
|
||||||
@ -319,36 +356,44 @@ def _extract_element_content(element: dict) -> list[str]:
|
|||||||
parts.append(content)
|
parts.append(content)
|
||||||
|
|
||||||
elif tag == "table":
|
elif tag == "table":
|
||||||
columns = [
|
columns: list[tuple[str, str]] = []
|
||||||
(column["name"], str(column.get("display_name") or column["name"]))
|
for column in _as_json_list(element.get("columns")) or []:
|
||||||
for column in (element.get("columns") or [])
|
column_object = _as_json_object(column)
|
||||||
if isinstance(column, dict) and column.get("name")
|
if column_object is None:
|
||||||
]
|
continue
|
||||||
rows = element.get("rows") or []
|
name = column_object.get("name")
|
||||||
|
if isinstance(name, str) and name:
|
||||||
|
columns.append((name, str(column_object.get("display_name") or name)))
|
||||||
|
rows = _as_json_list(element.get("rows")) or []
|
||||||
if columns:
|
if columns:
|
||||||
parts.append(" | ".join(header for _, header in columns))
|
parts.append(" | ".join(header for _, header in columns))
|
||||||
if isinstance(rows, list):
|
if rows:
|
||||||
for row in rows:
|
for row in rows:
|
||||||
if not isinstance(row, dict):
|
row_object = _as_json_object(row)
|
||||||
|
if row_object is None:
|
||||||
continue
|
continue
|
||||||
values = []
|
values: list[str] = []
|
||||||
for name, _ in columns:
|
for name, _ in columns:
|
||||||
value = row.get(name)
|
value = row_object.get(name)
|
||||||
if isinstance(value, list):
|
if isinstance(value, list):
|
||||||
value = " ".join(str(item).strip() for item in value if item is not None)
|
value = " ".join(
|
||||||
|
str(item).strip()
|
||||||
|
for item in cast(list[Any], value)
|
||||||
|
if item is not None
|
||||||
|
)
|
||||||
values.append("" if value is None else str(value).strip())
|
values.append("" if value is None else str(value).strip())
|
||||||
row_text = " | ".join(values).strip()
|
row_text = " | ".join(values).strip()
|
||||||
if row_text:
|
if row_text:
|
||||||
parts.append(row_text)
|
parts.append(row_text)
|
||||||
|
|
||||||
else:
|
else:
|
||||||
for ne in element.get("elements") or []:
|
for ne in _as_json_list(element.get("elements")) or []:
|
||||||
parts.extend(_extract_element_content(ne))
|
parts.extend(_extract_element_content(ne))
|
||||||
|
|
||||||
return parts
|
return parts
|
||||||
|
|
||||||
|
|
||||||
def _extract_post_content(content_json: dict) -> tuple[str, list[str]]:
|
def _extract_post_content(content_json: dict[str, Any]) -> tuple[str, list[str]]:
|
||||||
"""Extract text and image keys from Feishu post (rich text) message.
|
"""Extract text and image keys from Feishu post (rich text) message.
|
||||||
|
|
||||||
Handles three payload shapes:
|
Handles three payload shapes:
|
||||||
@ -357,45 +402,48 @@ def _extract_post_content(content_json: dict) -> tuple[str, list[str]]:
|
|||||||
- Wrapped: {"post": {"zh_cn": {"title": "...", "content": [...]}}}
|
- Wrapped: {"post": {"zh_cn": {"title": "...", "content": [...]}}}
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def _parse_block(block: dict) -> tuple[str | None, list[str]]:
|
def _parse_block(block: dict[str, Any]) -> tuple[str | None, list[str]]:
|
||||||
if not isinstance(block, dict) or not isinstance(block.get("content"), list):
|
content = _as_json_list(block.get("content"))
|
||||||
|
if content is None:
|
||||||
return None, []
|
return None, []
|
||||||
texts, images = [], []
|
texts: list[str] = []
|
||||||
|
images: list[str] = []
|
||||||
title = block.get("title")
|
title = block.get("title")
|
||||||
if isinstance(title, str) and title:
|
if isinstance(title, str) and title:
|
||||||
texts.append(title)
|
texts.append(title)
|
||||||
for row in block["content"]:
|
for row in content:
|
||||||
if not isinstance(row, list):
|
row_items = _as_json_list(row)
|
||||||
|
if row_items is None:
|
||||||
continue
|
continue
|
||||||
for el in row:
|
for el in row_items:
|
||||||
if not isinstance(el, dict):
|
element = _as_json_object(el)
|
||||||
|
if element is None:
|
||||||
continue
|
continue
|
||||||
tag = el.get("tag")
|
tag = element.get("tag")
|
||||||
if tag in ("text", "a"):
|
if tag in ("text", "a"):
|
||||||
text = el.get("text", "")
|
text = element.get("text", "")
|
||||||
if isinstance(text, str):
|
if isinstance(text, str):
|
||||||
texts.append(text)
|
texts.append(text)
|
||||||
elif tag == "at":
|
elif tag == "at":
|
||||||
user = el.get("user_name", "user")
|
user = element.get("user_name", "user")
|
||||||
texts.append(f"@{user if isinstance(user, str) and user else 'user'}")
|
texts.append(f"@{user if isinstance(user, str) and user else 'user'}")
|
||||||
elif tag == "code_block":
|
elif tag == "code_block":
|
||||||
lang = el.get("language", "")
|
lang = element.get("language", "")
|
||||||
code_text = el.get("text", "")
|
code_text = element.get("text", "")
|
||||||
if not isinstance(lang, str):
|
if not isinstance(lang, str):
|
||||||
lang = ""
|
lang = ""
|
||||||
if not isinstance(code_text, str):
|
if not isinstance(code_text, str):
|
||||||
code_text = ""
|
code_text = ""
|
||||||
texts.append(f"\n```{lang}\n{code_text}\n```\n")
|
texts.append(f"\n```{lang}\n{code_text}\n```\n")
|
||||||
elif tag == "img" and (key := el.get("image_key")):
|
elif tag == "img" and isinstance((key := element.get("image_key")), str):
|
||||||
images.append(key)
|
images.append(key)
|
||||||
return (" ".join(texts).strip() or None), images
|
return (" ".join(texts).strip() or None), images
|
||||||
|
|
||||||
# Unwrap optional {"post": ...} envelope
|
# Unwrap optional {"post": ...} envelope
|
||||||
root = content_json
|
root = content_json
|
||||||
if isinstance(root, dict) and isinstance(root.get("post"), dict):
|
post = _as_json_object(root.get("post"))
|
||||||
root = root["post"]
|
if post is not None:
|
||||||
if not isinstance(root, dict):
|
root = post
|
||||||
return "", []
|
|
||||||
|
|
||||||
# Direct format
|
# Direct format
|
||||||
if "content" in root:
|
if "content" in root:
|
||||||
@ -406,19 +454,23 @@ def _extract_post_content(content_json: dict) -> tuple[str, list[str]]:
|
|||||||
# Localized: prefer known locales, then fall back to any dict child
|
# Localized: prefer known locales, then fall back to any dict child
|
||||||
for key in ("zh_cn", "en_us", "ja_jp"):
|
for key in ("zh_cn", "en_us", "ja_jp"):
|
||||||
if key in root:
|
if key in root:
|
||||||
text, imgs = _parse_block(root[key])
|
block = _as_json_object(root[key])
|
||||||
|
if block is None:
|
||||||
|
continue
|
||||||
|
text, imgs = _parse_block(block)
|
||||||
if text or imgs:
|
if text or imgs:
|
||||||
return text or "", imgs
|
return text or "", imgs
|
||||||
for val in root.values():
|
for val in root.values():
|
||||||
if isinstance(val, dict):
|
block = _as_json_object(val)
|
||||||
text, imgs = _parse_block(val)
|
if block is not None:
|
||||||
|
text, imgs = _parse_block(block)
|
||||||
if text or imgs:
|
if text or imgs:
|
||||||
return text or "", imgs
|
return text or "", imgs
|
||||||
|
|
||||||
return "", []
|
return "", []
|
||||||
|
|
||||||
|
|
||||||
def _extract_post_text(content_json: dict) -> str:
|
def _extract_post_text(content_json: dict[str, Any]) -> str: # pyright: ignore[reportUnusedFunction]
|
||||||
"""Extract plain text from Feishu post (rich text) message content.
|
"""Extract plain text from Feishu post (rich text) message content.
|
||||||
|
|
||||||
Legacy wrapper for _extract_post_content, returns only text.
|
Legacy wrapper for _extract_post_content, returns only text.
|
||||||
@ -442,11 +494,18 @@ _REGISTRATION_PATH = "/oauth/v1/app/registration"
|
|||||||
_ONBOARD_REQUEST_TIMEOUT_S = 10
|
_ONBOARD_REQUEST_TIMEOUT_S = 10
|
||||||
|
|
||||||
|
|
||||||
|
class _RegistrationStart(TypedDict):
|
||||||
|
device_code: str
|
||||||
|
qr_url: str
|
||||||
|
interval: int
|
||||||
|
expire_in: int
|
||||||
|
|
||||||
|
|
||||||
def _accounts_base_url(domain: str) -> str:
|
def _accounts_base_url(domain: str) -> str:
|
||||||
return _ONBOARD_ACCOUNTS_URLS.get(domain, _ONBOARD_ACCOUNTS_URLS["feishu"])
|
return _ONBOARD_ACCOUNTS_URLS.get(domain, _ONBOARD_ACCOUNTS_URLS["feishu"])
|
||||||
|
|
||||||
|
|
||||||
def _post_registration(base_url: str, body: dict[str, str]) -> dict:
|
def _post_registration(base_url: str, body: dict[str, str]) -> dict[str, Any]:
|
||||||
"""POST form-encoded data to the registration endpoint, return parsed JSON.
|
"""POST form-encoded data to the registration endpoint, return parsed JSON.
|
||||||
|
|
||||||
The registration endpoint returns JSON even on HTTP errors (e.g. poll
|
The registration endpoint returns JSON even on HTTP errors (e.g. poll
|
||||||
@ -462,7 +521,8 @@ def _post_registration(base_url: str, body: dict[str, str]) -> dict:
|
|||||||
headers={"Content-Type": "application/x-www-form-urlencoded"},
|
headers={"Content-Type": "application/x-www-form-urlencoded"},
|
||||||
)
|
)
|
||||||
try:
|
try:
|
||||||
return resp.json()
|
parsed = resp.json()
|
||||||
|
return _as_json_object(parsed) or {}
|
||||||
except json.JSONDecodeError:
|
except json.JSONDecodeError:
|
||||||
resp.raise_for_status()
|
resp.raise_for_status()
|
||||||
return {}
|
return {}
|
||||||
@ -472,7 +532,7 @@ def _init_registration(domain: str = "feishu") -> None:
|
|||||||
"""Verify the environment supports client_secret auth. Raises RuntimeError if not."""
|
"""Verify the environment supports client_secret auth. Raises RuntimeError if not."""
|
||||||
base_url = _accounts_base_url(domain)
|
base_url = _accounts_base_url(domain)
|
||||||
res = _post_registration(base_url, {"action": "init"})
|
res = _post_registration(base_url, {"action": "init"})
|
||||||
methods = res.get("supported_auth_methods") or []
|
methods = _as_json_list(res.get("supported_auth_methods")) or []
|
||||||
if "client_secret" not in methods:
|
if "client_secret" not in methods:
|
||||||
raise RuntimeError(
|
raise RuntimeError(
|
||||||
f"Feishu / Lark registration does not support client_secret auth. "
|
f"Feishu / Lark registration does not support client_secret auth. "
|
||||||
@ -480,7 +540,7 @@ def _init_registration(domain: str = "feishu") -> None:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def _begin_registration(domain: str = "feishu") -> dict:
|
def _begin_registration(domain: str = "feishu") -> _RegistrationStart:
|
||||||
"""Start the device-code flow. Returns device_code, qr_url, interval, expire_in."""
|
"""Start the device-code flow. Returns device_code, qr_url, interval, expire_in."""
|
||||||
base_url = _accounts_base_url(domain)
|
base_url = _accounts_base_url(domain)
|
||||||
res = _post_registration(base_url, {
|
res = _post_registration(base_url, {
|
||||||
@ -490,16 +550,18 @@ def _begin_registration(domain: str = "feishu") -> dict:
|
|||||||
"request_user_info": "open_id",
|
"request_user_info": "open_id",
|
||||||
})
|
})
|
||||||
device_code = res.get("device_code")
|
device_code = res.get("device_code")
|
||||||
if not device_code:
|
if not isinstance(device_code, str) or not device_code:
|
||||||
raise RuntimeError("Feishu / Lark registration did not return a device_code")
|
raise RuntimeError("Feishu / Lark registration did not return a device_code")
|
||||||
qr_url = res.get("verification_uri_complete", "")
|
qr_url = res.get("verification_uri_complete", "")
|
||||||
if not qr_url:
|
if not isinstance(qr_url, str) or not qr_url:
|
||||||
raise RuntimeError("Feishu / Lark registration did not return a login URL")
|
raise RuntimeError("Feishu / Lark registration did not return a login URL")
|
||||||
|
interval = res.get("interval")
|
||||||
|
expire_in = res.get("expire_in")
|
||||||
return {
|
return {
|
||||||
"device_code": device_code,
|
"device_code": device_code,
|
||||||
"qr_url": qr_url,
|
"qr_url": qr_url,
|
||||||
"interval": res.get("interval") or 5,
|
"interval": interval if isinstance(interval, int) else 5,
|
||||||
"expire_in": res.get("expire_in") or 600,
|
"expire_in": expire_in if isinstance(expire_in, int) else 600,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@ -509,7 +571,7 @@ def _poll_registration(
|
|||||||
interval: int,
|
interval: int,
|
||||||
expire_in: int,
|
expire_in: int,
|
||||||
domain: str = "feishu",
|
domain: str = "feishu",
|
||||||
) -> dict | None:
|
) -> dict[str, Any] | None:
|
||||||
"""Poll until the user scans the QR code, or timeout/denial.
|
"""Poll until the user scans the QR code, or timeout/denial.
|
||||||
|
|
||||||
Returns dict with app_id, app_secret, domain on success, None on failure.
|
Returns dict with app_id, app_secret, domain on success, None on failure.
|
||||||
@ -548,7 +610,7 @@ def poll_registration_once(
|
|||||||
*,
|
*,
|
||||||
device_code: str,
|
device_code: str,
|
||||||
domain: str = "feishu",
|
domain: str = "feishu",
|
||||||
) -> dict:
|
) -> dict[str, Any]:
|
||||||
"""Poll the Feishu/Lark device-code flow once.
|
"""Poll the Feishu/Lark device-code flow once.
|
||||||
|
|
||||||
This non-blocking shape is used by WebUI. The CLI keeps using
|
This non-blocking shape is used by WebUI. The CLI keeps using
|
||||||
@ -562,7 +624,7 @@ def poll_registration_once(
|
|||||||
"tp": "ob_app",
|
"tp": "ob_app",
|
||||||
})
|
})
|
||||||
|
|
||||||
user_info = res.get("user_info") or {}
|
user_info = _as_json_object(res.get("user_info")) or {}
|
||||||
tenant_brand = user_info.get("tenant_brand")
|
tenant_brand = user_info.get("tenant_brand")
|
||||||
if tenant_brand == "lark":
|
if tenant_brand == "lark":
|
||||||
current_domain = "lark"
|
current_domain = "lark"
|
||||||
@ -641,9 +703,7 @@ def sync_saved_feishu_identity_boundary(
|
|||||||
from nanobot.config.loader import load_config, save_config
|
from nanobot.config.loader import load_config, save_config
|
||||||
|
|
||||||
full_config = load_config()
|
full_config = load_config()
|
||||||
feishu_cfg = getattr(full_config.channels, "feishu", None) or {}
|
feishu_cfg = _as_json_object(getattr(full_config.channels, "feishu", None)) or {}
|
||||||
if not isinstance(feishu_cfg, dict):
|
|
||||||
feishu_cfg = {}
|
|
||||||
|
|
||||||
defaults = feishu_default_config()
|
defaults = feishu_default_config()
|
||||||
previous_identity_key = ""
|
previous_identity_key = ""
|
||||||
@ -675,7 +735,7 @@ def sync_saved_feishu_identity_boundary(
|
|||||||
|
|
||||||
|
|
||||||
def save_registration_result(
|
def save_registration_result(
|
||||||
result: dict,
|
result: dict[str, Any],
|
||||||
*,
|
*,
|
||||||
instance_id: str = DEFAULT_INSTANCE_ID,
|
instance_id: str = DEFAULT_INSTANCE_ID,
|
||||||
name: str | None = None,
|
name: str | None = None,
|
||||||
@ -684,9 +744,7 @@ def save_registration_result(
|
|||||||
from nanobot.config.loader import load_config, save_config
|
from nanobot.config.loader import load_config, save_config
|
||||||
|
|
||||||
full_config = load_config()
|
full_config = load_config()
|
||||||
feishu_cfg = getattr(full_config.channels, "feishu", None) or {}
|
feishu_cfg = _as_json_object(getattr(full_config.channels, "feishu", None)) or {}
|
||||||
if not isinstance(feishu_cfg, dict):
|
|
||||||
feishu_cfg = {}
|
|
||||||
defaults = feishu_default_config()
|
defaults = feishu_default_config()
|
||||||
app_id = str(result["app_id"]).strip()
|
app_id = str(result["app_id"]).strip()
|
||||||
domain = str(result.get("domain", "feishu") or "feishu").strip().lower()
|
domain = str(result.get("domain", "feishu") or "feishu").strip().lower()
|
||||||
@ -809,7 +867,7 @@ def refresh_saved_feishu_identities(
|
|||||||
def qr_register(
|
def qr_register(
|
||||||
*,
|
*,
|
||||||
initial_domain: str = "feishu",
|
initial_domain: str = "feishu",
|
||||||
) -> dict | None:
|
) -> dict[str, Any] | None:
|
||||||
"""Run the Feishu / Lark scan-to-create QR registration flow.
|
"""Run the Feishu / Lark scan-to-create QR registration flow.
|
||||||
|
|
||||||
Returns on success:
|
Returns on success:
|
||||||
@ -853,7 +911,7 @@ def _print_qr_code(url: str) -> None:
|
|||||||
def _qr_register_inner(
|
def _qr_register_inner(
|
||||||
*,
|
*,
|
||||||
initial_domain: str,
|
initial_domain: str,
|
||||||
) -> dict | None:
|
) -> dict[str, Any] | None:
|
||||||
"""Run init → begin → poll. Raises on network/protocol errors."""
|
"""Run init → begin → poll. Raises on network/protocol errors."""
|
||||||
_LOGIN_CONSOLE.print("[cyan]Preparing Feishu/Lark login...[/cyan]")
|
_LOGIN_CONSOLE.print("[cyan]Preparing Feishu/Lark login...[/cyan]")
|
||||||
_init_registration(initial_domain)
|
_init_registration(initial_domain)
|
||||||
@ -935,7 +993,7 @@ class FeishuChannel(BaseChannel):
|
|||||||
self._loop: asyncio.AbstractEventLoop | None = None
|
self._loop: asyncio.AbstractEventLoop | None = None
|
||||||
self._stream_bufs: dict[str, _FeishuStreamBuf] = {}
|
self._stream_bufs: dict[str, _FeishuStreamBuf] = {}
|
||||||
self._bot_open_id: str | None = None
|
self._bot_open_id: str | None = None
|
||||||
self._background_tasks: set[asyncio.Task] = set()
|
self._background_tasks: set[asyncio.Task[Any]] = set()
|
||||||
self._reaction_ids: dict[str, str] = {} # message_id → reaction_id
|
self._reaction_ids: dict[str, str] = {} # message_id → reaction_id
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
@ -1062,12 +1120,12 @@ class FeishuChannel(BaseChannel):
|
|||||||
builder = self._register_optional_event(
|
builder = self._register_optional_event(
|
||||||
builder,
|
builder,
|
||||||
"register_p2_im_chat_member_bot_added_v1",
|
"register_p2_im_chat_member_bot_added_v1",
|
||||||
lambda _: None,
|
_ignore_event,
|
||||||
)
|
)
|
||||||
builder = self._register_optional_event(
|
builder = self._register_optional_event(
|
||||||
builder,
|
builder,
|
||||||
"register_p2_im_chat_member_bot_deleted_v1",
|
"register_p2_im_chat_member_bot_deleted_v1",
|
||||||
lambda _: None,
|
_ignore_event,
|
||||||
)
|
)
|
||||||
event_handler = builder.build()
|
event_handler = builder.build()
|
||||||
|
|
||||||
@ -1126,9 +1184,11 @@ class FeishuChannel(BaseChannel):
|
|||||||
if response.success():
|
if response.success():
|
||||||
import json
|
import json
|
||||||
|
|
||||||
data = json.loads(response.raw.content)
|
data = _as_json_object(json.loads(response.raw.content)) or {}
|
||||||
bot = (data.get("data") or data).get("bot") or data.get("bot") or {}
|
wrapped = _as_json_object(data.get("data")) or data
|
||||||
return bot.get("open_id")
|
bot = _as_json_object(wrapped.get("bot")) or _as_json_object(data.get("bot")) or {}
|
||||||
|
open_id = bot.get("open_id")
|
||||||
|
return open_id if isinstance(open_id, str) else None
|
||||||
self.logger.warning("Failed to get bot info: code={}, msg={}", response.code, response.msg)
|
self.logger.warning("Failed to get bot info: code={}, msg={}", response.code, response.msg)
|
||||||
return None
|
return None
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@ -1218,7 +1278,7 @@ class FeishuChannel(BaseChannel):
|
|||||||
if "@_all" in raw_content:
|
if "@_all" in raw_content:
|
||||||
return True
|
return True
|
||||||
|
|
||||||
for mention in getattr(message, "mentions", None) or []:
|
for mention in cast(list[Any], getattr(message, "mentions", None) or []):
|
||||||
if self._is_bot_mention_event(mention):
|
if self._is_bot_mention_event(mention):
|
||||||
return True
|
return True
|
||||||
return False
|
return False
|
||||||
@ -1312,7 +1372,7 @@ class FeishuChannel(BaseChannel):
|
|||||||
loop = asyncio.get_running_loop()
|
loop = asyncio.get_running_loop()
|
||||||
await loop.run_in_executor(None, self._remove_reaction_sync, message_id, reaction_id)
|
await loop.run_in_executor(None, self._remove_reaction_sync, message_id, reaction_id)
|
||||||
|
|
||||||
def _on_background_task_done(self, task: asyncio.Task) -> None:
|
def _on_background_task_done(self, task: asyncio.Task[Any]) -> None:
|
||||||
"""Callback: remove from tracking set and log unhandled exceptions."""
|
"""Callback: remove from tracking set and log unhandled exceptions."""
|
||||||
self._background_tasks.discard(task)
|
self._background_tasks.discard(task)
|
||||||
if task.cancelled():
|
if task.cancelled():
|
||||||
@ -1322,7 +1382,7 @@ class FeishuChannel(BaseChannel):
|
|||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
self.logger.warning("Background task failed: {}", exc)
|
self.logger.warning("Background task failed: {}", exc)
|
||||||
|
|
||||||
def _on_reaction_added(self, message_id: str, task: asyncio.Task) -> None:
|
def _on_reaction_added(self, message_id: str, task: asyncio.Task[Any]) -> None:
|
||||||
"""Callback: store reaction_id after background add-reaction completes."""
|
"""Callback: store reaction_id after background add-reaction completes."""
|
||||||
if task.cancelled():
|
if task.cancelled():
|
||||||
return
|
return
|
||||||
@ -1375,7 +1435,7 @@ class FeishuChannel(BaseChannel):
|
|||||||
return text
|
return text
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def _parse_md_table(cls, table_text: str) -> dict | None:
|
def _parse_md_table(cls, table_text: str) -> dict[str, Any] | None:
|
||||||
"""Parse a markdown table into a Feishu table element."""
|
"""Parse a markdown table into a Feishu table element."""
|
||||||
lines = [_line.strip() for _line in table_text.strip().split("\n") if _line.strip()]
|
lines = [_line.strip() for _line in table_text.strip().split("\n") if _line.strip()]
|
||||||
if len(lines) < 3:
|
if len(lines) < 3:
|
||||||
@ -1399,7 +1459,7 @@ class FeishuChannel(BaseChannel):
|
|||||||
],
|
],
|
||||||
}
|
}
|
||||||
|
|
||||||
def _build_card_elements(self, content: str) -> list[dict]:
|
def _build_card_elements(self, content: str) -> list[dict[str, Any]]:
|
||||||
"""Split content into div/markdown + table elements for Feishu card."""
|
"""Split content into div/markdown + table elements for Feishu card."""
|
||||||
protected = content
|
protected = content
|
||||||
code_blocks: list[str] = []
|
code_blocks: list[str] = []
|
||||||
@ -1407,7 +1467,8 @@ class FeishuChannel(BaseChannel):
|
|||||||
code_blocks.append(m.group(1))
|
code_blocks.append(m.group(1))
|
||||||
protected = protected.replace(m.group(1), f"\x00CODE{len(code_blocks) - 1}\x00", 1)
|
protected = protected.replace(m.group(1), f"\x00CODE{len(code_blocks) - 1}\x00", 1)
|
||||||
|
|
||||||
elements, last_end = [], 0
|
elements: list[dict[str, Any]] = []
|
||||||
|
last_end = 0
|
||||||
for m in self._TABLE_RE.finditer(protected):
|
for m in self._TABLE_RE.finditer(protected):
|
||||||
before = protected[last_end : m.start()]
|
before = protected[last_end : m.start()]
|
||||||
if before.strip():
|
if before.strip():
|
||||||
@ -1429,8 +1490,8 @@ class FeishuChannel(BaseChannel):
|
|||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _split_elements_by_table_limit(
|
def _split_elements_by_table_limit(
|
||||||
elements: list[dict], max_tables: int = 1
|
elements: list[dict[str, Any]], max_tables: int = 1
|
||||||
) -> list[list[dict]]:
|
) -> list[list[dict[str, Any]]]:
|
||||||
"""Split card elements into groups with at most *max_tables* table elements each.
|
"""Split card elements into groups with at most *max_tables* table elements each.
|
||||||
|
|
||||||
Feishu cards have a hard limit of one table per card (API error 11310).
|
Feishu cards have a hard limit of one table per card (API error 11310).
|
||||||
@ -1439,8 +1500,8 @@ class FeishuChannel(BaseChannel):
|
|||||||
"""
|
"""
|
||||||
if not elements:
|
if not elements:
|
||||||
return [[]]
|
return [[]]
|
||||||
groups: list[list[dict]] = []
|
groups: list[list[dict[str, Any]]] = []
|
||||||
current: list[dict] = []
|
current: list[dict[str, Any]] = []
|
||||||
table_count = 0
|
table_count = 0
|
||||||
for el in elements:
|
for el in elements:
|
||||||
if el.get("tag") == "table":
|
if el.get("tag") == "table":
|
||||||
@ -1457,15 +1518,15 @@ class FeishuChannel(BaseChannel):
|
|||||||
groups.append(current)
|
groups.append(current)
|
||||||
return groups or [[]]
|
return groups or [[]]
|
||||||
|
|
||||||
def _split_headings(self, content: str) -> list[dict]:
|
def _split_headings(self, content: str) -> list[dict[str, Any]]:
|
||||||
"""Split content by headings, converting headings to div elements."""
|
"""Split content by headings, converting headings to div elements."""
|
||||||
protected = content
|
protected = content
|
||||||
code_blocks = []
|
code_blocks: list[str] = []
|
||||||
for m in self._CODE_BLOCK_RE.finditer(content):
|
for m in self._CODE_BLOCK_RE.finditer(content):
|
||||||
code_blocks.append(m.group(1))
|
code_blocks.append(m.group(1))
|
||||||
protected = protected.replace(m.group(1), f"\x00CODE{len(code_blocks) - 1}\x00", 1)
|
protected = protected.replace(m.group(1), f"\x00CODE{len(code_blocks) - 1}\x00", 1)
|
||||||
|
|
||||||
elements = []
|
elements: list[dict[str, Any]] = []
|
||||||
last_end = 0
|
last_end = 0
|
||||||
for m in self._HEADING_RE.finditer(protected):
|
for m in self._HEADING_RE.finditer(protected):
|
||||||
before = protected[last_end : m.start()].strip()
|
before = protected[last_end : m.start()].strip()
|
||||||
@ -1573,10 +1634,10 @@ class FeishuChannel(BaseChannel):
|
|||||||
Each line becomes a paragraph (row) in the post body.
|
Each line becomes a paragraph (row) in the post body.
|
||||||
"""
|
"""
|
||||||
lines = content.strip().split("\n")
|
lines = content.strip().split("\n")
|
||||||
paragraphs: list[list[dict]] = []
|
paragraphs: list[list[dict[str, Any]]] = []
|
||||||
|
|
||||||
for line in lines:
|
for line in lines:
|
||||||
elements: list[dict] = []
|
elements: list[dict[str, Any]] = []
|
||||||
last_end = 0
|
last_end = 0
|
||||||
|
|
||||||
for m in cls._MD_LINK_RE.finditer(line):
|
for m in cls._MD_LINK_RE.finditer(line):
|
||||||
@ -1768,7 +1829,7 @@ class FeishuChannel(BaseChannel):
|
|||||||
return candidate
|
return candidate
|
||||||
|
|
||||||
async def _download_and_save_media(
|
async def _download_and_save_media(
|
||||||
self, msg_type: str, content_json: dict, message_id: str | None = None
|
self, msg_type: str, content_json: dict[str, Any], message_id: str | None = None
|
||||||
) -> tuple[str | None, str]:
|
) -> tuple[str | None, str]:
|
||||||
"""
|
"""
|
||||||
Download media from Feishu and save to local disk.
|
Download media from Feishu and save to local disk.
|
||||||
@ -2306,8 +2367,11 @@ class FeishuChannel(BaseChannel):
|
|||||||
fallback_msg_id = self._thread_reply_target(meta)
|
fallback_msg_id = self._thread_reply_target(meta)
|
||||||
if fallback_msg_id:
|
if fallback_msg_id:
|
||||||
await loop.run_in_executor(
|
await loop.run_in_executor(
|
||||||
None, lambda: self._reply_message_sync(
|
None, partial(
|
||||||
fallback_msg_id, "interactive", card,
|
self._reply_message_sync,
|
||||||
|
fallback_msg_id,
|
||||||
|
"interactive",
|
||||||
|
card,
|
||||||
reply_in_thread=self._should_use_reply_in_thread(meta),
|
reply_in_thread=self._should_use_reply_in_thread(meta),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
@ -2563,6 +2627,9 @@ class FeishuChannel(BaseChannel):
|
|||||||
return
|
return
|
||||||
try:
|
try:
|
||||||
event = data.event
|
event = data.event
|
||||||
|
if event is None or event.message is None or event.sender is None:
|
||||||
|
self.logger.warning("Ignoring incomplete Feishu message event")
|
||||||
|
return
|
||||||
message = event.message
|
message = event.message
|
||||||
sender = event.sender
|
sender = event.sender
|
||||||
|
|
||||||
@ -2579,6 +2646,20 @@ class FeishuChannel(BaseChannel):
|
|||||||
chat_id = message.chat_id
|
chat_id = message.chat_id
|
||||||
chat_type = message.chat_type
|
chat_type = message.chat_type
|
||||||
msg_type = message.message_type
|
msg_type = message.message_type
|
||||||
|
if not all(isinstance(value, str) and value for value in (
|
||||||
|
message_id,
|
||||||
|
sender_id,
|
||||||
|
chat_id,
|
||||||
|
chat_type,
|
||||||
|
msg_type,
|
||||||
|
)):
|
||||||
|
self.logger.warning("Ignoring Feishu message event with missing routing fields")
|
||||||
|
return
|
||||||
|
message_id = cast(str, message_id)
|
||||||
|
sender_id = cast(str, sender_id)
|
||||||
|
chat_id = cast(str, chat_id)
|
||||||
|
chat_type = cast(str, chat_type)
|
||||||
|
msg_type = cast(str, msg_type)
|
||||||
|
|
||||||
if chat_type == "group" and not self._is_group_message_for_bot(message):
|
if chat_type == "group" and not self._is_group_message_for_bot(message):
|
||||||
self.logger.debug("skipping group message (not mentioned)")
|
self.logger.debug("skipping group message (not mentioned)")
|
||||||
@ -2616,17 +2697,19 @@ class FeishuChannel(BaseChannel):
|
|||||||
task.add_done_callback(lambda t: self._on_reaction_added(message_id, t))
|
task.add_done_callback(lambda t: self._on_reaction_added(message_id, t))
|
||||||
|
|
||||||
# Parse content
|
# Parse content
|
||||||
content_parts = []
|
content_parts: list[str] = []
|
||||||
media_paths = []
|
media_paths: list[str] = []
|
||||||
|
|
||||||
try:
|
try:
|
||||||
content_json = json.loads(message.content) if message.content else {}
|
raw_content = message.content if isinstance(message.content, str) else ""
|
||||||
|
content_json = _as_json_object(json.loads(raw_content)) if raw_content else {}
|
||||||
except json.JSONDecodeError:
|
except json.JSONDecodeError:
|
||||||
content_json = {}
|
content_json = {}
|
||||||
|
content_json = content_json or {}
|
||||||
|
|
||||||
if msg_type == "text":
|
if msg_type == "text":
|
||||||
text = content_json.get("text", "")
|
text = content_json.get("text", "")
|
||||||
if text:
|
if isinstance(text, str) and text:
|
||||||
mentions = getattr(message, "mentions", None)
|
mentions = getattr(message, "mentions", None)
|
||||||
text = self._strip_leading_bot_mention(text, mentions)
|
text = self._strip_leading_bot_mention(text, mentions)
|
||||||
text = self._resolve_mentions(text, mentions)
|
text = self._resolve_mentions(text, mentions)
|
||||||
@ -2676,9 +2759,12 @@ class FeishuChannel(BaseChannel):
|
|||||||
content_parts.append(MSG_TYPE_MAP.get(msg_type, f"[{msg_type}]"))
|
content_parts.append(MSG_TYPE_MAP.get(msg_type, f"[{msg_type}]"))
|
||||||
|
|
||||||
# Extract reply context (parent/root message IDs)
|
# Extract reply context (parent/root message IDs)
|
||||||
parent_id = getattr(message, "parent_id", None) or None
|
parent_id = getattr(message, "parent_id", None)
|
||||||
root_id = getattr(message, "root_id", None) or None
|
root_id = getattr(message, "root_id", None)
|
||||||
thread_id = getattr(message, "thread_id", None) or None
|
thread_id = getattr(message, "thread_id", None)
|
||||||
|
parent_id = parent_id if isinstance(parent_id, str) else None
|
||||||
|
root_id = root_id if isinstance(root_id, str) else None
|
||||||
|
thread_id = thread_id if isinstance(thread_id, str) else None
|
||||||
|
|
||||||
# Prepend quoted message text when the user replied to another message
|
# Prepend quoted message text when the user replied to another message
|
||||||
if parent_id and self._client:
|
if parent_id and self._client:
|
||||||
|
|||||||
@ -1,3 +1,4 @@
|
|||||||
|
# pyright: reportMissingTypeStubs=false, reportPrivateUsage=false
|
||||||
"""Shared Feishu/Lark WebSocket runtime.
|
"""Shared Feishu/Lark WebSocket runtime.
|
||||||
|
|
||||||
The official lark_oapi websocket client stores an asyncio loop in a module-level
|
The official lark_oapi websocket client stores an asyncio loop in a module-level
|
||||||
@ -148,7 +149,7 @@ class FeishuWsRunner:
|
|||||||
async def _client_main(
|
async def _client_main(
|
||||||
self, key: str, client: _LarkWsClient, stop_event: asyncio.Event
|
self, key: str, client: _LarkWsClient, stop_event: asyncio.Event
|
||||||
) -> None:
|
) -> None:
|
||||||
ping_task: asyncio.Task | None = None
|
ping_task: asyncio.Task[None] | None = None
|
||||||
while not stop_event.is_set():
|
while not stop_event.is_set():
|
||||||
try:
|
try:
|
||||||
await client._connect()
|
await client._connect()
|
||||||
@ -171,12 +172,12 @@ class FeishuWsRunner:
|
|||||||
await client._disconnect()
|
await client._disconnect()
|
||||||
|
|
||||||
|
|
||||||
_RUNNER: FeishuWsRunner | None = None
|
_runner: FeishuWsRunner | None = None
|
||||||
|
|
||||||
|
|
||||||
def get_feishu_ws_runner() -> FeishuWsRunner:
|
def get_feishu_ws_runner() -> FeishuWsRunner:
|
||||||
"""Return the process-wide Feishu WebSocket runner."""
|
"""Return the process-wide Feishu WebSocket runner."""
|
||||||
global _RUNNER
|
global _runner
|
||||||
if _RUNNER is None:
|
if _runner is None:
|
||||||
_RUNNER = FeishuWsRunner()
|
_runner = FeishuWsRunner()
|
||||||
return _RUNNER
|
return _runner
|
||||||
|
|||||||
@ -8,7 +8,7 @@ import inspect
|
|||||||
from collections.abc import Callable, Iterable
|
from collections.abc import Callable, Iterable
|
||||||
from contextlib import suppress
|
from contextlib import suppress
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import TYPE_CHECKING, Any
|
from typing import TYPE_CHECKING, Any, cast
|
||||||
|
|
||||||
from loguru import logger
|
from loguru import logger
|
||||||
|
|
||||||
@ -41,7 +41,9 @@ from nanobot.utils.restart import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
|
from nanobot.cron.service import CronService
|
||||||
from nanobot.session.manager import SessionManager
|
from nanobot.session.manager import SessionManager
|
||||||
|
from nanobot.triggers.local_store import LocalTriggerStore
|
||||||
|
|
||||||
|
|
||||||
def _default_webui_dist() -> Path | None:
|
def _default_webui_dist() -> Path | None:
|
||||||
@ -90,8 +92,8 @@ class ChannelManager:
|
|||||||
bus: MessageBus,
|
bus: MessageBus,
|
||||||
*,
|
*,
|
||||||
session_manager: "SessionManager | None" = None,
|
session_manager: "SessionManager | None" = None,
|
||||||
cron_service: Any | None = None,
|
cron_service: CronService | None = None,
|
||||||
local_trigger_store: Any | None = None,
|
local_trigger_store: LocalTriggerStore | None = None,
|
||||||
webui_runtime_model_name: Callable[[], str | None] | None = None,
|
webui_runtime_model_name: Callable[[], str | None] | None = None,
|
||||||
webui_cron_pending_job_ids: Callable[[str], set[str]] | None = None,
|
webui_cron_pending_job_ids: Callable[[str], set[str]] | None = None,
|
||||||
webui_local_trigger_pending_ids: Callable[[str], set[str]] | None = None,
|
webui_local_trigger_pending_ids: Callable[[str], set[str]] | None = None,
|
||||||
@ -114,8 +116,8 @@ class ChannelManager:
|
|||||||
self._channel_owners: dict[str, str] = {}
|
self._channel_owners: dict[str, str] = {}
|
||||||
self._channel_runtime_specs: dict[str, tuple[str, str]] = {}
|
self._channel_runtime_specs: dict[str, tuple[str, str]] = {}
|
||||||
self._channel_errors: dict[str, str] = {}
|
self._channel_errors: dict[str, str] = {}
|
||||||
self._channel_tasks: dict[str, asyncio.Task] = {}
|
self._channel_tasks: dict[str, asyncio.Task[None]] = {}
|
||||||
self._dispatch_task: asyncio.Task | None = None
|
self._dispatch_task: asyncio.Task[None] | None = None
|
||||||
self._started = False
|
self._started = False
|
||||||
self._origin_reply_fingerprints: dict[tuple[str, str, str], str] = {}
|
self._origin_reply_fingerprints: dict[tuple[str, str, str], str] = {}
|
||||||
|
|
||||||
@ -291,10 +293,11 @@ class ChannelManager:
|
|||||||
for name, ch in self.channels.items():
|
for name, ch in self.channels.items():
|
||||||
cfg = ch.config
|
cfg = ch.config
|
||||||
if isinstance(cfg, dict):
|
if isinstance(cfg, dict):
|
||||||
if "allow_from" in cfg:
|
config_data = cast(dict[str, Any], cfg)
|
||||||
allow = cfg.get("allow_from")
|
if "allow_from" in config_data:
|
||||||
|
allow = config_data.get("allow_from")
|
||||||
else:
|
else:
|
||||||
allow = cfg.get("allowFrom")
|
allow = config_data.get("allowFrom")
|
||||||
else:
|
else:
|
||||||
allow = getattr(cfg, "allow_from", None)
|
allow = getattr(cfg, "allow_from", None)
|
||||||
if allow is None:
|
if allow is None:
|
||||||
@ -321,11 +324,12 @@ class ChannelManager:
|
|||||||
Pydantic models.
|
Pydantic models.
|
||||||
"""
|
"""
|
||||||
if isinstance(section, dict):
|
if isinstance(section, dict):
|
||||||
value = section.get(key)
|
section_data = cast(dict[str, Any], section)
|
||||||
|
value = section_data.get(key)
|
||||||
if value is None:
|
if value is None:
|
||||||
camel = _BOOL_CAMEL_ALIASES.get(key)
|
camel = _BOOL_CAMEL_ALIASES.get(key)
|
||||||
if camel:
|
if camel:
|
||||||
value = section.get(camel)
|
value = section_data.get(camel)
|
||||||
return value if isinstance(value, bool) else default
|
return value if isinstance(value, bool) else default
|
||||||
value = getattr(section, key, None)
|
value = getattr(section, key, None)
|
||||||
return value if isinstance(value, bool) else default
|
return value if isinstance(value, bool) else default
|
||||||
@ -344,7 +348,7 @@ class ChannelManager:
|
|||||||
errors[name] = "Channel failed to start. Check gateway logs."
|
errors[name] = "Channel failed to start. Check gateway logs."
|
||||||
logger.exception("Failed to start channel {}", name)
|
logger.exception("Failed to start channel {}", name)
|
||||||
|
|
||||||
def _start_channel_task(self, name: str, channel: BaseChannel) -> asyncio.Task:
|
def _start_channel_task(self, name: str, channel: BaseChannel) -> asyncio.Task[None]:
|
||||||
logger.info("Starting {} channel...", name)
|
logger.info("Starting {} channel...", name)
|
||||||
task = asyncio.create_task(self._start_channel(name, channel))
|
task = asyncio.create_task(self._start_channel(name, channel))
|
||||||
self._channel_tasks[name] = task
|
self._channel_tasks[name] = task
|
||||||
@ -361,7 +365,8 @@ class ChannelManager:
|
|||||||
await channel.stop()
|
await channel.stop()
|
||||||
logger.info("Stopped {} channel", name)
|
logger.info("Stopped {} channel", name)
|
||||||
except asyncio.CancelledError:
|
except asyncio.CancelledError:
|
||||||
if asyncio.current_task() and asyncio.current_task().cancelling():
|
current_task = asyncio.current_task()
|
||||||
|
if current_task is not None and current_task.cancelling():
|
||||||
raise
|
raise
|
||||||
logger.debug("Channel {} stop task was already cancelled", name)
|
logger.debug("Channel {} stop task was already cancelled", name)
|
||||||
except Exception:
|
except Exception:
|
||||||
@ -553,7 +558,7 @@ class ChannelManager:
|
|||||||
self._dispatch_task = asyncio.create_task(self._dispatch_outbound())
|
self._dispatch_task = asyncio.create_task(self._dispatch_outbound())
|
||||||
|
|
||||||
# Start channels
|
# Start channels
|
||||||
tasks = []
|
tasks: list[asyncio.Task[None]] = []
|
||||||
for name, channel in self.channels.items():
|
for name, channel in self.channels.items():
|
||||||
tasks.append(self._start_channel_task(name, channel))
|
tasks.append(self._start_channel_task(name, channel))
|
||||||
|
|
||||||
|
|||||||
@ -1,5 +1,7 @@
|
|||||||
"""Matrix (Element) channel — inbound sync + outbound message/media delivery."""
|
"""Matrix (Element) channel — inbound sync + outbound message/media delivery."""
|
||||||
|
|
||||||
|
# pyright: reportMissingTypeStubs=false
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
import html
|
import html
|
||||||
import json
|
import json
|
||||||
@ -10,7 +12,7 @@ import time
|
|||||||
from contextlib import suppress
|
from contextlib import suppress
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any, Literal, TypeAlias
|
from typing import Any, Callable, Literal, Protocol, TypeAlias, cast
|
||||||
from urllib.parse import quote, unquote, urlparse
|
from urllib.parse import quote, unquote, urlparse
|
||||||
|
|
||||||
from pydantic import Field
|
from pydantic import Field
|
||||||
@ -75,6 +77,18 @@ MATRIX_MEDIA_EVENT_FILTER = (RoomMessageMedia, RoomEncryptedMedia)
|
|||||||
MatrixMediaEvent: TypeAlias = RoomMessageMedia | RoomEncryptedMedia
|
MatrixMediaEvent: TypeAlias = RoomMessageMedia | RoomEncryptedMedia
|
||||||
|
|
||||||
|
|
||||||
|
class _MatrixCallbackRegistrar(Protocol):
|
||||||
|
"""Runtime callback surface whose upstream stubs reject valid filtered handlers."""
|
||||||
|
|
||||||
|
def add_event_callback(self, callback: Callable[..., Any], event_filter: Any) -> None: ...
|
||||||
|
def add_to_device_callback(
|
||||||
|
self,
|
||||||
|
callback: Callable[..., Any],
|
||||||
|
event_filter: Any,
|
||||||
|
) -> None: ...
|
||||||
|
def add_response_callback(self, callback: Callable[..., Any], response_filter: Any) -> None: ...
|
||||||
|
|
||||||
|
|
||||||
class _MediaTooLargeError(Exception):
|
class _MediaTooLargeError(Exception):
|
||||||
"""Raised when an inbound Matrix media download exceeds the configured cap."""
|
"""Raised when an inbound Matrix media download exceeds the configured cap."""
|
||||||
|
|
||||||
@ -187,7 +201,7 @@ def _render_markdown_html(text: str) -> str | None:
|
|||||||
"""Render markdown to sanitized HTML; returns None for plain text."""
|
"""Render markdown to sanitized HTML; returns None for plain text."""
|
||||||
try:
|
try:
|
||||||
masked_text = _mask_mxc_markdown_image_sources(text)
|
masked_text = _mask_mxc_markdown_image_sources(text)
|
||||||
rendered = _mask_mxc_image_sources(MATRIX_MARKDOWN(masked_text))
|
rendered = _mask_mxc_image_sources(cast(str, MATRIX_MARKDOWN(masked_text)))
|
||||||
formatted = _unmask_mxc_image_sources(MATRIX_HTML_CLEANER.clean(rendered).strip())
|
formatted = _unmask_mxc_image_sources(MATRIX_HTML_CLEANER.clean(rendered).strip())
|
||||||
except Exception:
|
except Exception:
|
||||||
return None
|
return None
|
||||||
@ -229,16 +243,17 @@ def _build_matrix_text_content(
|
|||||||
content["format"] = MATRIX_HTML_FORMAT
|
content["format"] = MATRIX_HTML_FORMAT
|
||||||
content["formatted_body"] = html
|
content["formatted_body"] = html
|
||||||
if event_id:
|
if event_id:
|
||||||
content["m.new_content"] = {
|
new_content: dict[str, object] = {
|
||||||
"body": text,
|
"body": text,
|
||||||
"msgtype": "m.text",
|
"msgtype": "m.text",
|
||||||
}
|
}
|
||||||
|
content["m.new_content"] = new_content
|
||||||
content["m.relates_to"] = {
|
content["m.relates_to"] = {
|
||||||
"rel_type": "m.replace",
|
"rel_type": "m.replace",
|
||||||
"event_id": event_id,
|
"event_id": event_id,
|
||||||
}
|
}
|
||||||
if thread_relates_to:
|
if thread_relates_to:
|
||||||
content["m.new_content"]["m.relates_to"] = thread_relates_to
|
new_content["m.relates_to"] = thread_relates_to
|
||||||
elif thread_relates_to:
|
elif thread_relates_to:
|
||||||
content["m.relates_to"] = thread_relates_to
|
content["m.relates_to"] = thread_relates_to
|
||||||
|
|
||||||
@ -276,7 +291,7 @@ class MatrixChannel(BaseChannel):
|
|||||||
name = "matrix"
|
name = "matrix"
|
||||||
display_name = "Matrix"
|
display_name = "Matrix"
|
||||||
_STREAM_EDIT_INTERVAL = 2 # min seconds between edit_message_text calls
|
_STREAM_EDIT_INTERVAL = 2 # min seconds between edit_message_text calls
|
||||||
monotonic_time = time.monotonic
|
monotonic_time: Callable[[], float] = staticmethod(time.monotonic)
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def default_config(cls) -> dict[str, Any]:
|
def default_config(cls) -> dict[str, Any]:
|
||||||
@ -294,8 +309,8 @@ class MatrixChannel(BaseChannel):
|
|||||||
config = MatrixConfig.model_validate(config)
|
config = MatrixConfig.model_validate(config)
|
||||||
super().__init__(config, bus)
|
super().__init__(config, bus)
|
||||||
self.client: AsyncClient | None = None
|
self.client: AsyncClient | None = None
|
||||||
self._sync_task: asyncio.Task | None = None
|
self._sync_task: asyncio.Task[None] | None = None
|
||||||
self._typing_tasks: dict[str, asyncio.Task] = {}
|
self._typing_tasks: dict[str, asyncio.Task[None]] = {}
|
||||||
self._restrict_to_workspace = bool(restrict_to_workspace)
|
self._restrict_to_workspace = bool(restrict_to_workspace)
|
||||||
self._workspace = (
|
self._workspace = (
|
||||||
Path(workspace).expanduser().resolve(strict=False) if workspace is not None else None
|
Path(workspace).expanduser().resolve(strict=False) if workspace is not None else None
|
||||||
@ -325,7 +340,7 @@ class MatrixChannel(BaseChannel):
|
|||||||
self.client = AsyncClient(
|
self.client = AsyncClient(
|
||||||
homeserver=self.config.homeserver,
|
homeserver=self.config.homeserver,
|
||||||
user=self.config.user_id,
|
user=self.config.user_id,
|
||||||
store_path=self.store_path,
|
store_path=str(self.store_path),
|
||||||
config=AsyncClientConfig(
|
config=AsyncClientConfig(
|
||||||
store_sync_tokens=True,
|
store_sync_tokens=True,
|
||||||
encryption_enabled=self.config.e2ee_enabled,
|
encryption_enabled=self.config.e2ee_enabled,
|
||||||
@ -386,6 +401,16 @@ class MatrixChannel(BaseChannel):
|
|||||||
|
|
||||||
self._sync_task = asyncio.create_task(self._sync_loop())
|
self._sync_task = asyncio.create_task(self._sync_loop())
|
||||||
|
|
||||||
|
def _require_client(self) -> AsyncClient:
|
||||||
|
if self.client is None:
|
||||||
|
raise RuntimeError("Matrix client is not started")
|
||||||
|
return self.client
|
||||||
|
|
||||||
|
def _callback_registrar(self) -> _MatrixCallbackRegistrar:
|
||||||
|
# matrix-nio's callback annotations do not model filtered subtype or
|
||||||
|
# async handlers, although the runtime API supports both.
|
||||||
|
return cast(_MatrixCallbackRegistrar, self._require_client())
|
||||||
|
|
||||||
async def stop(self) -> None:
|
async def stop(self) -> None:
|
||||||
"""Stop the Matrix channel with graceful sync shutdown."""
|
"""Stop the Matrix channel with graceful sync shutdown."""
|
||||||
self._running = False
|
self._running = False
|
||||||
@ -428,9 +453,10 @@ class MatrixChannel(BaseChannel):
|
|||||||
seen: set[str] = set()
|
seen: set[str] = set()
|
||||||
candidates: list[Path] = []
|
candidates: list[Path] = []
|
||||||
for raw in media:
|
for raw in media:
|
||||||
if not isinstance(raw, str) or not raw.strip():
|
raw_value = cast(object, raw)
|
||||||
|
if not isinstance(raw_value, str) or not raw_value.strip():
|
||||||
continue
|
continue
|
||||||
path = Path(raw.strip()).expanduser()
|
path = Path(raw_value.strip()).expanduser()
|
||||||
try:
|
try:
|
||||||
key = str(path.resolve(strict=False))
|
key = str(path.resolve(strict=False))
|
||||||
except OSError:
|
except OSError:
|
||||||
@ -535,8 +561,13 @@ class MatrixChannel(BaseChannel):
|
|||||||
self.logger.error("Matrix media upload failed for %s", filename, exc_info=True)
|
self.logger.error("Matrix media upload failed for %s", filename, exc_info=True)
|
||||||
return fail
|
return fail
|
||||||
|
|
||||||
upload_response = upload_result[0] if isinstance(upload_result, tuple) else upload_result
|
is_tuple_result = isinstance(cast(object, upload_result), tuple)
|
||||||
encryption_info = upload_result[1] if isinstance(upload_result, tuple) and isinstance(upload_result[1], dict) else None
|
upload_response = upload_result[0] if is_tuple_result else upload_result
|
||||||
|
encryption_info = (
|
||||||
|
upload_result[1]
|
||||||
|
if is_tuple_result and isinstance(cast(object, upload_result[1]), dict)
|
||||||
|
else None
|
||||||
|
)
|
||||||
if isinstance(upload_response, UploadError):
|
if isinstance(upload_response, UploadError):
|
||||||
return fail
|
return fail
|
||||||
mxc_url = getattr(upload_response, "content_uri", None)
|
mxc_url = getattr(upload_response, "content_uri", None)
|
||||||
@ -645,28 +676,31 @@ class MatrixChannel(BaseChannel):
|
|||||||
buf.last_edit = now
|
buf.last_edit = now
|
||||||
if not buf.event_id:
|
if not buf.event_id:
|
||||||
# we are editing the same message all the time, so only the first time the event id needs to be set
|
# we are editing the same message all the time, so only the first time the event id needs to be set
|
||||||
buf.event_id = response.event_id
|
buf.event_id = cast(RoomSendResponse, response).event_id
|
||||||
except Exception:
|
except Exception:
|
||||||
self.logger.error("Stream send/edit failed for chat_id=%s", chat_id, exc_info=True)
|
self.logger.error("Stream send/edit failed for chat_id=%s", chat_id, exc_info=True)
|
||||||
await self._stop_typing_keepalive(chat_id, clear_typing=True)
|
await self._stop_typing_keepalive(chat_id, clear_typing=True)
|
||||||
|
|
||||||
|
|
||||||
def _register_event_callbacks(self) -> None:
|
def _register_event_callbacks(self) -> None:
|
||||||
self.client.add_event_callback(self._on_message, RoomMessageText)
|
client = self._callback_registrar()
|
||||||
self.client.add_event_callback(self._on_media_message, MATRIX_MEDIA_EVENT_FILTER)
|
client.add_event_callback(self._on_message, RoomMessageText)
|
||||||
self.client.add_event_callback(self._on_room_invite, InviteEvent)
|
client.add_event_callback(self._on_media_message, MATRIX_MEDIA_EVENT_FILTER)
|
||||||
|
client.add_event_callback(self._on_room_invite, InviteEvent)
|
||||||
|
|
||||||
def _register_to_device_callbacks(self) -> None:
|
def _register_to_device_callbacks(self) -> None:
|
||||||
if self.config.e2ee_enabled and self.config.sas_verification:
|
if self.config.e2ee_enabled and self.config.sas_verification:
|
||||||
self.client.add_to_device_callback(
|
client = self._callback_registrar()
|
||||||
|
client.add_to_device_callback(
|
||||||
self._on_key_verification_event,
|
self._on_key_verification_event,
|
||||||
(KeyVerificationEvent,),
|
(KeyVerificationEvent,),
|
||||||
)
|
)
|
||||||
|
|
||||||
def _register_response_callbacks(self) -> None:
|
def _register_response_callbacks(self) -> None:
|
||||||
self.client.add_response_callback(self._on_sync_error, SyncError)
|
client = self._callback_registrar()
|
||||||
self.client.add_response_callback(self._on_join_error, JoinError)
|
client.add_response_callback(self._on_sync_error, SyncError)
|
||||||
self.client.add_response_callback(self._on_send_error, RoomSendError)
|
client.add_response_callback(self._on_join_error, JoinError)
|
||||||
|
client.add_response_callback(self._on_send_error, RoomSendError)
|
||||||
|
|
||||||
def _is_sas_sender_allowed(self, sender: str) -> bool:
|
def _is_sas_sender_allowed(self, sender: str) -> bool:
|
||||||
return bool(sender and self.is_allowed(sender))
|
return bool(sender and self.is_allowed(sender))
|
||||||
@ -791,7 +825,8 @@ class MatrixChannel(BaseChannel):
|
|||||||
backoff = 2.0
|
backoff = 2.0
|
||||||
while self._running:
|
while self._running:
|
||||||
try:
|
try:
|
||||||
await self.client.sync_forever(timeout=30000, full_state=True)
|
client = self._require_client()
|
||||||
|
await client.sync_forever(timeout=30000, full_state=True)
|
||||||
backoff = 2.0
|
backoff = 2.0
|
||||||
except asyncio.CancelledError:
|
except asyncio.CancelledError:
|
||||||
break
|
break
|
||||||
@ -803,7 +838,8 @@ class MatrixChannel(BaseChannel):
|
|||||||
|
|
||||||
async def _on_room_invite(self, room: MatrixRoom, event: InviteEvent) -> None:
|
async def _on_room_invite(self, room: MatrixRoom, event: InviteEvent) -> None:
|
||||||
if self.is_allowed(event.sender):
|
if self.is_allowed(event.sender):
|
||||||
await self.client.join(room.room_id)
|
client = self._require_client()
|
||||||
|
await client.join(room.room_id)
|
||||||
|
|
||||||
def _is_direct_room(self, room: MatrixRoom) -> bool:
|
def _is_direct_room(self, room: MatrixRoom) -> bool:
|
||||||
count = getattr(room, "member_count", None)
|
count = getattr(room, "member_count", None)
|
||||||
@ -814,13 +850,19 @@ class MatrixChannel(BaseChannel):
|
|||||||
source = getattr(event, "source", None)
|
source = getattr(event, "source", None)
|
||||||
if not isinstance(source, dict):
|
if not isinstance(source, dict):
|
||||||
return False
|
return False
|
||||||
mentions = (source.get("content") or {}).get("m.mentions")
|
source_data = cast(dict[str, Any], source)
|
||||||
|
content = cast(dict[str, Any], source_data.get("content") or {})
|
||||||
|
mentions = cast(object, content.get("m.mentions"))
|
||||||
if not isinstance(mentions, dict):
|
if not isinstance(mentions, dict):
|
||||||
return False
|
return False
|
||||||
user_ids = mentions.get("user_ids")
|
mentions_data = cast(dict[str, Any], mentions)
|
||||||
|
user_ids = cast(object, mentions_data.get("user_ids"))
|
||||||
if isinstance(user_ids, list) and self.config.user_id in user_ids:
|
if isinstance(user_ids, list) and self.config.user_id in user_ids:
|
||||||
return True
|
return True
|
||||||
return bool(self.config.allow_room_mentions and mentions.get("room") is True)
|
return bool(
|
||||||
|
self.config.allow_room_mentions
|
||||||
|
and mentions_data.get("room") is True
|
||||||
|
)
|
||||||
|
|
||||||
def _is_pre_startup_event(self, event: RoomMessage) -> bool:
|
def _is_pre_startup_event(self, event: RoomMessage) -> bool:
|
||||||
"""Skip events that landed in the timeline before this process started.
|
"""Skip events that landed in the timeline before this process started.
|
||||||
@ -855,14 +897,21 @@ class MatrixChannel(BaseChannel):
|
|||||||
source = getattr(event, "source", None)
|
source = getattr(event, "source", None)
|
||||||
if not isinstance(source, dict):
|
if not isinstance(source, dict):
|
||||||
return {}
|
return {}
|
||||||
content = source.get("content")
|
source_data = cast(dict[str, Any], source)
|
||||||
return content if isinstance(content, dict) else {}
|
content = cast(object, source_data.get("content"))
|
||||||
|
return cast(dict[str, Any], content) if isinstance(content, dict) else {}
|
||||||
|
|
||||||
def _event_thread_root_id(self, event: RoomMessage) -> str | None:
|
def _event_thread_root_id(self, event: RoomMessage) -> str | None:
|
||||||
relates_to = self._event_source_content(event).get("m.relates_to")
|
relates_to = cast(
|
||||||
if not isinstance(relates_to, dict) or relates_to.get("rel_type") != "m.thread":
|
object,
|
||||||
|
self._event_source_content(event).get("m.relates_to"),
|
||||||
|
)
|
||||||
|
if not isinstance(relates_to, dict):
|
||||||
return None
|
return None
|
||||||
root_id = relates_to.get("event_id")
|
relation = cast(dict[str, Any], relates_to)
|
||||||
|
if relation.get("rel_type") != "m.thread":
|
||||||
|
return None
|
||||||
|
root_id = cast(object, relation.get("event_id"))
|
||||||
return root_id if isinstance(root_id, str) and root_id else None
|
return root_id if isinstance(root_id, str) and root_id else None
|
||||||
|
|
||||||
def _thread_metadata(self, event: RoomMessage) -> dict[str, str] | None:
|
def _thread_metadata(self, event: RoomMessage) -> dict[str, str] | None:
|
||||||
@ -888,7 +937,7 @@ class MatrixChannel(BaseChannel):
|
|||||||
|
|
||||||
def _event_attachment_type(self, event: MatrixMediaEvent) -> str:
|
def _event_attachment_type(self, event: MatrixMediaEvent) -> str:
|
||||||
msgtype = self._event_source_content(event).get("msgtype")
|
msgtype = self._event_source_content(event).get("msgtype")
|
||||||
return _MSGTYPE_MAP.get(msgtype, "file")
|
return _MSGTYPE_MAP.get(cast(str, msgtype), "file")
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _is_encrypted_media_event(event: MatrixMediaEvent) -> bool:
|
def _is_encrypted_media_event(event: MatrixMediaEvent) -> bool:
|
||||||
@ -897,16 +946,27 @@ class MatrixChannel(BaseChannel):
|
|||||||
and isinstance(getattr(event, "iv", None), str))
|
and isinstance(getattr(event, "iv", None), str))
|
||||||
|
|
||||||
def _event_declared_size_bytes(self, event: MatrixMediaEvent) -> int | None:
|
def _event_declared_size_bytes(self, event: MatrixMediaEvent) -> int | None:
|
||||||
info = self._event_source_content(event).get("info")
|
info = cast(object, self._event_source_content(event).get("info"))
|
||||||
size = info.get("size") if isinstance(info, dict) else None
|
size = (
|
||||||
|
cast(dict[str, Any], info).get("size")
|
||||||
|
if isinstance(info, dict)
|
||||||
|
else None
|
||||||
|
)
|
||||||
return size if type(size) is int and size >= 0 else None # noqa: E721
|
return size if type(size) is int and size >= 0 else None # noqa: E721
|
||||||
|
|
||||||
def _event_mime(self, event: MatrixMediaEvent) -> str | None:
|
def _event_mime(self, event: MatrixMediaEvent) -> str | None:
|
||||||
info = self._event_source_content(event).get("info")
|
info = cast(object, self._event_source_content(event).get("info"))
|
||||||
if isinstance(info, dict) and isinstance(m := info.get("mimetype"), str) and m:
|
if (
|
||||||
return m
|
isinstance(info, dict)
|
||||||
m = getattr(event, "mimetype", None)
|
and isinstance(
|
||||||
return m if isinstance(m, str) and m else None
|
mime := cast(dict[str, Any], info).get("mimetype"),
|
||||||
|
str,
|
||||||
|
)
|
||||||
|
and mime
|
||||||
|
):
|
||||||
|
return mime
|
||||||
|
mime = getattr(event, "mimetype", None)
|
||||||
|
return mime if isinstance(mime, str) and mime else None
|
||||||
|
|
||||||
def _event_filename(self, event: MatrixMediaEvent, attachment_type: str) -> str:
|
def _event_filename(self, event: MatrixMediaEvent, attachment_type: str) -> str:
|
||||||
body = getattr(event, "body", None)
|
body = getattr(event, "body", None)
|
||||||
@ -973,9 +1033,21 @@ class MatrixChannel(BaseChannel):
|
|||||||
|
|
||||||
def _decrypt_media_bytes(self, event: MatrixMediaEvent, ciphertext: bytes) -> bytes | None:
|
def _decrypt_media_bytes(self, event: MatrixMediaEvent, ciphertext: bytes) -> bytes | None:
|
||||||
key_obj, hashes, iv = getattr(event, "key", None), getattr(event, "hashes", None), getattr(event, "iv", None)
|
key_obj, hashes, iv = getattr(event, "key", None), getattr(event, "hashes", None), getattr(event, "iv", None)
|
||||||
key = key_obj.get("k") if isinstance(key_obj, dict) else None
|
key = (
|
||||||
sha256 = hashes.get("sha256") if isinstance(hashes, dict) else None
|
cast(dict[str, Any], key_obj).get("k")
|
||||||
if not all(isinstance(v, str) for v in (key, sha256, iv)):
|
if isinstance(key_obj, dict)
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
sha256 = (
|
||||||
|
cast(dict[str, Any], hashes).get("sha256")
|
||||||
|
if isinstance(hashes, dict)
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
if (
|
||||||
|
not isinstance(key, str)
|
||||||
|
or not isinstance(sha256, str)
|
||||||
|
or not isinstance(iv, str)
|
||||||
|
):
|
||||||
return None
|
return None
|
||||||
try:
|
try:
|
||||||
return decrypt_attachment(ciphertext, key, sha256, iv)
|
return decrypt_attachment(ciphertext, key, sha256, iv)
|
||||||
|
|||||||
@ -6,7 +6,7 @@ import asyncio
|
|||||||
import json
|
import json
|
||||||
import re
|
import re
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any
|
from typing import Any, cast
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
from pydantic import Field
|
from pydantic import Field
|
||||||
@ -86,7 +86,7 @@ class MattermostChannel(BaseChannel):
|
|||||||
self._server_url = config.server_url.rstrip("/")
|
self._server_url = config.server_url.rstrip("/")
|
||||||
self._ws_url = _server_url_to_ws_url(self._server_url)
|
self._ws_url = _server_url_to_ws_url(self._server_url)
|
||||||
self._http_client: httpx.AsyncClient | None = None
|
self._http_client: httpx.AsyncClient | None = None
|
||||||
self._ws_task: asyncio.Task | None = None
|
self._ws_task: asyncio.Task[None] | None = None
|
||||||
self._self_id: str | None = None
|
self._self_id: str | None = None
|
||||||
self._self_username: str | None = None
|
self._self_username: str | None = None
|
||||||
self._self_email: str | None = None
|
self._self_email: str | None = None
|
||||||
@ -118,7 +118,7 @@ class MattermostChannel(BaseChannel):
|
|||||||
try:
|
try:
|
||||||
resp = await self._http_client.get("/api/v4/users/me")
|
resp = await self._http_client.get("/api/v4/users/me")
|
||||||
resp.raise_for_status()
|
resp.raise_for_status()
|
||||||
me = resp.json()
|
me = cast(dict[str, Any], resp.json())
|
||||||
self._self_id = me.get("id")
|
self._self_id = me.get("id")
|
||||||
self._self_username = me.get("username")
|
self._self_username = me.get("username")
|
||||||
self._self_email = me.get("email", "")
|
self._self_email = me.get("email", "")
|
||||||
@ -169,7 +169,7 @@ class MattermostChannel(BaseChannel):
|
|||||||
self.logger.debug("websocket connected")
|
self.logger.debug("websocket connected")
|
||||||
delay = MATTERMOST_WS_RECONNECT_BASE_DELAY
|
delay = MATTERMOST_WS_RECONNECT_BASE_DELAY
|
||||||
async for raw in ws:
|
async for raw in ws:
|
||||||
await self._handle_ws_message(json.loads(raw))
|
await self._handle_ws_message(cast(dict[str, Any], json.loads(raw)))
|
||||||
except asyncio.CancelledError:
|
except asyncio.CancelledError:
|
||||||
break
|
break
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@ -191,12 +191,15 @@ class MattermostChannel(BaseChannel):
|
|||||||
# Event: posted ------------------------------------------------------------
|
# Event: posted ------------------------------------------------------------
|
||||||
|
|
||||||
async def _handle_posted_event(self, msg: dict[str, Any]) -> None:
|
async def _handle_posted_event(self, msg: dict[str, Any]) -> None:
|
||||||
data = msg.get("data", {})
|
data = cast(dict[str, Any], msg.get("data", {}))
|
||||||
broadcast = msg.get("broadcast", {})
|
broadcast = cast(dict[str, Any], msg.get("broadcast", {}))
|
||||||
|
|
||||||
raw_post = data.get("post", "{}")
|
raw_post = data.get("post", "{}")
|
||||||
try:
|
try:
|
||||||
post = json.loads(raw_post) if isinstance(raw_post, str) else raw_post
|
post = cast(
|
||||||
|
dict[str, Any],
|
||||||
|
json.loads(raw_post) if isinstance(raw_post, str) else raw_post,
|
||||||
|
)
|
||||||
except json.JSONDecodeError:
|
except json.JSONDecodeError:
|
||||||
self.logger.warning("failed to parse post json")
|
self.logger.warning("failed to parse post json")
|
||||||
return
|
return
|
||||||
@ -206,7 +209,7 @@ class MattermostChannel(BaseChannel):
|
|||||||
message_text = post.get("message", "")
|
message_text = post.get("message", "")
|
||||||
root_id = post.get("root_id", "") or ""
|
root_id = post.get("root_id", "") or ""
|
||||||
post_id = post.get("id", "")
|
post_id = post.get("id", "")
|
||||||
file_ids: list[str] = post.get("file_ids", [])
|
file_ids = cast(list[str], post.get("file_ids", []))
|
||||||
|
|
||||||
if self._self_id and sender_id == self._self_id:
|
if self._self_id and sender_id == self._self_id:
|
||||||
return
|
return
|
||||||
@ -292,11 +295,11 @@ class MattermostChannel(BaseChannel):
|
|||||||
# Event: action ------------------------------------------------------------
|
# Event: action ------------------------------------------------------------
|
||||||
|
|
||||||
async def _handle_action_event(self, msg: dict[str, Any]) -> None:
|
async def _handle_action_event(self, msg: dict[str, Any]) -> None:
|
||||||
data = msg.get("data", {})
|
data = cast(dict[str, Any], msg.get("data", {}))
|
||||||
sender_id = data.get("user_id", "")
|
sender_id = data.get("user_id", "")
|
||||||
channel_id = data.get("channel_id", "")
|
channel_id = data.get("channel_id", "")
|
||||||
context = data.get("context", {}) or {}
|
context = cast(dict[str, Any], data.get("context", {}) or {})
|
||||||
value = context.get("selected_option", "")
|
value = cast(str, context.get("selected_option", ""))
|
||||||
|
|
||||||
if not sender_id or not channel_id or not value:
|
if not sender_id or not channel_id or not value:
|
||||||
return
|
return
|
||||||
@ -319,10 +322,13 @@ class MattermostChannel(BaseChannel):
|
|||||||
# Event: post_deleted ------------------------------------------------------
|
# Event: post_deleted ------------------------------------------------------
|
||||||
|
|
||||||
async def _handle_post_deleted_event(self, msg: dict[str, Any]) -> None:
|
async def _handle_post_deleted_event(self, msg: dict[str, Any]) -> None:
|
||||||
data = msg.get("data", {})
|
data = cast(dict[str, Any], msg.get("data", {}))
|
||||||
raw_post = data.get("post", "{}")
|
raw_post = data.get("post", "{}")
|
||||||
try:
|
try:
|
||||||
post = json.loads(raw_post) if isinstance(raw_post, str) else raw_post
|
post = cast(
|
||||||
|
dict[str, Any],
|
||||||
|
json.loads(raw_post) if isinstance(raw_post, str) else raw_post,
|
||||||
|
)
|
||||||
except json.JSONDecodeError:
|
except json.JSONDecodeError:
|
||||||
return
|
return
|
||||||
post_id = post.get("id", "")
|
post_id = post.get("id", "")
|
||||||
@ -363,15 +369,15 @@ class MattermostChannel(BaseChannel):
|
|||||||
return chat_id in self.config.group_allow_from
|
return chat_id in self.config.group_allow_from
|
||||||
return False
|
return False
|
||||||
|
|
||||||
_BOT_MENTION_RE: re.Pattern | None = None
|
_bot_mention_re: re.Pattern[str] | None = None
|
||||||
|
|
||||||
def _is_mentioned(self, text: str) -> bool:
|
def _is_mentioned(self, text: str) -> bool:
|
||||||
if not self._self_username:
|
if not self._self_username:
|
||||||
return False
|
return False
|
||||||
if self._BOT_MENTION_RE is None:
|
if self._bot_mention_re is None:
|
||||||
pat = r"(?<![@\w])@" + re.escape(self._self_username) + r"(?![@\w])"
|
pat = r"(?<![@\w])@" + re.escape(self._self_username) + r"(?![@\w])"
|
||||||
self._BOT_MENTION_RE = re.compile(pat)
|
self._bot_mention_re = re.compile(pat)
|
||||||
return bool(self._BOT_MENTION_RE.search(text))
|
return bool(self._bot_mention_re.search(text))
|
||||||
|
|
||||||
def _strip_bot_mention(self, text: str) -> str:
|
def _strip_bot_mention(self, text: str) -> str:
|
||||||
if not text or not self._self_username:
|
if not text or not self._self_username:
|
||||||
@ -432,8 +438,8 @@ class MattermostChannel(BaseChannel):
|
|||||||
self.logger.warning("thread context unavailable for {}: {}", key, e)
|
self.logger.warning("thread context unavailable for {}: {}", key, e)
|
||||||
return text
|
return text
|
||||||
|
|
||||||
posts = data.get("posts", {})
|
posts = cast(dict[str, dict[str, Any]], data.get("posts", {}))
|
||||||
order = data.get("order", [])
|
order = cast(list[str], data.get("order", []))
|
||||||
if not order:
|
if not order:
|
||||||
return text
|
return text
|
||||||
|
|
||||||
@ -467,8 +473,11 @@ class MattermostChannel(BaseChannel):
|
|||||||
try:
|
try:
|
||||||
chat_id = msg.chat_id
|
chat_id = msg.chat_id
|
||||||
meta = msg.metadata or {}
|
meta = msg.metadata or {}
|
||||||
mm_meta = meta.get("mattermost", {}) or {}
|
mm_meta = cast(dict[str, Any], meta.get("mattermost", {}) or {})
|
||||||
root_id = mm_meta.get("root_id") or mm_meta.get("thread_ts") or meta.get("root_id")
|
root_id = cast(
|
||||||
|
str | None,
|
||||||
|
mm_meta.get("root_id") or mm_meta.get("thread_ts") or meta.get("root_id"),
|
||||||
|
)
|
||||||
|
|
||||||
file_ids: list[str] = []
|
file_ids: list[str] = []
|
||||||
for media_path in msg.media or []:
|
for media_path in msg.media or []:
|
||||||
@ -521,7 +530,7 @@ class MattermostChannel(BaseChannel):
|
|||||||
return
|
return
|
||||||
|
|
||||||
meta = metadata or {}
|
meta = metadata or {}
|
||||||
stream_id = stream_id or meta.get("_stream_id") or chat_id
|
stream_id = cast(str, stream_id or meta.get("_stream_id") or chat_id)
|
||||||
stream_end = stream_end or bool(meta.get("_stream_end"))
|
stream_end = stream_end or bool(meta.get("_stream_end"))
|
||||||
resuming = resuming or bool(meta.get("_resuming"))
|
resuming = resuming or bool(meta.get("_resuming"))
|
||||||
|
|
||||||
@ -541,13 +550,17 @@ class MattermostChannel(BaseChannel):
|
|||||||
return
|
return
|
||||||
|
|
||||||
if final and not meta.get("_progress"):
|
if final and not meta.get("_progress"):
|
||||||
mm_meta = (meta.get("mattermost", {}) or {}) if isinstance(meta.get("mattermost"), dict) else {}
|
mm_meta = (
|
||||||
root_id = (
|
cast(dict[str, Any], meta.get("mattermost", {}) or {})
|
||||||
|
if isinstance(meta.get("mattermost"), dict)
|
||||||
|
else {}
|
||||||
|
)
|
||||||
|
root_id = cast(str | None, (
|
||||||
mm_meta.get("root_id")
|
mm_meta.get("root_id")
|
||||||
or mm_meta.get("thread_ts")
|
or mm_meta.get("thread_ts")
|
||||||
or meta.get("root_id")
|
or meta.get("root_id")
|
||||||
or self._stream_root_ids.get(stream_id)
|
or self._stream_root_ids.get(stream_id)
|
||||||
)
|
))
|
||||||
chunks = split_message(final, MATTERMOST_MAX_MESSAGE_LEN)
|
chunks = split_message(final, MATTERMOST_MAX_MESSAGE_LEN)
|
||||||
first_post_id: str | None = None
|
first_post_id: str | None = None
|
||||||
try:
|
try:
|
||||||
@ -579,8 +592,15 @@ class MattermostChannel(BaseChannel):
|
|||||||
if not delta.strip():
|
if not delta.strip():
|
||||||
return
|
return
|
||||||
|
|
||||||
mm_meta = (meta.get("mattermost", {}) or {}) if isinstance(meta.get("mattermost"), dict) else {}
|
mm_meta = (
|
||||||
root_id = mm_meta.get("root_id") or mm_meta.get("thread_ts") or meta.get("root_id")
|
cast(dict[str, Any], meta.get("mattermost", {}) or {})
|
||||||
|
if isinstance(meta.get("mattermost"), dict)
|
||||||
|
else {}
|
||||||
|
)
|
||||||
|
root_id = cast(
|
||||||
|
str | None,
|
||||||
|
mm_meta.get("root_id") or mm_meta.get("thread_ts") or meta.get("root_id"),
|
||||||
|
)
|
||||||
if root_id:
|
if root_id:
|
||||||
self._stream_root_ids[stream_id] = root_id
|
self._stream_root_ids[stream_id] = root_id
|
||||||
committed = self._stream_committed.get(stream_id, "")
|
committed = self._stream_committed.get(stream_id, "")
|
||||||
@ -598,20 +618,25 @@ class MattermostChannel(BaseChannel):
|
|||||||
|
|
||||||
# API helpers ---------------------------------------------------------------
|
# API helpers ---------------------------------------------------------------
|
||||||
|
|
||||||
|
def _require_http_client(self) -> httpx.AsyncClient:
|
||||||
|
if self._http_client is None:
|
||||||
|
raise RuntimeError("Mattermost client is not started")
|
||||||
|
return self._http_client
|
||||||
|
|
||||||
async def _api_get(self, path: str) -> dict[str, Any]:
|
async def _api_get(self, path: str) -> dict[str, Any]:
|
||||||
resp = await self._http_client.get(path)
|
resp = await self._require_http_client().get(path)
|
||||||
resp.raise_for_status()
|
resp.raise_for_status()
|
||||||
return resp.json()
|
return cast(dict[str, Any], resp.json())
|
||||||
|
|
||||||
async def _api_post(self, path: str, json_data: dict[str, Any]) -> dict[str, Any]:
|
async def _api_post(self, path: str, json_data: dict[str, Any]) -> dict[str, Any]:
|
||||||
resp = await self._http_client.post(path, json=json_data)
|
resp = await self._require_http_client().post(path, json=json_data)
|
||||||
resp.raise_for_status()
|
resp.raise_for_status()
|
||||||
return resp.json()
|
return cast(dict[str, Any], resp.json())
|
||||||
|
|
||||||
async def _api_put(self, path: str, json_data: dict[str, Any]) -> dict[str, Any]:
|
async def _api_put(self, path: str, json_data: dict[str, Any]) -> dict[str, Any]:
|
||||||
resp = await self._http_client.put(path, json=json_data)
|
resp = await self._require_http_client().put(path, json=json_data)
|
||||||
resp.raise_for_status()
|
resp.raise_for_status()
|
||||||
return resp.json()
|
return cast(dict[str, Any], resp.json())
|
||||||
|
|
||||||
async def _create_post(
|
async def _create_post(
|
||||||
self,
|
self,
|
||||||
@ -642,14 +667,14 @@ class MattermostChannel(BaseChannel):
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
files = {"files": (path.name, path.read_bytes())}
|
files = {"files": (path.name, path.read_bytes())}
|
||||||
resp = await self._http_client.post(
|
resp = await self._require_http_client().post(
|
||||||
"/api/v4/files",
|
"/api/v4/files",
|
||||||
data={"channel_id": channel_id},
|
data={"channel_id": channel_id},
|
||||||
files=files,
|
files=files,
|
||||||
)
|
)
|
||||||
resp.raise_for_status()
|
resp.raise_for_status()
|
||||||
data = resp.json()
|
data = cast(dict[str, Any], resp.json())
|
||||||
infos = data.get("file_infos", [])
|
infos = cast(list[dict[str, Any]], data.get("file_infos", []))
|
||||||
if infos:
|
if infos:
|
||||||
return infos[0].get("id")
|
return infos[0].get("id")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@ -658,14 +683,15 @@ class MattermostChannel(BaseChannel):
|
|||||||
|
|
||||||
async def _download_file(self, file_id: str) -> str | None:
|
async def _download_file(self, file_id: str) -> str | None:
|
||||||
try:
|
try:
|
||||||
info_resp = await self._http_client.get(f"/api/v4/files/{file_id}/info")
|
client = self._require_http_client()
|
||||||
|
info_resp = await client.get(f"/api/v4/files/{file_id}/info")
|
||||||
info_resp.raise_for_status()
|
info_resp.raise_for_status()
|
||||||
info = info_resp.json()
|
info = cast(dict[str, Any], info_resp.json())
|
||||||
name = Path(info.get("name", file_id)).name
|
name = Path(info.get("name", file_id)).name
|
||||||
out = Path(get_media_dir("mattermost")) / safe_filename(f"{file_id}_{name}")
|
out = Path(get_media_dir("mattermost")) / safe_filename(f"{file_id}_{name}")
|
||||||
out.parent.mkdir(parents=True, exist_ok=True)
|
out.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
dl = await self._http_client.get(f"/api/v4/files/{file_id}")
|
dl = await client.get(f"/api/v4/files/{file_id}")
|
||||||
dl.raise_for_status()
|
dl.raise_for_status()
|
||||||
out.write_bytes(dl.content)
|
out.write_bytes(dl.content)
|
||||||
return str(out)
|
return str(out)
|
||||||
@ -685,7 +711,7 @@ class MattermostChannel(BaseChannel):
|
|||||||
async def _remove_reaction(self, post_id: str, emoji: str) -> None:
|
async def _remove_reaction(self, post_id: str, emoji: str) -> None:
|
||||||
if not self._self_id or not emoji:
|
if not self._self_id or not emoji:
|
||||||
return
|
return
|
||||||
resp = await self._http_client.delete(
|
resp = await self._require_http_client().delete(
|
||||||
f"/api/v4/users/{self._self_id}/posts/{post_id}/reactions/{emoji}",
|
f"/api/v4/users/{self._self_id}/posts/{post_id}/reactions/{emoji}",
|
||||||
)
|
)
|
||||||
if resp.status_code >= 400:
|
if resp.status_code >= 400:
|
||||||
|
|||||||
@ -1,3 +1,4 @@
|
|||||||
|
# pyright: reportConstantRedefinition=false, reportMissingTypeStubs=false
|
||||||
"""Mochat channel implementation using Socket.IO with HTTP polling fallback."""
|
"""Mochat channel implementation using Socket.IO with HTTP polling fallback."""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
@ -5,10 +6,11 @@ from __future__ import annotations
|
|||||||
import asyncio
|
import asyncio
|
||||||
import json
|
import json
|
||||||
from collections import deque
|
from collections import deque
|
||||||
|
from collections.abc import Awaitable, Callable
|
||||||
from contextlib import suppress
|
from contextlib import suppress
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from typing import Any
|
from typing import Any, cast
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
from pydantic import Field
|
from pydantic import Field
|
||||||
@ -27,7 +29,7 @@ except ImportError:
|
|||||||
SOCKETIO_AVAILABLE = False
|
SOCKETIO_AVAILABLE = False
|
||||||
|
|
||||||
try:
|
try:
|
||||||
import msgpack # noqa: F401
|
import msgpack # noqa: F401 # pyright: ignore[reportUnusedImport]
|
||||||
MSGPACK_AVAILABLE = True
|
MSGPACK_AVAILABLE = True
|
||||||
except ImportError:
|
except ImportError:
|
||||||
MSGPACK_AVAILABLE = False
|
MSGPACK_AVAILABLE = False
|
||||||
@ -57,7 +59,7 @@ class DelayState:
|
|||||||
"""Per-target delayed message state."""
|
"""Per-target delayed message state."""
|
||||||
entries: list[MochatBufferedEntry] = field(default_factory=list)
|
entries: list[MochatBufferedEntry] = field(default_factory=list)
|
||||||
lock: asyncio.Lock = field(default_factory=asyncio.Lock)
|
lock: asyncio.Lock = field(default_factory=asyncio.Lock)
|
||||||
timer: asyncio.Task | None = None
|
timer: asyncio.Task[None] | None = None
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
@ -71,12 +73,12 @@ class MochatTarget:
|
|||||||
# Pure helpers
|
# Pure helpers
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
def _safe_dict(value: Any) -> dict:
|
def _safe_dict(value: Any) -> dict[str, Any]:
|
||||||
"""Return *value* if it's a dict, else empty dict."""
|
"""Return *value* if it's a dict, else empty dict."""
|
||||||
return value if isinstance(value, dict) else {}
|
return cast(dict[str, Any], value) if isinstance(value, dict) else {}
|
||||||
|
|
||||||
|
|
||||||
def _str_field(src: dict, *keys: str) -> str:
|
def _str_field(src: dict[str, Any], *keys: str) -> str:
|
||||||
"""Return the first non-empty str value found for *keys*, stripped."""
|
"""Return the first non-empty str value found for *keys*, stripped."""
|
||||||
for k in keys:
|
for k in keys:
|
||||||
v = src.get(k)
|
v = src.get(k)
|
||||||
@ -100,7 +102,7 @@ def _make_synthetic_event(
|
|||||||
payload["authorInfo"] = _safe_dict(author_info)
|
payload["authorInfo"] = _safe_dict(author_info)
|
||||||
return {
|
return {
|
||||||
"type": "message.add",
|
"type": "message.add",
|
||||||
"timestamp": timestamp or datetime.utcnow().isoformat(),
|
"timestamp": timestamp or datetime.utcnow().isoformat(), # pyright: ignore[reportDeprecated]
|
||||||
"payload": payload,
|
"payload": payload,
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -141,11 +143,12 @@ def extract_mention_ids(value: Any) -> list[str]:
|
|||||||
if not isinstance(value, list):
|
if not isinstance(value, list):
|
||||||
return []
|
return []
|
||||||
ids: list[str] = []
|
ids: list[str] = []
|
||||||
for item in value:
|
for item in cast(list[object], value):
|
||||||
if isinstance(item, str):
|
if isinstance(item, str):
|
||||||
if item.strip():
|
if item.strip():
|
||||||
ids.append(item.strip())
|
ids.append(item.strip())
|
||||||
elif isinstance(item, dict):
|
elif isinstance(item, dict):
|
||||||
|
item = cast(dict[str, Any], item)
|
||||||
for key in ("id", "userId", "_id"):
|
for key in ("id", "userId", "_id"):
|
||||||
candidate = item.get(key)
|
candidate = item.get(key)
|
||||||
if isinstance(candidate, str) and candidate.strip():
|
if isinstance(candidate, str) and candidate.strip():
|
||||||
@ -158,6 +161,7 @@ def resolve_was_mentioned(payload: dict[str, Any], agent_user_id: str) -> bool:
|
|||||||
"""Resolve mention state from payload metadata and text fallback."""
|
"""Resolve mention state from payload metadata and text fallback."""
|
||||||
meta = payload.get("meta")
|
meta = payload.get("meta")
|
||||||
if isinstance(meta, dict):
|
if isinstance(meta, dict):
|
||||||
|
meta = cast(dict[str, Any], meta)
|
||||||
if meta.get("mentioned") is True or meta.get("wasMentioned") is True:
|
if meta.get("mentioned") is True or meta.get("wasMentioned") is True:
|
||||||
return True
|
return True
|
||||||
for f in ("mentions", "mentionIds", "mentionedUserIds", "mentionedUsers"):
|
for f in ("mentions", "mentionIds", "mentionedUserIds", "mentionedUsers"):
|
||||||
@ -278,7 +282,7 @@ class MochatChannel(BaseChannel):
|
|||||||
self._state_dir = get_runtime_subdir("mochat")
|
self._state_dir = get_runtime_subdir("mochat")
|
||||||
self._cursor_path = self._state_dir / "session_cursors.json"
|
self._cursor_path = self._state_dir / "session_cursors.json"
|
||||||
self._session_cursor: dict[str, int] = {}
|
self._session_cursor: dict[str, int] = {}
|
||||||
self._cursor_save_task: asyncio.Task | None = None
|
self._cursor_save_task: asyncio.Task[None] | None = None
|
||||||
|
|
||||||
self._session_set: set[str] = set()
|
self._session_set: set[str] = set()
|
||||||
self._panel_set: set[str] = set()
|
self._panel_set: set[str] = set()
|
||||||
@ -292,9 +296,9 @@ class MochatChannel(BaseChannel):
|
|||||||
self._delay_states: dict[str, DelayState] = {}
|
self._delay_states: dict[str, DelayState] = {}
|
||||||
|
|
||||||
self._fallback_mode = False
|
self._fallback_mode = False
|
||||||
self._session_fallback_tasks: dict[str, asyncio.Task] = {}
|
self._session_fallback_tasks: dict[str, asyncio.Task[None]] = {}
|
||||||
self._panel_fallback_tasks: dict[str, asyncio.Task] = {}
|
self._panel_fallback_tasks: dict[str, asyncio.Task[None]] = {}
|
||||||
self._refresh_task: asyncio.Task | None = None
|
self._refresh_task: asyncio.Task[None] | None = None
|
||||||
self._target_locks: dict[str, asyncio.Lock] = {}
|
self._target_locks: dict[str, asyncio.Lock] = {}
|
||||||
|
|
||||||
# ---- lifecycle ---------------------------------------------------------
|
# ---- lifecycle ---------------------------------------------------------
|
||||||
@ -352,7 +356,11 @@ class MochatChannel(BaseChannel):
|
|||||||
|
|
||||||
parts = ([msg.content.strip()] if msg.content and msg.content.strip() else [])
|
parts = ([msg.content.strip()] if msg.content and msg.content.strip() else [])
|
||||||
if msg.media:
|
if msg.media:
|
||||||
parts.extend(m for m in msg.media if isinstance(m, str) and m.strip())
|
parts.extend(
|
||||||
|
m
|
||||||
|
for m in msg.media
|
||||||
|
if isinstance(cast(object, m), str) and m.strip()
|
||||||
|
)
|
||||||
content = "\n".join(parts).strip()
|
content = "\n".join(parts).strip()
|
||||||
if not content:
|
if not content:
|
||||||
return
|
return
|
||||||
@ -404,7 +412,8 @@ class MochatChannel(BaseChannel):
|
|||||||
else:
|
else:
|
||||||
self.logger.warning("msgpack not installed but socket_disable_msgpack=false; using JSON")
|
self.logger.warning("msgpack not installed but socket_disable_msgpack=false; using JSON")
|
||||||
|
|
||||||
client = socketio.AsyncClient(
|
socketio_module = cast(Any, socketio)
|
||||||
|
client: Any = socketio_module.AsyncClient(
|
||||||
reconnection=True,
|
reconnection=True,
|
||||||
reconnection_attempts=self.config.max_retry_attempts or None,
|
reconnection_attempts=self.config.max_retry_attempts or None,
|
||||||
reconnection_delay=max(0.1, self.config.socket_reconnect_delay_ms / 1000.0),
|
reconnection_delay=max(0.1, self.config.socket_reconnect_delay_ms / 1000.0),
|
||||||
@ -412,7 +421,6 @@ class MochatChannel(BaseChannel):
|
|||||||
logger=False, engineio_logger=False, serializer=serializer,
|
logger=False, engineio_logger=False, serializer=serializer,
|
||||||
)
|
)
|
||||||
|
|
||||||
@client.event
|
|
||||||
async def connect() -> None:
|
async def connect() -> None:
|
||||||
self._ws_connected, self._ws_ready = True, False
|
self._ws_connected, self._ws_ready = True, False
|
||||||
self.logger.info("websocket connected")
|
self.logger.info("websocket connected")
|
||||||
@ -420,7 +428,6 @@ class MochatChannel(BaseChannel):
|
|||||||
self._ws_ready = subscribed
|
self._ws_ready = subscribed
|
||||||
await (self._stop_fallback_workers() if subscribed else self._ensure_fallback_workers())
|
await (self._stop_fallback_workers() if subscribed else self._ensure_fallback_workers())
|
||||||
|
|
||||||
@client.event
|
|
||||||
async def disconnect() -> None:
|
async def disconnect() -> None:
|
||||||
if not self._running:
|
if not self._running:
|
||||||
return
|
return
|
||||||
@ -428,18 +435,21 @@ class MochatChannel(BaseChannel):
|
|||||||
self.logger.warning("websocket disconnected")
|
self.logger.warning("websocket disconnected")
|
||||||
await self._ensure_fallback_workers()
|
await self._ensure_fallback_workers()
|
||||||
|
|
||||||
@client.event
|
|
||||||
async def connect_error(data: Any) -> None:
|
async def connect_error(data: Any) -> None:
|
||||||
self.logger.error("websocket connect error: {}", data)
|
self.logger.error("websocket connect error: {}", data)
|
||||||
|
|
||||||
@client.on("claw.session.events")
|
|
||||||
async def on_session_events(payload: dict[str, Any]) -> None:
|
async def on_session_events(payload: dict[str, Any]) -> None:
|
||||||
await self._handle_watch_payload(payload, "session")
|
await self._handle_watch_payload(payload, "session")
|
||||||
|
|
||||||
@client.on("claw.panel.events")
|
|
||||||
async def on_panel_events(payload: dict[str, Any]) -> None:
|
async def on_panel_events(payload: dict[str, Any]) -> None:
|
||||||
await self._handle_watch_payload(payload, "panel")
|
await self._handle_watch_payload(payload, "panel")
|
||||||
|
|
||||||
|
client.event(connect)
|
||||||
|
client.event(disconnect)
|
||||||
|
client.event(connect_error)
|
||||||
|
client.on("claw.session.events", on_session_events)
|
||||||
|
client.on("claw.panel.events", on_panel_events)
|
||||||
|
|
||||||
for ev in ("notify:chat.inbox.append", "notify:chat.message.add",
|
for ev in ("notify:chat.inbox.append", "notify:chat.message.add",
|
||||||
"notify:chat.message.update", "notify:chat.message.recall",
|
"notify:chat.message.update", "notify:chat.message.recall",
|
||||||
"notify:chat.message.delete"):
|
"notify:chat.message.delete"):
|
||||||
@ -463,7 +473,10 @@ class MochatChannel(BaseChannel):
|
|||||||
self._socket = None
|
self._socket = None
|
||||||
return False
|
return False
|
||||||
|
|
||||||
def _build_notify_handler(self, event_name: str):
|
def _build_notify_handler(
|
||||||
|
self,
|
||||||
|
event_name: str,
|
||||||
|
) -> Callable[[Any], Awaitable[None]]:
|
||||||
async def handler(payload: Any) -> None:
|
async def handler(payload: Any) -> None:
|
||||||
if event_name == "notify:chat.inbox.append":
|
if event_name == "notify:chat.inbox.append":
|
||||||
await self._handle_notify_inbox_append(payload)
|
await self._handle_notify_inbox_append(payload)
|
||||||
@ -498,11 +511,20 @@ class MochatChannel(BaseChannel):
|
|||||||
data = ack.get("data")
|
data = ack.get("data")
|
||||||
items: list[dict[str, Any]] = []
|
items: list[dict[str, Any]] = []
|
||||||
if isinstance(data, list):
|
if isinstance(data, list):
|
||||||
items = [i for i in data if isinstance(i, dict)]
|
items = [
|
||||||
|
cast(dict[str, Any], item)
|
||||||
|
for item in cast(list[object], data)
|
||||||
|
if isinstance(item, dict)
|
||||||
|
]
|
||||||
elif isinstance(data, dict):
|
elif isinstance(data, dict):
|
||||||
|
data = cast(dict[str, Any], data)
|
||||||
sessions = data.get("sessions")
|
sessions = data.get("sessions")
|
||||||
if isinstance(sessions, list):
|
if isinstance(sessions, list):
|
||||||
items = [i for i in sessions if isinstance(i, dict)]
|
items = [
|
||||||
|
cast(dict[str, Any], item)
|
||||||
|
for item in cast(list[object], sessions)
|
||||||
|
if isinstance(item, dict)
|
||||||
|
]
|
||||||
elif "sessionId" in data:
|
elif "sessionId" in data:
|
||||||
items = [data]
|
items = [data]
|
||||||
for p in items:
|
for p in items:
|
||||||
@ -525,7 +547,11 @@ class MochatChannel(BaseChannel):
|
|||||||
raw = await self._socket.call(event_name, payload, timeout=10)
|
raw = await self._socket.call(event_name, payload, timeout=10)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return {"result": False, "message": str(e)}
|
return {"result": False, "message": str(e)}
|
||||||
return raw if isinstance(raw, dict) else {"result": True, "data": raw}
|
return (
|
||||||
|
cast(dict[str, Any], raw)
|
||||||
|
if isinstance(raw, dict)
|
||||||
|
else {"result": True, "data": raw}
|
||||||
|
)
|
||||||
|
|
||||||
# ---- refresh / discovery -----------------------------------------------
|
# ---- refresh / discovery -----------------------------------------------
|
||||||
|
|
||||||
@ -558,10 +584,11 @@ class MochatChannel(BaseChannel):
|
|||||||
return
|
return
|
||||||
|
|
||||||
new_ids: list[str] = []
|
new_ids: list[str] = []
|
||||||
for s in sessions:
|
for session_value in cast(list[object], sessions):
|
||||||
if not isinstance(s, dict):
|
if not isinstance(session_value, dict):
|
||||||
continue
|
continue
|
||||||
sid = _str_field(s, "sessionId")
|
session = cast(dict[str, Any], session_value)
|
||||||
|
sid = _str_field(session, "sessionId")
|
||||||
if not sid:
|
if not sid:
|
||||||
continue
|
continue
|
||||||
if sid not in self._session_set:
|
if sid not in self._session_set:
|
||||||
@ -569,7 +596,7 @@ class MochatChannel(BaseChannel):
|
|||||||
new_ids.append(sid)
|
new_ids.append(sid)
|
||||||
if sid not in self._session_cursor:
|
if sid not in self._session_cursor:
|
||||||
self._cold_sessions.add(sid)
|
self._cold_sessions.add(sid)
|
||||||
cid = _str_field(s, "converseId")
|
cid = _str_field(session, "converseId")
|
||||||
if cid:
|
if cid:
|
||||||
self._session_by_converse[cid] = sid
|
self._session_by_converse[cid] = sid
|
||||||
|
|
||||||
@ -592,13 +619,14 @@ class MochatChannel(BaseChannel):
|
|||||||
return
|
return
|
||||||
|
|
||||||
new_ids: list[str] = []
|
new_ids: list[str] = []
|
||||||
for p in raw_panels:
|
for panel_value in cast(list[object], raw_panels):
|
||||||
if not isinstance(p, dict):
|
if not isinstance(panel_value, dict):
|
||||||
continue
|
continue
|
||||||
pt = p.get("type")
|
panel = cast(dict[str, Any], panel_value)
|
||||||
|
pt = panel.get("type")
|
||||||
if isinstance(pt, int) and pt != 0:
|
if isinstance(pt, int) and pt != 0:
|
||||||
continue
|
continue
|
||||||
pid = _str_field(p, "id", "_id")
|
pid = _str_field(panel, "id", "_id")
|
||||||
if pid and pid not in self._panel_set:
|
if pid and pid not in self._panel_set:
|
||||||
self._panel_set.add(pid)
|
self._panel_set.add(pid)
|
||||||
new_ids.append(pid)
|
new_ids.append(pid)
|
||||||
@ -658,16 +686,19 @@ class MochatChannel(BaseChannel):
|
|||||||
})
|
})
|
||||||
msgs = resp.get("messages")
|
msgs = resp.get("messages")
|
||||||
if isinstance(msgs, list):
|
if isinstance(msgs, list):
|
||||||
for m in reversed(msgs):
|
for message_value in reversed(cast(list[object], msgs)):
|
||||||
if not isinstance(m, dict):
|
if not isinstance(message_value, dict):
|
||||||
continue
|
continue
|
||||||
|
message = cast(dict[str, Any], message_value)
|
||||||
evt = _make_synthetic_event(
|
evt = _make_synthetic_event(
|
||||||
message_id=str(m.get("messageId") or ""),
|
message_id=str(message.get("messageId") or ""),
|
||||||
author=str(m.get("author") or ""),
|
author=str(message.get("author") or ""),
|
||||||
content=m.get("content"),
|
content=message.get("content"),
|
||||||
meta=m.get("meta"), group_id=str(resp.get("groupId") or ""),
|
meta=message.get("meta"),
|
||||||
converse_id=panel_id, timestamp=m.get("createdAt"),
|
group_id=str(resp.get("groupId") or ""),
|
||||||
author_info=m.get("authorInfo"),
|
converse_id=panel_id,
|
||||||
|
timestamp=message.get("createdAt"),
|
||||||
|
author_info=message.get("authorInfo"),
|
||||||
)
|
)
|
||||||
await self._process_inbound_event(panel_id, evt, "panel")
|
await self._process_inbound_event(panel_id, evt, "panel")
|
||||||
except asyncio.CancelledError:
|
except asyncio.CancelledError:
|
||||||
@ -679,7 +710,7 @@ class MochatChannel(BaseChannel):
|
|||||||
# ---- inbound event processing ------------------------------------------
|
# ---- inbound event processing ------------------------------------------
|
||||||
|
|
||||||
async def _handle_watch_payload(self, payload: dict[str, Any], target_kind: str) -> None:
|
async def _handle_watch_payload(self, payload: dict[str, Any], target_kind: str) -> None:
|
||||||
if not isinstance(payload, dict):
|
if not isinstance(cast(object, payload), dict):
|
||||||
return
|
return
|
||||||
target_id = _str_field(payload, "sessionId")
|
target_id = _str_field(payload, "sessionId")
|
||||||
if not target_id:
|
if not target_id:
|
||||||
@ -699,9 +730,10 @@ class MochatChannel(BaseChannel):
|
|||||||
self._cold_sessions.discard(target_id)
|
self._cold_sessions.discard(target_id)
|
||||||
return
|
return
|
||||||
|
|
||||||
for event in raw_events:
|
for event_value in cast(list[object], raw_events):
|
||||||
if not isinstance(event, dict):
|
if not isinstance(event_value, dict):
|
||||||
continue
|
continue
|
||||||
|
event = cast(dict[str, Any], event_value)
|
||||||
seq = event.get("seq")
|
seq = event.get("seq")
|
||||||
if target_kind == "session" and isinstance(seq, int) and seq > self._session_cursor.get(target_id, prev):
|
if target_kind == "session" and isinstance(seq, int) and seq > self._session_cursor.get(target_id, prev):
|
||||||
self._mark_session_cursor(target_id, seq)
|
self._mark_session_cursor(target_id, seq)
|
||||||
@ -712,6 +744,7 @@ class MochatChannel(BaseChannel):
|
|||||||
payload = event.get("payload")
|
payload = event.get("payload")
|
||||||
if not isinstance(payload, dict):
|
if not isinstance(payload, dict):
|
||||||
return
|
return
|
||||||
|
payload = cast(dict[str, Any], payload)
|
||||||
|
|
||||||
author = _str_field(payload, "author")
|
author = _str_field(payload, "author")
|
||||||
if not author or (self.config.agent_user_id and author == self.config.agent_user_id):
|
if not author or (self.config.agent_user_id and author == self.config.agent_user_id):
|
||||||
@ -821,6 +854,7 @@ class MochatChannel(BaseChannel):
|
|||||||
async def _handle_notify_chat_message(self, payload: Any) -> None:
|
async def _handle_notify_chat_message(self, payload: Any) -> None:
|
||||||
if not isinstance(payload, dict):
|
if not isinstance(payload, dict):
|
||||||
return
|
return
|
||||||
|
payload = cast(dict[str, Any], payload)
|
||||||
group_id = _str_field(payload, "groupId")
|
group_id = _str_field(payload, "groupId")
|
||||||
panel_id = _str_field(payload, "converseId", "panelId")
|
panel_id = _str_field(payload, "converseId", "panelId")
|
||||||
if not group_id or not panel_id:
|
if not group_id or not panel_id:
|
||||||
@ -838,11 +872,15 @@ class MochatChannel(BaseChannel):
|
|||||||
await self._process_inbound_event(panel_id, evt, "panel")
|
await self._process_inbound_event(panel_id, evt, "panel")
|
||||||
|
|
||||||
async def _handle_notify_inbox_append(self, payload: Any) -> None:
|
async def _handle_notify_inbox_append(self, payload: Any) -> None:
|
||||||
if not isinstance(payload, dict) or payload.get("type") != "message":
|
if not isinstance(payload, dict):
|
||||||
|
return
|
||||||
|
payload = cast(dict[str, Any], payload)
|
||||||
|
if payload.get("type") != "message":
|
||||||
return
|
return
|
||||||
detail = payload.get("payload")
|
detail = payload.get("payload")
|
||||||
if not isinstance(detail, dict):
|
if not isinstance(detail, dict):
|
||||||
return
|
return
|
||||||
|
detail = cast(dict[str, Any], detail)
|
||||||
if _str_field(detail, "groupId"):
|
if _str_field(detail, "groupId"):
|
||||||
return
|
return
|
||||||
converse_id = _str_field(detail, "converseId")
|
converse_id = _str_field(detail, "converseId")
|
||||||
@ -886,9 +924,14 @@ class MochatChannel(BaseChannel):
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
self.logger.warning("Failed to read cursor file: {}", e)
|
self.logger.warning("Failed to read cursor file: {}", e)
|
||||||
return
|
return
|
||||||
cursors = data.get("cursors") if isinstance(data, dict) else None
|
data_object = cast(object, data)
|
||||||
|
cursors = (
|
||||||
|
cast(dict[str, Any], data_object).get("cursors")
|
||||||
|
if isinstance(data_object, dict)
|
||||||
|
else None
|
||||||
|
)
|
||||||
if isinstance(cursors, dict):
|
if isinstance(cursors, dict):
|
||||||
for sid, cur in cursors.items():
|
for sid, cur in cast(dict[object, object], cursors).items():
|
||||||
if isinstance(sid, str) and isinstance(cur, int) and cur >= 0:
|
if isinstance(sid, str) and isinstance(cur, int) and cur >= 0:
|
||||||
self._session_cursor[sid] = cur
|
self._session_cursor[sid] = cur
|
||||||
|
|
||||||
@ -896,7 +939,8 @@ class MochatChannel(BaseChannel):
|
|||||||
try:
|
try:
|
||||||
self._state_dir.mkdir(parents=True, exist_ok=True)
|
self._state_dir.mkdir(parents=True, exist_ok=True)
|
||||||
self._cursor_path.write_text(json.dumps({
|
self._cursor_path.write_text(json.dumps({
|
||||||
"schemaVersion": 1, "updatedAt": datetime.utcnow().isoformat(),
|
"schemaVersion": 1,
|
||||||
|
"updatedAt": datetime.utcnow().isoformat(), # pyright: ignore[reportDeprecated]
|
||||||
"cursors": self._session_cursor,
|
"cursors": self._session_cursor,
|
||||||
}, ensure_ascii=False, indent=2) + "\n", "utf-8")
|
}, ensure_ascii=False, indent=2) + "\n", "utf-8")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@ -917,13 +961,22 @@ class MochatChannel(BaseChannel):
|
|||||||
parsed = response.json()
|
parsed = response.json()
|
||||||
except Exception:
|
except Exception:
|
||||||
parsed = response.text
|
parsed = response.text
|
||||||
if isinstance(parsed, dict) and isinstance(parsed.get("code"), int):
|
if isinstance(parsed, dict):
|
||||||
if parsed["code"] != 200:
|
parsed_dict = cast(dict[str, Any], parsed)
|
||||||
msg = str(parsed.get("message") or parsed.get("name") or "request failed")
|
if isinstance(parsed_dict.get("code"), int):
|
||||||
raise RuntimeError(f"Mochat API error: {msg} (code={parsed['code']})")
|
if parsed_dict["code"] != 200:
|
||||||
data = parsed.get("data")
|
msg = str(
|
||||||
return data if isinstance(data, dict) else {}
|
parsed_dict.get("message")
|
||||||
return parsed if isinstance(parsed, dict) else {}
|
or parsed_dict.get("name")
|
||||||
|
or "request failed"
|
||||||
|
)
|
||||||
|
raise RuntimeError(
|
||||||
|
f"Mochat API error: {msg} (code={parsed_dict['code']})"
|
||||||
|
)
|
||||||
|
data = parsed_dict.get("data")
|
||||||
|
return cast(dict[str, Any], data) if isinstance(data, dict) else {}
|
||||||
|
return parsed_dict
|
||||||
|
return {}
|
||||||
|
|
||||||
async def _api_send(self, path: str, id_key: str, id_val: str,
|
async def _api_send(self, path: str, id_key: str, id_val: str,
|
||||||
content: str, reply_to: str | None, group_id: str | None = None) -> dict[str, Any]:
|
content: str, reply_to: str | None, group_id: str | None = None) -> dict[str, Any]:
|
||||||
@ -937,7 +990,7 @@ class MochatChannel(BaseChannel):
|
|||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _read_group_id(metadata: dict[str, Any]) -> str | None:
|
def _read_group_id(metadata: dict[str, Any]) -> str | None:
|
||||||
if not isinstance(metadata, dict):
|
if not isinstance(cast(object, metadata), dict):
|
||||||
return None
|
return None
|
||||||
value = metadata.get("group_id") or metadata.get("groupId")
|
value = metadata.get("group_id") or metadata.get("groupId")
|
||||||
return value.strip() if isinstance(value, str) and value.strip() else None
|
return value.strip() if isinstance(value, str) and value.strip() else None
|
||||||
|
|||||||
@ -23,7 +23,8 @@ import time
|
|||||||
from contextlib import contextmanager, suppress
|
from contextlib import contextmanager, suppress
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||||
from typing import TYPE_CHECKING, Any
|
from pathlib import Path
|
||||||
|
from typing import TYPE_CHECKING, Any, Generator, cast
|
||||||
from urllib.parse import urlparse
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
try: # pragma: no cover - Windows fallback path
|
try: # pragma: no cover - Windows fallback path
|
||||||
@ -47,9 +48,11 @@ MSTEAMS_AVAILABLE = (
|
|||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
import jwt
|
import jwt
|
||||||
|
from jwt.algorithms import RSAAlgorithm
|
||||||
|
|
||||||
if MSTEAMS_AVAILABLE:
|
if MSTEAMS_AVAILABLE:
|
||||||
import jwt
|
import jwt
|
||||||
|
from jwt.algorithms import RSAAlgorithm
|
||||||
|
|
||||||
MSTEAMS_REF_TTL_DAYS = 30
|
MSTEAMS_REF_TTL_DAYS = 30
|
||||||
MSTEAMS_WEBCHAT_HOST = "webchat.botframework.com"
|
MSTEAMS_WEBCHAT_HOST = "webchat.botframework.com"
|
||||||
@ -182,9 +185,10 @@ class MSTeamsChannel(BaseChannel):
|
|||||||
auth_header = self.headers.get("Authorization", "")
|
auth_header = self.headers.get("Authorization", "")
|
||||||
if channel.config.validate_inbound_auth:
|
if channel.config.validate_inbound_auth:
|
||||||
try:
|
try:
|
||||||
|
loop = cast(asyncio.AbstractEventLoop, channel._loop)
|
||||||
fut = asyncio.run_coroutine_threadsafe(
|
fut = asyncio.run_coroutine_threadsafe(
|
||||||
channel._validate_inbound_auth(auth_header, payload),
|
channel._validate_inbound_auth(auth_header, payload),
|
||||||
channel._loop,
|
loop,
|
||||||
)
|
)
|
||||||
fut.result(timeout=15)
|
fut.result(timeout=15)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@ -195,9 +199,10 @@ class MSTeamsChannel(BaseChannel):
|
|||||||
self.wfile.write(b'{"error":"unauthorized"}')
|
self.wfile.write(b'{"error":"unauthorized"}')
|
||||||
return
|
return
|
||||||
try:
|
try:
|
||||||
|
loop = cast(asyncio.AbstractEventLoop, channel._loop)
|
||||||
fut = asyncio.run_coroutine_threadsafe(
|
fut = asyncio.run_coroutine_threadsafe(
|
||||||
channel._handle_activity(payload),
|
channel._handle_activity(payload),
|
||||||
channel._loop,
|
loop,
|
||||||
)
|
)
|
||||||
fut.result(timeout=15)
|
fut.result(timeout=15)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@ -269,7 +274,7 @@ class MSTeamsChannel(BaseChannel):
|
|||||||
"text": msg.content or " ",
|
"text": msg.content or " ",
|
||||||
}
|
}
|
||||||
if use_thread_reply:
|
if use_thread_reply:
|
||||||
payload["replyToId"] = ref.activity_id
|
payload["replyToId"] = cast(str, ref.activity_id)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
resp = await self._http.post(base_url, headers=headers, json=payload)
|
resp = await self._http.post(base_url, headers=headers, json=payload)
|
||||||
@ -285,10 +290,10 @@ class MSTeamsChannel(BaseChannel):
|
|||||||
if activity.get("type") != "message":
|
if activity.get("type") != "message":
|
||||||
return
|
return
|
||||||
|
|
||||||
conversation = activity.get("conversation") or {}
|
conversation = cast(dict[str, Any], activity.get("conversation") or {})
|
||||||
from_user = activity.get("from") or {}
|
from_user = cast(dict[str, Any], activity.get("from") or {})
|
||||||
recipient = activity.get("recipient") or {}
|
recipient = cast(dict[str, Any], activity.get("recipient") or {})
|
||||||
channel_data = activity.get("channelData") or {}
|
channel_data = cast(dict[str, Any], activity.get("channelData") or {})
|
||||||
|
|
||||||
sender_id = str(from_user.get("aadObjectId") or from_user.get("id") or "").strip()
|
sender_id = str(from_user.get("aadObjectId") or from_user.get("id") or "").strip()
|
||||||
conversation_id = str(conversation.get("id") or "").strip()
|
conversation_id = str(conversation.get("id") or "").strip()
|
||||||
@ -336,7 +341,16 @@ class MSTeamsChannel(BaseChannel):
|
|||||||
bot_id=str(recipient.get("id") or "") or None,
|
bot_id=str(recipient.get("id") or "") or None,
|
||||||
activity_id=activity_id or None,
|
activity_id=activity_id or None,
|
||||||
conversation_type=conversation_type or None,
|
conversation_type=conversation_type or None,
|
||||||
tenant_id=str((channel_data.get("tenant") or {}).get("id") or "") or None,
|
tenant_id=(
|
||||||
|
str(
|
||||||
|
cast(
|
||||||
|
dict[str, Any],
|
||||||
|
channel_data.get("tenant") or {},
|
||||||
|
).get("id")
|
||||||
|
or ""
|
||||||
|
)
|
||||||
|
or None
|
||||||
|
),
|
||||||
updated_at=time.time(),
|
updated_at=time.time(),
|
||||||
)
|
)
|
||||||
self._save_refs_locked()
|
self._save_refs_locked()
|
||||||
@ -361,7 +375,7 @@ class MSTeamsChannel(BaseChannel):
|
|||||||
text = self._strip_possible_bot_mention(text)
|
text = self._strip_possible_bot_mention(text)
|
||||||
text = self._normalize_html_whitespace(text)
|
text = self._normalize_html_whitespace(text)
|
||||||
|
|
||||||
channel_data = activity.get("channelData") or {}
|
channel_data = cast(dict[str, Any], activity.get("channelData") or {})
|
||||||
reply_to_id = str(activity.get("replyToId") or "").strip()
|
reply_to_id = str(activity.get("replyToId") or "").strip()
|
||||||
normalized_preview = html.unescape(text).replace("&rsquo", "’").strip()
|
normalized_preview = html.unescape(text).replace("&rsquo", "’").strip()
|
||||||
normalized_preview = normalized_preview.replace("\xa0", " ")
|
normalized_preview = normalized_preview.replace("\xa0", " ")
|
||||||
@ -473,15 +487,15 @@ class MSTeamsChannel(BaseChannel):
|
|||||||
raise ValueError("missing token kid")
|
raise ValueError("missing token kid")
|
||||||
|
|
||||||
jwks = await self._get_botframework_jwks()
|
jwks = await self._get_botframework_jwks()
|
||||||
keys = jwks.get("keys") or []
|
keys = cast(list[dict[str, Any]], jwks.get("keys") or [])
|
||||||
jwk = next((key for key in keys if key.get("kid") == kid), None)
|
jwk = next((key for key in keys if key.get("kid") == kid), None)
|
||||||
if not jwk:
|
if not jwk:
|
||||||
raise ValueError(f"signing key not found for kid={kid}")
|
raise ValueError(f"signing key not found for kid={kid}")
|
||||||
|
|
||||||
public_key = jwt.algorithms.RSAAlgorithm.from_jwk(json.dumps(jwk))
|
public_key = RSAAlgorithm.from_jwk(json.dumps(jwk))
|
||||||
claims = jwt.decode(
|
claims = jwt.decode(
|
||||||
token,
|
token,
|
||||||
key=public_key,
|
key=cast(Any, public_key),
|
||||||
algorithms=["RS256"],
|
algorithms=["RS256"],
|
||||||
audience=self.config.app_id,
|
audience=self.config.app_id,
|
||||||
issuer="https://api.botframework.com",
|
issuer="https://api.botframework.com",
|
||||||
@ -509,9 +523,10 @@ class MSTeamsChannel(BaseChannel):
|
|||||||
|
|
||||||
resp = await self._http.get(self._botframework_openid_config_url)
|
resp = await self._http.get(self._botframework_openid_config_url)
|
||||||
resp.raise_for_status()
|
resp.raise_for_status()
|
||||||
self._botframework_openid_config = resp.json()
|
openid_config = cast(dict[str, Any], resp.json())
|
||||||
|
self._botframework_openid_config = openid_config
|
||||||
self._botframework_openid_config_expires_at = now + 3600
|
self._botframework_openid_config_expires_at = now + 3600
|
||||||
return self._botframework_openid_config
|
return openid_config
|
||||||
|
|
||||||
async def _get_botframework_jwks(self) -> dict[str, Any]:
|
async def _get_botframework_jwks(self) -> dict[str, Any]:
|
||||||
"""Fetch and cache Bot Framework JWKS."""
|
"""Fetch and cache Bot Framework JWKS."""
|
||||||
@ -530,36 +545,38 @@ class MSTeamsChannel(BaseChannel):
|
|||||||
|
|
||||||
resp = await self._http.get(jwks_uri)
|
resp = await self._http.get(jwks_uri)
|
||||||
resp.raise_for_status()
|
resp.raise_for_status()
|
||||||
self._botframework_jwks = resp.json()
|
jwks = cast(dict[str, Any], resp.json())
|
||||||
|
self._botframework_jwks = jwks
|
||||||
self._botframework_jwks_expires_at = now + 3600
|
self._botframework_jwks_expires_at = now + 3600
|
||||||
return self._botframework_jwks
|
return jwks
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _safe_float(value: Any) -> float | None:
|
def _safe_float(value: object) -> float | None:
|
||||||
try:
|
try:
|
||||||
out = float(value)
|
out = float(cast(Any, value))
|
||||||
if out > 0:
|
if out > 0:
|
||||||
return out
|
return out
|
||||||
except (TypeError, ValueError):
|
except (TypeError, ValueError):
|
||||||
return None
|
return None
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def _normalize_ref_record(self, value: Any) -> ConversationRef | None:
|
def _normalize_ref_record(self, value: object) -> ConversationRef | None:
|
||||||
"""Normalize a stored ref record from legacy/current schema."""
|
"""Normalize a stored ref record from legacy/current schema."""
|
||||||
if not isinstance(value, dict):
|
if not isinstance(value, dict):
|
||||||
return None
|
return None
|
||||||
service_url = str(value.get("service_url") or "").strip()
|
record = cast(dict[str, Any], value)
|
||||||
conversation_id = str(value.get("conversation_id") or "").strip()
|
service_url = str(record.get("service_url") or "").strip()
|
||||||
|
conversation_id = str(record.get("conversation_id") or "").strip()
|
||||||
if not service_url or not conversation_id:
|
if not service_url or not conversation_id:
|
||||||
return None
|
return None
|
||||||
return ConversationRef(
|
return ConversationRef(
|
||||||
service_url=service_url,
|
service_url=service_url,
|
||||||
conversation_id=conversation_id,
|
conversation_id=conversation_id,
|
||||||
bot_id=str(value.get("bot_id") or "") or None,
|
bot_id=str(record.get("bot_id") or "") or None,
|
||||||
activity_id=str(value.get("activity_id") or "") or None,
|
activity_id=str(record.get("activity_id") or "") or None,
|
||||||
conversation_type=str(value.get("conversation_type") or "") or None,
|
conversation_type=str(record.get("conversation_type") or "") or None,
|
||||||
tenant_id=str(value.get("tenant_id") or "") or None,
|
tenant_id=str(record.get("tenant_id") or "") or None,
|
||||||
updated_at=self._safe_float(value.get("updated_at")),
|
updated_at=self._safe_float(cast(object, record.get("updated_at"))),
|
||||||
)
|
)
|
||||||
|
|
||||||
def _load_refs_raw(self) -> tuple[dict[str, Any], dict[str, Any], bool]:
|
def _load_refs_raw(self) -> tuple[dict[str, Any], dict[str, Any], bool]:
|
||||||
@ -570,17 +587,19 @@ class MSTeamsChannel(BaseChannel):
|
|||||||
|
|
||||||
if self._refs_path.exists():
|
if self._refs_path.exists():
|
||||||
try:
|
try:
|
||||||
loaded = json.loads(self._refs_path.read_text(encoding="utf-8"))
|
loaded: object = json.loads(self._refs_path.read_text(encoding="utf-8"))
|
||||||
if isinstance(loaded, dict):
|
if isinstance(loaded, dict):
|
||||||
main_data = loaded
|
main_data = cast(dict[str, Any], loaded)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
self.logger.warning("Failed to load conversation refs: {}", e)
|
self.logger.warning("Failed to load conversation refs: {}", e)
|
||||||
|
|
||||||
if meta_exists:
|
if meta_exists:
|
||||||
try:
|
try:
|
||||||
loaded_meta = json.loads(self._refs_meta_path.read_text(encoding="utf-8"))
|
loaded_meta: object = json.loads(
|
||||||
|
self._refs_meta_path.read_text(encoding="utf-8")
|
||||||
|
)
|
||||||
if isinstance(loaded_meta, dict):
|
if isinstance(loaded_meta, dict):
|
||||||
meta_data = loaded_meta
|
meta_data = cast(dict[str, Any], loaded_meta)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
self.logger.warning("Failed to load conversation refs metadata: {}", e)
|
self.logger.warning("Failed to load conversation refs metadata: {}", e)
|
||||||
|
|
||||||
@ -599,10 +618,11 @@ class MSTeamsChannel(BaseChannel):
|
|||||||
if not ref:
|
if not ref:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
meta_entry = meta_data.get(key) if isinstance(meta_data, dict) else None
|
meta_entry = cast(object, meta_data.get(key))
|
||||||
meta_ts = None
|
meta_ts: float | None = None
|
||||||
if isinstance(meta_entry, dict):
|
if isinstance(meta_entry, dict):
|
||||||
meta_ts = self._safe_float(meta_entry.get("updated_at"))
|
meta_record = cast(dict[str, Any], meta_entry)
|
||||||
|
meta_ts = self._safe_float(cast(object, meta_record.get("updated_at")))
|
||||||
elif meta_entry is not None:
|
elif meta_entry is not None:
|
||||||
meta_ts = self._safe_float(meta_entry)
|
meta_ts = self._safe_float(meta_entry)
|
||||||
|
|
||||||
@ -623,7 +643,7 @@ class MSTeamsChannel(BaseChannel):
|
|||||||
return self._load_refs_from_disk()
|
return self._load_refs_from_disk()
|
||||||
|
|
||||||
@contextmanager
|
@contextmanager
|
||||||
def _refs_file_lock(self):
|
def _refs_file_lock(self) -> Generator[None, None, None]:
|
||||||
"""Cross-process lock while merging and writing refs state."""
|
"""Cross-process lock while merging and writing refs state."""
|
||||||
self._refs_path.parent.mkdir(parents=True, exist_ok=True)
|
self._refs_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
lock_fp = self._refs_lock_path.open("a+", encoding="utf-8")
|
lock_fp = self._refs_lock_path.open("a+", encoding="utf-8")
|
||||||
@ -742,7 +762,7 @@ class MSTeamsChannel(BaseChannel):
|
|||||||
if persist:
|
if persist:
|
||||||
self._save_refs_locked()
|
self._save_refs_locked()
|
||||||
|
|
||||||
def _write_json_atomically(self, path, data: dict[str, Any]) -> None:
|
def _write_json_atomically(self, path: Path, data: dict[str, Any]) -> None:
|
||||||
"""Write refs JSON atomically to reduce corruption risk during crashes."""
|
"""Write refs JSON atomically to reduce corruption risk during crashes."""
|
||||||
payload = json.dumps(data, indent=2)
|
payload = json.dumps(data, indent=2)
|
||||||
tmp_path: str | None = None
|
tmp_path: str | None = None
|
||||||
@ -816,7 +836,8 @@ class MSTeamsChannel(BaseChannel):
|
|||||||
}
|
}
|
||||||
resp = await self._http.post(token_url, data=data)
|
resp = await self._http.post(token_url, data=data)
|
||||||
resp.raise_for_status()
|
resp.raise_for_status()
|
||||||
payload = resp.json()
|
payload = cast(dict[str, Any], resp.json())
|
||||||
self._token = payload["access_token"]
|
token = cast(str, payload["access_token"])
|
||||||
|
self._token = token
|
||||||
self._token_expires_at = now + int(payload.get("expires_in", 3600))
|
self._token_expires_at = now + int(payload.get("expires_in", 3600))
|
||||||
return self._token
|
return token
|
||||||
|
|||||||
@ -11,7 +11,7 @@ import time
|
|||||||
import uuid
|
import uuid
|
||||||
from collections import deque
|
from collections import deque
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Annotated, Any, Literal
|
from typing import Annotated, Any, Literal, cast
|
||||||
|
|
||||||
import aiohttp
|
import aiohttp
|
||||||
from loguru import logger
|
from loguru import logger
|
||||||
@ -103,7 +103,7 @@ class NapcatChannel(BaseChannel):
|
|||||||
await asyncio.sleep(next(backoff, 30))
|
await asyncio.sleep(next(backoff, 30))
|
||||||
|
|
||||||
async def _run_once(self) -> None:
|
async def _run_once(self) -> None:
|
||||||
headers = []
|
headers: list[tuple[str, str]] = []
|
||||||
if self.config.access_token:
|
if self.config.access_token:
|
||||||
headers.append(("Authorization", f"Bearer {self.config.access_token}"))
|
headers.append(("Authorization", f"Bearer {self.config.access_token}"))
|
||||||
|
|
||||||
@ -132,12 +132,17 @@ class NapcatChannel(BaseChannel):
|
|||||||
payload = json.loads(raw)
|
payload = json.loads(raw)
|
||||||
except json.JSONDecodeError:
|
except json.JSONDecodeError:
|
||||||
continue
|
continue
|
||||||
if isinstance(payload, dict) and payload.get("echo") == echo:
|
if isinstance(payload, dict):
|
||||||
data = payload.get("data") or {}
|
login_payload = cast(dict[str, Any], payload)
|
||||||
|
else:
|
||||||
|
login_payload = None
|
||||||
|
if login_payload is not None and login_payload.get("echo") == echo:
|
||||||
|
data = login_payload.get("data")
|
||||||
|
login_data = cast(dict[str, Any], data) if isinstance(data, dict) else {}
|
||||||
logger.info(
|
logger.info(
|
||||||
"napcat: logged in as {} (user_id={})",
|
"napcat: logged in as {} (user_id={})",
|
||||||
data.get("nickname"),
|
login_data.get("nickname"),
|
||||||
data.get("user_id"),
|
login_data.get("user_id"),
|
||||||
)
|
)
|
||||||
break
|
break
|
||||||
await self._dispatch_frame(raw)
|
await self._dispatch_frame(raw)
|
||||||
@ -189,26 +194,27 @@ class NapcatChannel(BaseChannel):
|
|||||||
return
|
return
|
||||||
if not isinstance(payload, dict):
|
if not isinstance(payload, dict):
|
||||||
return
|
return
|
||||||
|
frame = cast(dict[str, Any], payload)
|
||||||
|
|
||||||
# Action response: identified by `echo` and absence of post_type.
|
# Action response: identified by `echo` and absence of post_type.
|
||||||
if "echo" in payload and payload.get("post_type") is None:
|
if "echo" in frame and frame.get("post_type") is None:
|
||||||
echo = payload.get("echo")
|
echo = frame.get("echo")
|
||||||
fut = self._pending.pop(echo, None) if isinstance(echo, str) else None
|
fut = self._pending.pop(echo, None) if isinstance(echo, str) else None
|
||||||
if fut and not fut.done():
|
if fut and not fut.done():
|
||||||
fut.set_result(payload)
|
fut.set_result(frame)
|
||||||
return
|
return
|
||||||
|
|
||||||
if (sid := payload.get("self_id")) is not None:
|
if (sid := frame.get("self_id")) is not None:
|
||||||
try:
|
try:
|
||||||
self._self_id = int(sid)
|
self._self_id = int(sid)
|
||||||
except (TypeError, ValueError):
|
except (TypeError, ValueError):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
post_type = payload.get("post_type")
|
post_type = frame.get("post_type")
|
||||||
if post_type == "message":
|
if post_type == "message":
|
||||||
self._create_background_task(self._on_message(payload), "message")
|
self._create_background_task(self._on_message(frame), "message")
|
||||||
elif post_type == "notice":
|
elif post_type == "notice":
|
||||||
self._create_background_task(self._on_notice(payload), "notice")
|
self._create_background_task(self._on_notice(frame), "notice")
|
||||||
|
|
||||||
def _create_background_task(self, coro: Any, kind: str) -> None:
|
def _create_background_task(self, coro: Any, kind: str) -> None:
|
||||||
task = asyncio.create_task(coro)
|
task = asyncio.create_task(coro)
|
||||||
@ -249,7 +255,8 @@ class NapcatChannel(BaseChannel):
|
|||||||
if local := await self._download_image(info):
|
if local := await self._download_image(info):
|
||||||
media_paths.append(local)
|
media_paths.append(local)
|
||||||
|
|
||||||
sender = ev.get("sender") or {}
|
sender_raw = ev.get("sender")
|
||||||
|
sender = cast(dict[str, Any], sender_raw) if isinstance(sender_raw, dict) else {}
|
||||||
nickname = sender.get("card") or sender.get("nickname")
|
nickname = sender.get("card") or sender.get("nickname")
|
||||||
|
|
||||||
if message_type == "group":
|
if message_type == "group":
|
||||||
@ -270,7 +277,7 @@ class NapcatChannel(BaseChannel):
|
|||||||
chat_id = f"group:{group_id}"
|
chat_id = f"group:{group_id}"
|
||||||
content = self._format_group_content(
|
content = self._format_group_content(
|
||||||
text=text,
|
text=text,
|
||||||
nickname=nickname,
|
nickname=cast(str, nickname),
|
||||||
user_id=user_id,
|
user_id=user_id,
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
@ -299,7 +306,7 @@ class NapcatChannel(BaseChannel):
|
|||||||
# segment rather than parsing CQ codes — that path is fragile and
|
# segment rather than parsing CQ codes — that path is fragile and
|
||||||
# users can configure napcat to emit arrays.
|
# users can configure napcat to emit arrays.
|
||||||
if isinstance(message, list):
|
if isinstance(message, list):
|
||||||
return [seg for seg in message if isinstance(seg, dict)]
|
return [cast(dict[str, Any], seg) for seg in cast(list[Any], message) if isinstance(seg, dict)]
|
||||||
if isinstance(message, str) and message:
|
if isinstance(message, str) and message:
|
||||||
return [{"type": "text", "data": {"text": message}}]
|
return [{"type": "text", "data": {"text": message}}]
|
||||||
return []
|
return []
|
||||||
@ -315,7 +322,8 @@ class NapcatChannel(BaseChannel):
|
|||||||
|
|
||||||
for seg in segments:
|
for seg in segments:
|
||||||
stype = seg.get("type")
|
stype = seg.get("type")
|
||||||
data = seg.get("data") or {}
|
raw_data = seg.get("data")
|
||||||
|
data = cast(dict[str, Any], raw_data) if isinstance(raw_data, dict) else {}
|
||||||
if stype == "text":
|
if stype == "text":
|
||||||
if txt := data.get("text"):
|
if txt := data.get("text"):
|
||||||
parts.append(str(txt))
|
parts.append(str(txt))
|
||||||
@ -455,7 +463,8 @@ class NapcatChannel(BaseChannel):
|
|||||||
params["user_id"] = int(target)
|
params["user_id"] = int(target)
|
||||||
|
|
||||||
resp = await self._call_action("send_msg", params)
|
resp = await self._call_action("send_msg", params)
|
||||||
data = resp.get("data") or {}
|
raw_data = resp.get("data")
|
||||||
|
data = cast(dict[str, Any], raw_data) if isinstance(raw_data, dict) else {}
|
||||||
if (mid := data.get("message_id")) is not None:
|
if (mid := data.get("message_id")) is not None:
|
||||||
self._bot_outbound_ids.append(int(mid))
|
self._bot_outbound_ids.append(int(mid))
|
||||||
|
|
||||||
|
|||||||
@ -7,7 +7,7 @@ import re
|
|||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from functools import lru_cache
|
from functools import lru_cache
|
||||||
from importlib.resources import files
|
from importlib.resources import files
|
||||||
from typing import TYPE_CHECKING, Any
|
from typing import TYPE_CHECKING, Any, cast
|
||||||
|
|
||||||
from packaging.requirements import InvalidRequirement, Requirement
|
from packaging.requirements import InvalidRequirement, Requirement
|
||||||
|
|
||||||
@ -49,12 +49,12 @@ class ChannelPlugin:
|
|||||||
_target_parts(self.runtime, label="runtime")
|
_target_parts(self.runtime, label="runtime")
|
||||||
if self.connector is not None:
|
if self.connector is not None:
|
||||||
_target_parts(self.connector, label="connector")
|
_target_parts(self.connector, label="connector")
|
||||||
if self.setup is not None and not isinstance(self.setup, ChannelSetupSpec):
|
if self.setup is not None and not isinstance(cast(object, self.setup), ChannelSetupSpec):
|
||||||
raise TypeError("channel plugin setup must be a ChannelSetupSpec or None")
|
raise TypeError("channel plugin setup must be a ChannelSetupSpec or None")
|
||||||
if not isinstance(self.management, ChannelManagementSpec):
|
if not isinstance(cast(object, self.management), ChannelManagementSpec):
|
||||||
raise TypeError("channel plugin management must be a ChannelManagementSpec")
|
raise TypeError("channel plugin management must be a ChannelManagementSpec")
|
||||||
if not isinstance(self.dependencies, tuple) or not all(
|
if not isinstance(cast(object, self.dependencies), tuple) or not all(
|
||||||
isinstance(requirement, str) and requirement.strip()
|
isinstance(cast(object, requirement), str) and requirement.strip()
|
||||||
for requirement in self.dependencies
|
for requirement in self.dependencies
|
||||||
):
|
):
|
||||||
raise TypeError("channel plugin dependencies must be a tuple of requirements")
|
raise TypeError("channel plugin dependencies must be a tuple of requirements")
|
||||||
|
|||||||
@ -16,6 +16,8 @@ Notes:
|
|||||||
- Attachment structures differ across botpy versions; we try multiple field candidates.
|
- Attachment structures differ across botpy versions; we try multiple field candidates.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
# pyright: reportConstantRedefinition=false, reportMissingTypeStubs=false, reportPrivateUsage=false
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
@ -27,7 +29,7 @@ import time
|
|||||||
from collections import deque
|
from collections import deque
|
||||||
from contextlib import suppress
|
from contextlib import suppress
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import TYPE_CHECKING, Any, Literal
|
from typing import Any, BinaryIO, Literal, cast
|
||||||
from urllib.parse import unquote, urlparse
|
from urllib.parse import unquote, urlparse
|
||||||
|
|
||||||
import aiohttp
|
import aiohttp
|
||||||
@ -58,11 +60,6 @@ except ImportError: # pragma: no cover
|
|||||||
BotWebSocket = None
|
BotWebSocket = None
|
||||||
Route = None
|
Route = None
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
|
||||||
from botpy.message import BaseMessage, C2CMessage, GroupMessage
|
|
||||||
from botpy.types.message import Media
|
|
||||||
|
|
||||||
|
|
||||||
# QQ rich media file_type: 1=image, 4=file
|
# QQ rich media file_type: 1=image, 4=file
|
||||||
# (2=voice, 3=video are restricted; we only use image vs file)
|
# (2=voice, 3=video are restricted; we only use image vs file)
|
||||||
QQ_FILE_TYPE_IMAGE = 1
|
QQ_FILE_TYPE_IMAGE = 1
|
||||||
@ -118,30 +115,34 @@ def _is_network_error(exc: BaseException) -> bool:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def _make_bot_class(channel: QQChannel) -> type[botpy.Client]:
|
def _make_bot_class(channel: QQChannel) -> type[Any]:
|
||||||
"""Create a botpy client with per-session reconnect backoff."""
|
"""Create a botpy client with per-session reconnect backoff."""
|
||||||
intents = botpy.Intents(public_messages=True, direct_message=True)
|
botpy_sdk = cast(Any, botpy)
|
||||||
|
intents = botpy_sdk.Intents(public_messages=True, direct_message=True)
|
||||||
|
|
||||||
class _Bot(botpy.Client):
|
class _Bot(botpy_sdk.Client):
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
# Disable botpy's file log — nanobot uses loguru; default "botpy.log" fails on read-only fs
|
# Disable botpy's file log — nanobot uses loguru; default "botpy.log" fails on read-only fs
|
||||||
super().__init__(intents=intents, ext_handlers=False)
|
super().__init__( # pyright: ignore[reportUnknownMemberType]
|
||||||
|
intents=intents,
|
||||||
|
ext_handlers=False,
|
||||||
|
)
|
||||||
self._ws_backoff: dict[int, int] = {}
|
self._ws_backoff: dict[int, int] = {}
|
||||||
self._ws_retry_at: dict[int, float] = {}
|
self._ws_retry_at: dict[int, float] = {}
|
||||||
|
|
||||||
async def on_ready(self):
|
async def on_ready(self):
|
||||||
logger.info("QQ bot ready: {}", self.robot.name)
|
logger.info("QQ bot ready: {}", self.robot.name)
|
||||||
|
|
||||||
async def on_c2c_message_create(self, message: C2CMessage):
|
async def on_c2c_message_create(self, message: object) -> None:
|
||||||
await channel._on_message(message, is_group=False)
|
await channel._on_message(message, is_group=False)
|
||||||
|
|
||||||
async def on_group_at_message_create(self, message: GroupMessage):
|
async def on_group_at_message_create(self, message: object) -> None:
|
||||||
await channel._on_message(message, is_group=True)
|
await channel._on_message(message, is_group=True)
|
||||||
|
|
||||||
async def on_direct_message_create(self, message):
|
async def on_direct_message_create(self, message: object) -> None:
|
||||||
await channel._on_message(message, is_group=False)
|
await channel._on_message(message, is_group=False)
|
||||||
|
|
||||||
async def bot_connect(self, session):
|
async def bot_connect(self, session: object) -> None:
|
||||||
"""Connect a botpy session with exponential retry backoff."""
|
"""Connect a botpy session with exponential retry backoff."""
|
||||||
session_id = id(session)
|
session_id = id(session)
|
||||||
retry_at = self._ws_retry_at.pop(session_id, None)
|
retry_at = self._ws_retry_at.pop(session_id, None)
|
||||||
@ -150,7 +151,8 @@ def _make_bot_class(channel: QQChannel) -> type[botpy.Client]:
|
|||||||
if remaining > 0:
|
if remaining > 0:
|
||||||
await asyncio.sleep(remaining)
|
await asyncio.sleep(remaining)
|
||||||
|
|
||||||
client = BotWebSocket(session, self._connection)
|
websocket_class = cast(Any, BotWebSocket)
|
||||||
|
client = websocket_class(session, self._connection)
|
||||||
backoff = self._ws_backoff.get(session_id, _RECONNECT_BACKOFF_START)
|
backoff = self._ws_backoff.get(session_id, _RECONNECT_BACKOFF_START)
|
||||||
try:
|
try:
|
||||||
await client.ws_connect()
|
await client.ws_connect()
|
||||||
@ -207,7 +209,7 @@ class QQChannel(BaseChannel):
|
|||||||
super().__init__(config, bus)
|
super().__init__(config, bus)
|
||||||
self.config: QQConfig = config
|
self.config: QQConfig = config
|
||||||
|
|
||||||
self._client: botpy.Client | None = None
|
self._client: Any | None = None
|
||||||
self._http: aiohttp.ClientSession | None = None
|
self._http: aiohttp.ClientSession | None = None
|
||||||
|
|
||||||
self._processed_ids: deque[str] = deque(maxlen=1000)
|
self._processed_ids: deque[str] = deque(maxlen=1000)
|
||||||
@ -260,7 +262,8 @@ class QQChannel(BaseChannel):
|
|||||||
max_backoff = 300
|
max_backoff = 300
|
||||||
while self._running:
|
while self._running:
|
||||||
try:
|
try:
|
||||||
await self._client.start(appid=self.config.app_id, secret=self.config.secret)
|
client = cast(Any, self._client)
|
||||||
|
await client.start(appid=self.config.app_id, secret=self.config.secret)
|
||||||
backoff = 5
|
backoff = 5
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
if _is_network_error(e):
|
if _is_network_error(e):
|
||||||
@ -490,7 +493,7 @@ class QQChannel(BaseChannel):
|
|||||||
file_data: str,
|
file_data: str,
|
||||||
file_name: str | None = None,
|
file_name: str | None = None,
|
||||||
srv_send_msg: bool = False,
|
srv_send_msg: bool = False,
|
||||||
) -> Media:
|
) -> dict[str, Any]:
|
||||||
"""Upload base64-encoded file and return Media object."""
|
"""Upload base64-encoded file and return Media object."""
|
||||||
if not self._client:
|
if not self._client:
|
||||||
raise RuntimeError("QQ client not initialized")
|
raise RuntimeError("QQ client not initialized")
|
||||||
@ -514,39 +517,44 @@ class QQChannel(BaseChannel):
|
|||||||
if file_type != QQ_FILE_TYPE_IMAGE and file_name:
|
if file_type != QQ_FILE_TYPE_IMAGE and file_name:
|
||||||
payload["file_name"] = file_name
|
payload["file_name"] = file_name
|
||||||
|
|
||||||
route = Route("POST", endpoint, **{id_key: chat_id})
|
route_class = cast(Any, Route)
|
||||||
result = await self._client.api._http.request(route, json=payload)
|
route = route_class("POST", endpoint, **{id_key: chat_id})
|
||||||
|
client = self._client
|
||||||
|
result: object = await client.api._http.request(route, json=payload)
|
||||||
|
|
||||||
# Extract only the file_info field to avoid extra fields (file_uuid, ttl, etc.)
|
# Extract only the file_info field to avoid extra fields (file_uuid, ttl, etc.)
|
||||||
# that may confuse QQ client when sending the media object.
|
# that may confuse QQ client when sending the media object.
|
||||||
if isinstance(result, dict) and "file_info" in result:
|
if isinstance(result, dict) and "file_info" in result:
|
||||||
return {"file_info": result["file_info"]}
|
result_data = cast(dict[str, Any], result)
|
||||||
return result
|
return {"file_info": result_data["file_info"]}
|
||||||
|
return cast(dict[str, Any], result)
|
||||||
|
|
||||||
# ---------------------------
|
# ---------------------------
|
||||||
# Inbound (receive)
|
# Inbound (receive)
|
||||||
# ---------------------------
|
# ---------------------------
|
||||||
|
|
||||||
async def _on_message(self, data: C2CMessage | GroupMessage, is_group: bool = False) -> None:
|
async def _on_message(self, data: object, is_group: bool = False) -> None:
|
||||||
"""Parse inbound message, download attachments, and publish to the bus."""
|
"""Parse inbound message, download attachments, and publish to the bus."""
|
||||||
try:
|
try:
|
||||||
|
message = cast(Any, data)
|
||||||
if is_group:
|
if is_group:
|
||||||
chat_id = data.group_openid
|
chat_id = cast(str, message.group_openid)
|
||||||
user_id = data.author.member_openid
|
user_id = cast(str, message.author.member_openid)
|
||||||
chat_type = "group"
|
chat_type = "group"
|
||||||
else:
|
else:
|
||||||
chat_id = str(
|
chat_id = str(
|
||||||
getattr(data.author, "id", None)
|
getattr(message.author, "id", None)
|
||||||
or getattr(data.author, "user_openid", "unknown")
|
or getattr(message.author, "user_openid", "unknown")
|
||||||
)
|
)
|
||||||
user_id = chat_id
|
user_id = chat_id
|
||||||
chat_type = "c2c"
|
chat_type = "c2c"
|
||||||
|
|
||||||
content = (data.content or "").strip()
|
content = str(message.content or "").strip()
|
||||||
|
|
||||||
if data.id in self._processed_ids:
|
message_id = cast(str, message.id)
|
||||||
|
if message_id in self._processed_ids:
|
||||||
return
|
return
|
||||||
self._processed_ids.append(data.id)
|
self._processed_ids.append(message_id)
|
||||||
self._chat_type_cache[chat_id] = chat_type
|
self._chat_type_cache[chat_id] = chat_type
|
||||||
|
|
||||||
# Early permission check — avoid attachment downloads and ack side effects
|
# Early permission check — avoid attachment downloads and ack side effects
|
||||||
@ -564,7 +572,10 @@ class QQChannel(BaseChannel):
|
|||||||
|
|
||||||
# the data used by tests don't contain attachments property
|
# the data used by tests don't contain attachments property
|
||||||
# so we use getattr with a default of [] to avoid AttributeError in tests
|
# so we use getattr with a default of [] to avoid AttributeError in tests
|
||||||
attachments = getattr(data, "attachments", None) or []
|
attachments = cast(
|
||||||
|
list[object],
|
||||||
|
getattr(message, "attachments", None) or [],
|
||||||
|
)
|
||||||
media_paths, recv_lines, att_meta = await self._handle_attachments(attachments)
|
media_paths, recv_lines, att_meta = await self._handle_attachments(attachments)
|
||||||
|
|
||||||
# Compose content that always contains actionable saved paths
|
# Compose content that always contains actionable saved paths
|
||||||
@ -587,7 +598,7 @@ class QQChannel(BaseChannel):
|
|||||||
await self._send_text_only(
|
await self._send_text_only(
|
||||||
chat_id=chat_id,
|
chat_id=chat_id,
|
||||||
is_group=is_group,
|
is_group=is_group,
|
||||||
msg_id=data.id,
|
msg_id=message_id,
|
||||||
content=self.config.ack_message,
|
content=self.config.ack_message,
|
||||||
)
|
)
|
||||||
except Exception:
|
except Exception:
|
||||||
@ -599,17 +610,20 @@ class QQChannel(BaseChannel):
|
|||||||
content=content,
|
content=content,
|
||||||
media=media_paths if media_paths else None,
|
media=media_paths if media_paths else None,
|
||||||
metadata={
|
metadata={
|
||||||
"message_id": data.id,
|
"message_id": message_id,
|
||||||
"attachments": att_meta,
|
"attachments": att_meta,
|
||||||
},
|
},
|
||||||
is_dm=not is_group,
|
is_dm=not is_group,
|
||||||
)
|
)
|
||||||
except Exception:
|
except Exception:
|
||||||
self.logger.exception("Error handling inbound message id={}", getattr(data, "id", "?"))
|
self.logger.exception(
|
||||||
|
"Error handling inbound message id={}",
|
||||||
|
getattr(data, "id", "?"),
|
||||||
|
)
|
||||||
|
|
||||||
async def _handle_attachments(
|
async def _handle_attachments(
|
||||||
self,
|
self,
|
||||||
attachments: list[BaseMessage._Attachments],
|
attachments: list[object],
|
||||||
) -> tuple[list[str], list[str], list[dict[str, Any]]]:
|
) -> tuple[list[str], list[str], list[dict[str, Any]]]:
|
||||||
"""Extract, download (chunked), and format attachments for agent consumption."""
|
"""Extract, download (chunked), and format attachments for agent consumption."""
|
||||||
media_paths: list[str] = []
|
media_paths: list[str] = []
|
||||||
@ -718,9 +732,11 @@ class QQChannel(BaseChannel):
|
|||||||
1024 * 1024, int(self.config.download_max_bytes or (200 * 1024 * 1024))
|
1024 * 1024, int(self.config.download_max_bytes or (200 * 1024 * 1024))
|
||||||
)
|
)
|
||||||
|
|
||||||
def _open_tmp():
|
active_tmp_path = tmp_path
|
||||||
tmp_path.parent.mkdir(parents=True, exist_ok=True)
|
|
||||||
return open(tmp_path, "wb") # noqa: SIM115
|
def _open_tmp() -> BinaryIO:
|
||||||
|
active_tmp_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
return active_tmp_path.open("wb") # noqa: SIM115
|
||||||
|
|
||||||
f = await asyncio.to_thread(_open_tmp)
|
f = await asyncio.to_thread(_open_tmp)
|
||||||
try:
|
try:
|
||||||
@ -740,7 +756,7 @@ class QQChannel(BaseChannel):
|
|||||||
await asyncio.to_thread(f.close)
|
await asyncio.to_thread(f.close)
|
||||||
|
|
||||||
# Atomic rename
|
# Atomic rename
|
||||||
await asyncio.to_thread(os.replace, tmp_path, target)
|
await asyncio.to_thread(os.replace, active_tmp_path, target)
|
||||||
tmp_path = None # mark as moved
|
tmp_path = None # mark as moved
|
||||||
self.logger.info("file saved: {}", str(target))
|
self.logger.info("file saved: {}", str(target))
|
||||||
return str(target)
|
return str(target)
|
||||||
|
|||||||
@ -12,7 +12,7 @@ from collections.abc import AsyncIterator, Callable
|
|||||||
from contextlib import asynccontextmanager
|
from contextlib import asynccontextmanager
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any
|
from typing import Any, TypedDict, cast
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
from pydantic import Field, computed_field, field_validator
|
from pydantic import Field, computed_field, field_validator
|
||||||
@ -53,7 +53,7 @@ _SIG_TOKEN_RE = re.compile(r"\x00C(\d+)\x00")
|
|||||||
# stripper needs a fixed, narrow subset (no single-asterisk italic, no
|
# stripper needs a fixed, narrow subset (no single-asterisk italic, no
|
||||||
# single-tilde strikethrough) and benefits from each pattern's group 1 being
|
# single-tilde strikethrough) and benefits from each pattern's group 1 being
|
||||||
# the content directly.
|
# the content directly.
|
||||||
_SIG_CELL_STRIP_PATTERNS: tuple[tuple[re.Pattern, str], ...] = (
|
_SIG_CELL_STRIP_PATTERNS: tuple[tuple[re.Pattern[str], str], ...] = (
|
||||||
(re.compile(r"\*\*(.+?)\*\*"), r"\1"),
|
(re.compile(r"\*\*(.+?)\*\*"), r"\1"),
|
||||||
(re.compile(r"__(.+?)__"), r"\1"),
|
(re.compile(r"__(.+?)__"), r"\1"),
|
||||||
(re.compile(r"~~(.+?)~~"), r"\1"),
|
(re.compile(r"~~(.+?)~~"), r"\1"),
|
||||||
@ -61,6 +61,27 @@ _SIG_CELL_STRIP_PATTERNS: tuple[tuple[re.Pattern, str], ...] = (
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _as_json_object(value: object) -> dict[str, Any] | None:
|
||||||
|
"""Return an untrusted JSON value only when it is an object."""
|
||||||
|
if isinstance(value, dict):
|
||||||
|
return cast(dict[str, Any], value)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _as_json_object_list(value: object) -> list[dict[str, Any]]:
|
||||||
|
"""Return the object members of an untrusted JSON array."""
|
||||||
|
if not isinstance(value, list):
|
||||||
|
return []
|
||||||
|
return [cast(dict[str, Any], item) for item in cast(list[object], value) if isinstance(item, dict)]
|
||||||
|
|
||||||
|
|
||||||
|
class _BufferedMessage(TypedDict):
|
||||||
|
sender_name: str
|
||||||
|
sender_number: str
|
||||||
|
content: str
|
||||||
|
timestamp: int | None
|
||||||
|
|
||||||
|
|
||||||
def _utf16_len(s: str) -> int:
|
def _utf16_len(s: str) -> int:
|
||||||
"""UTF-16 code-unit length, matching Signal BodyRange semantics."""
|
"""UTF-16 code-unit length, matching Signal BodyRange semantics."""
|
||||||
return len(s.encode("utf-16-le")) // 2
|
return len(s.encode("utf-16-le")) // 2
|
||||||
@ -118,7 +139,7 @@ def _markdown_to_signal(text: str) -> tuple[str, list[str]]:
|
|||||||
# so they're protected from inline-style processing.
|
# so they're protected from inline-style processing.
|
||||||
protected: list[str] = []
|
protected: list[str] = []
|
||||||
|
|
||||||
def save_code(m: re.Match) -> str:
|
def save_code(m: re.Match[str]) -> str:
|
||||||
protected.append(m.group(1))
|
protected.append(m.group(1))
|
||||||
return f"\x00C{len(protected) - 1}\x00"
|
return f"\x00C{len(protected) - 1}\x00"
|
||||||
|
|
||||||
@ -149,8 +170,8 @@ def _markdown_to_signal(text: str) -> tuple[str, list[str]]:
|
|||||||
runs: list[_Run] = [_Run(text)]
|
runs: list[_Run] = [_Run(text)]
|
||||||
|
|
||||||
def transform(
|
def transform(
|
||||||
pattern: re.Pattern,
|
pattern: re.Pattern[str],
|
||||||
make_runs: Callable[[re.Match, frozenset[str]], list[_Run]],
|
make_runs: Callable[[re.Match[str], frozenset[str]], list[_Run]],
|
||||||
) -> None:
|
) -> None:
|
||||||
new_runs: list[_Run] = []
|
new_runs: list[_Run] = []
|
||||||
for run in runs:
|
for run in runs:
|
||||||
@ -189,7 +210,7 @@ def _markdown_to_signal(text: str) -> tuple[str, list[str]]:
|
|||||||
transform(_SIG_OLIST_RE, lambda m, s: [_Run(m.group(1) + ". ", s)])
|
transform(_SIG_OLIST_RE, lambda m, s: [_Run(m.group(1) + ". ", s)])
|
||||||
|
|
||||||
# Links → "text (url)" or bare url when text equals url.
|
# Links → "text (url)" or bare url when text equals url.
|
||||||
def _link_runs(m: re.Match, s: frozenset) -> list[_Run]:
|
def _link_runs(m: re.Match[str], s: frozenset[str]) -> list[_Run]:
|
||||||
link_text, url = m.group(1), m.group(2)
|
link_text, url = m.group(1), m.group(2)
|
||||||
|
|
||||||
def _norm(u: str) -> str:
|
def _norm(u: str) -> str:
|
||||||
@ -357,15 +378,15 @@ class SignalChannel(BaseChannel):
|
|||||||
self.config: SignalConfig = config
|
self.config: SignalConfig = config
|
||||||
self._http: httpx.AsyncClient | None = None
|
self._http: httpx.AsyncClient | None = None
|
||||||
self._request_id = 0
|
self._request_id = 0
|
||||||
self._sse_task: asyncio.Task | None = None
|
self._sse_task: asyncio.Task[None] | None = None
|
||||||
self._typing_tasks: dict[str, asyncio.Task] = {}
|
self._typing_tasks: dict[str, asyncio.Task[None]] = {}
|
||||||
self._typing_uuid_warnings: set[str] = set()
|
self._typing_uuid_warnings: set[str] = set()
|
||||||
self._account_id_aliases: set[str] = set()
|
self._account_id_aliases: set[str] = set()
|
||||||
self._remember_account_id_alias(self.config.phone_number)
|
self._remember_account_id_alias(self.config.phone_number)
|
||||||
|
|
||||||
# Rolling message buffer for group context (group_id -> deque of messages)
|
# Rolling message buffer for group context (group_id -> deque of messages)
|
||||||
# Each message is a dict with: sender_name, sender_number, content, timestamp
|
# Each message is a dict with: sender_name, sender_number, content, timestamp
|
||||||
self._group_buffers: dict[str, deque] = {}
|
self._group_buffers: dict[str, deque[_BufferedMessage]] = {}
|
||||||
|
|
||||||
def is_allowed(self, sender_id: str) -> bool:
|
def is_allowed(self, sender_id: str) -> bool:
|
||||||
"""Override base check to normalize and split pipe-joined identifiers.
|
"""Override base check to normalize and split pipe-joined identifiers.
|
||||||
@ -409,6 +430,7 @@ class SignalChannel(BaseChannel):
|
|||||||
metadata: dict[str, Any] | None = None,
|
metadata: dict[str, Any] | None = None,
|
||||||
session_key: str | None = None,
|
session_key: str | None = None,
|
||||||
is_dm: bool = False,
|
is_dm: bool = False,
|
||||||
|
authorization_id: str | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Handle an inbound message whose policy has already been checked.
|
"""Handle an inbound message whose policy has already been checked.
|
||||||
|
|
||||||
@ -418,6 +440,7 @@ class SignalChannel(BaseChannel):
|
|||||||
``super()._handle_message`` instead, which goes through
|
``super()._handle_message`` instead, which goes through
|
||||||
``is_allowed`` and issues a pairing code.
|
``is_allowed`` and issues a pairing code.
|
||||||
"""
|
"""
|
||||||
|
del authorization_id
|
||||||
meta = metadata or {}
|
meta = metadata or {}
|
||||||
if self.supports_streaming:
|
if self.supports_streaming:
|
||||||
meta = {**meta, "_wants_stream": True}
|
meta = {**meta, "_wants_stream": True}
|
||||||
@ -594,7 +617,7 @@ class SignalChannel(BaseChannel):
|
|||||||
self.logger.info("Subscribed to Signal messages via SSE")
|
self.logger.info("Subscribed to Signal messages via SSE")
|
||||||
|
|
||||||
# Buffer for accumulating SSE data across multiple lines
|
# Buffer for accumulating SSE data across multiple lines
|
||||||
event_buffer = []
|
event_buffer: list[str] = []
|
||||||
|
|
||||||
async for line in response.aiter_lines():
|
async for line in response.aiter_lines():
|
||||||
if not self._running:
|
if not self._running:
|
||||||
@ -605,7 +628,7 @@ class SignalChannel(BaseChannel):
|
|||||||
self.logger.debug("SSE line received: {}", line[:200])
|
self.logger.debug("SSE line received: {}", line[:200])
|
||||||
|
|
||||||
# SSE format handling
|
# SSE format handling
|
||||||
if isinstance(line, str):
|
if isinstance(line, str): # pyright: ignore[reportUnnecessaryIsInstance]
|
||||||
# Empty line signals end of event
|
# Empty line signals end of event
|
||||||
if not line or line == ":":
|
if not line or line == ":":
|
||||||
if event_buffer:
|
if event_buffer:
|
||||||
@ -613,7 +636,10 @@ class SignalChannel(BaseChannel):
|
|||||||
data_str = ""
|
data_str = ""
|
||||||
try:
|
try:
|
||||||
data_str = "\n".join(event_buffer)
|
data_str = "\n".join(event_buffer)
|
||||||
data = json.loads(data_str)
|
data = _as_json_object(json.loads(data_str))
|
||||||
|
if data is None:
|
||||||
|
self.logger.warning("Ignoring non-object SSE event: {}", data_str[:200])
|
||||||
|
continue
|
||||||
self.logger.debug("SSE event parsed: {}", data)
|
self.logger.debug("SSE event parsed: {}", data)
|
||||||
await self._handle_receive_notification(data)
|
await self._handle_receive_notification(data)
|
||||||
except json.JSONDecodeError as e:
|
except json.JSONDecodeError as e:
|
||||||
@ -644,7 +670,7 @@ class SignalChannel(BaseChannel):
|
|||||||
self.logger.error("Error in SSE receive loop: {}", e)
|
self.logger.error("Error in SSE receive loop: {}", e)
|
||||||
raise
|
raise
|
||||||
|
|
||||||
@asynccontextmanager
|
@asynccontextmanager # pyright: ignore[reportDeprecated]
|
||||||
async def _safe_handle(self, action: str, payload: Any = None) -> AsyncIterator[None]:
|
async def _safe_handle(self, action: str, payload: Any = None) -> AsyncIterator[None]:
|
||||||
"""Swallow and log any exception from a top-level handler block.
|
"""Swallow and log any exception from a top-level handler block.
|
||||||
|
|
||||||
@ -666,17 +692,18 @@ class SignalChannel(BaseChannel):
|
|||||||
self.logger.debug("_handle_receive_notification called with: {}", params)
|
self.logger.debug("_handle_receive_notification called with: {}", params)
|
||||||
async with self._safe_handle("receive notification", params):
|
async with self._safe_handle("receive notification", params):
|
||||||
# Extract envelope from SSE notification: {"envelope": {...}}
|
# Extract envelope from SSE notification: {"envelope": {...}}
|
||||||
envelope = params.get("envelope", {})
|
envelope = _as_json_object(params.get("envelope"))
|
||||||
|
|
||||||
self.logger.debug("Extracted envelope: {}", envelope)
|
self.logger.debug("Extracted envelope: {}", envelope)
|
||||||
|
|
||||||
if not envelope:
|
if envelope is None:
|
||||||
self.logger.debug("No envelope found in params")
|
self.logger.debug("No envelope found in params")
|
||||||
return
|
return
|
||||||
|
|
||||||
# Extract sender information
|
# Extract sender information
|
||||||
sender_parts = self._collect_sender_id_parts(envelope)
|
sender_parts = self._collect_sender_id_parts(envelope)
|
||||||
source_name = envelope.get("sourceName")
|
source_name_value = envelope.get("sourceName")
|
||||||
|
source_name = source_name_value if isinstance(source_name_value, str) else None
|
||||||
|
|
||||||
if not sender_parts:
|
if not sender_parts:
|
||||||
self.logger.debug("Received message without source, skipping")
|
self.logger.debug("Received message without source, skipping")
|
||||||
@ -691,10 +718,10 @@ class SignalChannel(BaseChannel):
|
|||||||
self._remember_account_id_alias(part)
|
self._remember_account_id_alias(part)
|
||||||
|
|
||||||
# Check different message types
|
# Check different message types
|
||||||
data_message = envelope.get("dataMessage")
|
data_message = _as_json_object(envelope.get("dataMessage"))
|
||||||
sync_message = envelope.get("syncMessage")
|
sync_message = _as_json_object(envelope.get("syncMessage"))
|
||||||
typing_message = envelope.get("typingMessage")
|
typing_message = _as_json_object(envelope.get("typingMessage"))
|
||||||
receipt_message = envelope.get("receiptMessage")
|
receipt_message = _as_json_object(envelope.get("receiptMessage"))
|
||||||
|
|
||||||
# Ignore receipt messages (delivery/read receipts)
|
# Ignore receipt messages (delivery/read receipts)
|
||||||
if receipt_message:
|
if receipt_message:
|
||||||
@ -705,8 +732,7 @@ class SignalChannel(BaseChannel):
|
|||||||
await self._handle_data_message(sender_id, sender_number, data_message, source_name)
|
await self._handle_data_message(sender_id, sender_number, data_message, source_name)
|
||||||
|
|
||||||
# Handle sync messages (messages sent from another device)
|
# Handle sync messages (messages sent from another device)
|
||||||
elif sync_message and sync_message.get("sentMessage"):
|
elif sync_message and (sent_msg := _as_json_object(sync_message.get("sentMessage"))):
|
||||||
sent_msg = sync_message["sentMessage"]
|
|
||||||
destination = sent_msg.get("destination") or sent_msg.get("destinationNumber")
|
destination = sent_msg.get("destination") or sent_msg.get("destinationNumber")
|
||||||
if destination:
|
if destination:
|
||||||
self.logger.debug(
|
self.logger.debug(
|
||||||
@ -725,10 +751,12 @@ class SignalChannel(BaseChannel):
|
|||||||
sender_name: str | None,
|
sender_name: str | None,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Handle a data message (text, attachments, etc.)."""
|
"""Handle a data message (text, attachments, etc.)."""
|
||||||
message_text = data_message.get("message") or ""
|
message_value = data_message.get("message")
|
||||||
attachments = data_message.get("attachments", [])
|
message_text = message_value if isinstance(message_value, str) else ""
|
||||||
mentions = data_message.get("mentions", [])
|
attachments = _as_json_object_list(data_message.get("attachments"))
|
||||||
timestamp = data_message.get("timestamp")
|
mentions = _as_json_object_list(data_message.get("mentions"))
|
||||||
|
timestamp_value = data_message.get("timestamp")
|
||||||
|
timestamp = timestamp_value if isinstance(timestamp_value, int) else None
|
||||||
|
|
||||||
self.logger.info(
|
self.logger.info(
|
||||||
"Data message from {}: groupInfo={}, groupV2={}, keys={}",
|
"Data message from {}: groupInfo={}, groupV2={}, keys={}",
|
||||||
@ -815,7 +843,7 @@ class SignalChannel(BaseChannel):
|
|||||||
group_id: str | None,
|
group_id: str | None,
|
||||||
is_group_message: bool,
|
is_group_message: bool,
|
||||||
message_text: str,
|
message_text: str,
|
||||||
mentions: list,
|
mentions: list[dict[str, Any]],
|
||||||
sender_name: str | None,
|
sender_name: str | None,
|
||||||
timestamp: int | None,
|
timestamp: int | None,
|
||||||
) -> tuple[bool, str]:
|
) -> tuple[bool, str]:
|
||||||
@ -877,8 +905,8 @@ class SignalChannel(BaseChannel):
|
|||||||
sender_name: str | None,
|
sender_name: str | None,
|
||||||
sender_number: str,
|
sender_number: str,
|
||||||
message_text: str,
|
message_text: str,
|
||||||
attachments: list,
|
attachments: list[dict[str, Any]],
|
||||||
mentions: list,
|
mentions: list[dict[str, Any]],
|
||||||
is_group_message: bool,
|
is_group_message: bool,
|
||||||
chat_id: str,
|
chat_id: str,
|
||||||
) -> tuple[str, list[str]]:
|
) -> tuple[str, list[str]]:
|
||||||
@ -952,7 +980,9 @@ class SignalChannel(BaseChannel):
|
|||||||
"""
|
"""
|
||||||
# Create buffer for this group if it doesn't exist
|
# Create buffer for this group if it doesn't exist
|
||||||
if group_id not in self._group_buffers:
|
if group_id not in self._group_buffers:
|
||||||
self._group_buffers[group_id] = deque(maxlen=self.config.group_message_buffer_size)
|
self._group_buffers[group_id] = deque[_BufferedMessage](
|
||||||
|
maxlen=self.config.group_message_buffer_size
|
||||||
|
)
|
||||||
|
|
||||||
# Add message to buffer (deque will automatically drop oldest when full)
|
# Add message to buffer (deque will automatically drop oldest when full)
|
||||||
self._group_buffers[group_id].append(
|
self._group_buffers[group_id].append(
|
||||||
@ -992,7 +1022,7 @@ class SignalChannel(BaseChannel):
|
|||||||
# We want to show context BEFORE the mention
|
# We want to show context BEFORE the mention
|
||||||
context_messages = list(buffer)[:-1] # Exclude the last (current) message
|
context_messages = list(buffer)[:-1] # Exclude the last (current) message
|
||||||
|
|
||||||
lines = []
|
lines: list[str] = []
|
||||||
for msg in context_messages:
|
for msg in context_messages:
|
||||||
sender = msg["sender_name"]
|
sender = msg["sender_name"]
|
||||||
content = msg["content"][:200] # Limit to 200 chars per message
|
content = msg["content"][:200] # Limit to 200 chars per message
|
||||||
@ -1053,8 +1083,6 @@ class SignalChannel(BaseChannel):
|
|||||||
"""Remember known bot identifiers for mention matching."""
|
"""Remember known bot identifiers for mention matching."""
|
||||||
if not value:
|
if not value:
|
||||||
return
|
return
|
||||||
if not isinstance(value, str):
|
|
||||||
return
|
|
||||||
for candidate in self._normalize_signal_id(value):
|
for candidate in self._normalize_signal_id(value):
|
||||||
self._account_id_aliases.add(candidate)
|
self._account_id_aliases.add(candidate)
|
||||||
|
|
||||||
@ -1062,8 +1090,6 @@ class SignalChannel(BaseChannel):
|
|||||||
"""Return True when an identifier refers to the bot account."""
|
"""Return True when an identifier refers to the bot account."""
|
||||||
if not value:
|
if not value:
|
||||||
return False
|
return False
|
||||||
if not isinstance(value, str):
|
|
||||||
return False
|
|
||||||
return any(
|
return any(
|
||||||
candidate in self._account_id_aliases for candidate in self._normalize_signal_id(value)
|
candidate in self._account_id_aliases for candidate in self._normalize_signal_id(value)
|
||||||
)
|
)
|
||||||
@ -1097,13 +1123,14 @@ class SignalChannel(BaseChannel):
|
|||||||
return sender_parts[0] if sender_parts else ""
|
return sender_parts[0] if sender_parts else ""
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _extract_group_id(group_info: Any, group_v2: Any) -> str | None:
|
def _extract_group_id(group_info: object, group_v2: object) -> str | None:
|
||||||
"""Extract group ID from groupInfo/groupV2 payloads across signal-cli variants."""
|
"""Extract group ID from groupInfo/groupV2 payloads across signal-cli variants."""
|
||||||
for group_obj in (group_info, group_v2):
|
for group_obj in (group_info, group_v2):
|
||||||
if not isinstance(group_obj, dict):
|
if not isinstance(group_obj, dict):
|
||||||
continue
|
continue
|
||||||
|
group = cast(dict[str, Any], group_obj)
|
||||||
for key in ("groupId", "id", "groupID"):
|
for key in ("groupId", "id", "groupID"):
|
||||||
value = group_obj.get(key)
|
value = group.get(key)
|
||||||
if isinstance(value, str) and value:
|
if isinstance(value, str) and value:
|
||||||
return value
|
return value
|
||||||
return None
|
return None
|
||||||
@ -1113,18 +1140,19 @@ class SignalChannel(BaseChannel):
|
|||||||
"""Extract possible identifier fields from a mention payload."""
|
"""Extract possible identifier fields from a mention payload."""
|
||||||
ids: list[str] = []
|
ids: list[str] = []
|
||||||
|
|
||||||
def _walk(value: dict[str, Any] | Any, depth: int = 0) -> None:
|
def _walk(value: object, depth: int = 0) -> None:
|
||||||
if depth > 2:
|
if depth > 2:
|
||||||
return
|
return
|
||||||
if not isinstance(value, dict):
|
if not isinstance(value, dict):
|
||||||
return
|
return
|
||||||
for key, child in value.items():
|
object_value = cast(dict[str, Any], value)
|
||||||
key_lower = str(key).lower()
|
for key, child in object_value.items():
|
||||||
|
key_lower = key.lower()
|
||||||
if isinstance(child, str) and child:
|
if isinstance(child, str) and child:
|
||||||
if any(token in key_lower for token in ("number", "uuid", "serviceid", "aci")):
|
if any(token in key_lower for token in ("number", "uuid", "serviceid", "aci")):
|
||||||
ids.append(child)
|
ids.append(child)
|
||||||
elif isinstance(child, dict):
|
elif isinstance(child, dict):
|
||||||
_walk(child, depth + 1)
|
_walk(cast(object, child), depth + 1)
|
||||||
|
|
||||||
_walk(mention)
|
_walk(mention)
|
||||||
return list(dict.fromkeys(ids))
|
return list(dict.fromkeys(ids))
|
||||||
@ -1187,8 +1215,6 @@ class SignalChannel(BaseChannel):
|
|||||||
|
|
||||||
# If mention is required, check if bot was mentioned.
|
# If mention is required, check if bot was mentioned.
|
||||||
for mention in mentions:
|
for mention in mentions:
|
||||||
if not isinstance(mention, dict):
|
|
||||||
continue
|
|
||||||
for mention_id in self._mention_id_candidates(mention):
|
for mention_id in self._mention_id_candidates(mention):
|
||||||
if self._id_matches_account(mention_id):
|
if self._id_matches_account(mention_id):
|
||||||
return True
|
return True
|
||||||
@ -1197,15 +1223,13 @@ class SignalChannel(BaseChannel):
|
|||||||
# (for handle-style mentions). Accept a leading identifier-less mention
|
# (for handle-style mentions). Accept a leading identifier-less mention
|
||||||
# as a mention of the bot to avoid false negatives.
|
# as a mention of the bot to avoid false negatives.
|
||||||
for mention in mentions:
|
for mention in mentions:
|
||||||
if not isinstance(mention, dict):
|
|
||||||
continue
|
|
||||||
if self._mention_id_candidates(mention):
|
if self._mention_id_candidates(mention):
|
||||||
continue
|
continue
|
||||||
span = self._mention_span(mention)
|
span = self._mention_span(mention)
|
||||||
if not span:
|
if not span:
|
||||||
continue
|
continue
|
||||||
start, _ = span
|
start, _ = span
|
||||||
if message_text is not None and not message_text[:start].strip():
|
if not message_text[:start].strip():
|
||||||
self.logger.debug("Accepting identifier-less leading mention as bot mention")
|
self.logger.debug("Accepting identifier-less leading mention as bot mention")
|
||||||
return True
|
return True
|
||||||
|
|
||||||
@ -1241,10 +1265,8 @@ class SignalChannel(BaseChannel):
|
|||||||
return text
|
return text
|
||||||
|
|
||||||
# Build a list of (start, length) tuples for our bot's mentions
|
# Build a list of (start, length) tuples for our bot's mentions
|
||||||
bot_mentions = []
|
bot_mentions: list[tuple[int, int]] = []
|
||||||
for mention in mentions:
|
for mention in mentions:
|
||||||
if not isinstance(mention, dict):
|
|
||||||
continue
|
|
||||||
mention_ids = self._mention_id_candidates(mention)
|
mention_ids = self._mention_id_candidates(mention)
|
||||||
span = self._mention_span(mention)
|
span = self._mention_span(mention)
|
||||||
if not span:
|
if not span:
|
||||||
@ -1382,7 +1404,7 @@ class SignalChannel(BaseChannel):
|
|||||||
request_id = self._request_id
|
request_id = self._request_id
|
||||||
|
|
||||||
# Build JSON-RPC request
|
# Build JSON-RPC request
|
||||||
request = {"jsonrpc": "2.0", "method": method, "id": request_id}
|
request: dict[str, Any] = {"jsonrpc": "2.0", "method": method, "id": request_id}
|
||||||
|
|
||||||
if params:
|
if params:
|
||||||
request["params"] = params
|
request["params"] = params
|
||||||
@ -1397,7 +1419,10 @@ class SignalChannel(BaseChannel):
|
|||||||
try:
|
try:
|
||||||
response = await self._http.post("/api/v1/rpc", json=request)
|
response = await self._http.post("/api/v1/rpc", json=request)
|
||||||
response.raise_for_status()
|
response.raise_for_status()
|
||||||
return response.json()
|
response_json = _as_json_object(response.json())
|
||||||
|
if response_json is None:
|
||||||
|
return {"error": {"message": "signal-cli returned a non-object JSON-RPC response"}}
|
||||||
|
return response_json
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
self.logger.error("HTTP request failed: {}", e)
|
self.logger.error("HTTP request failed: {}", e)
|
||||||
return {"error": {"message": str(e)}}
|
return {"error": {"message": str(e)}}
|
||||||
|
|||||||
@ -3,15 +3,16 @@
|
|||||||
import asyncio
|
import asyncio
|
||||||
import re
|
import re
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any
|
from typing import Any, Protocol, cast
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
from pydantic import Field
|
from pydantic import Field
|
||||||
|
from slack_sdk.socket_mode.async_client import AsyncBaseSocketModeClient
|
||||||
from slack_sdk.socket_mode.request import SocketModeRequest
|
from slack_sdk.socket_mode.request import SocketModeRequest
|
||||||
from slack_sdk.socket_mode.response import SocketModeResponse
|
from slack_sdk.socket_mode.response import SocketModeResponse
|
||||||
from slack_sdk.socket_mode.websockets import SocketModeClient
|
from slack_sdk.socket_mode.websockets import SocketModeClient
|
||||||
from slack_sdk.web.async_client import AsyncWebClient
|
from slack_sdk.web.async_client import AsyncWebClient
|
||||||
from slackify_markdown import slackify_markdown
|
from slackify_markdown import slackify_markdown # pyright: ignore[reportMissingTypeStubs]
|
||||||
|
|
||||||
from nanobot.bus.events import OutboundMessage
|
from nanobot.bus.events import OutboundMessage
|
||||||
from nanobot.bus.outbound_events import ProgressEvent
|
from nanobot.bus.outbound_events import ProgressEvent
|
||||||
@ -23,6 +24,30 @@ from nanobot.pairing import is_approved
|
|||||||
from nanobot.utils.helpers import safe_filename, split_message
|
from nanobot.utils.helpers import safe_filename, split_message
|
||||||
|
|
||||||
|
|
||||||
|
def _as_json_object(value: Any) -> dict[str, Any] | None:
|
||||||
|
"""Narrow Slack's untyped Socket Mode payloads at the boundary."""
|
||||||
|
return cast(dict[str, Any], value) if isinstance(value, dict) else None
|
||||||
|
|
||||||
|
|
||||||
|
def _as_json_list(value: Any) -> list[Any] | None:
|
||||||
|
"""Narrow Slack's untyped Socket Mode arrays at the boundary."""
|
||||||
|
return cast(list[Any], value) if isinstance(value, list) else None
|
||||||
|
|
||||||
|
|
||||||
|
class _SlackWebAPI(Protocol):
|
||||||
|
"""Subset of slack-sdk's dynamically typed Web API used by this channel."""
|
||||||
|
|
||||||
|
async def auth_test(self, **kwargs: Any) -> Any: ...
|
||||||
|
async def chat_postMessage(self, **kwargs: Any) -> Any: ... # noqa: N802
|
||||||
|
async def conversations_list(self, **kwargs: Any) -> Any: ...
|
||||||
|
async def conversations_open(self, **kwargs: Any) -> Any: ...
|
||||||
|
async def conversations_replies(self, **kwargs: Any) -> Any: ...
|
||||||
|
async def files_upload_v2(self, **kwargs: Any) -> Any: ...
|
||||||
|
async def reactions_add(self, **kwargs: Any) -> Any: ...
|
||||||
|
async def reactions_remove(self, **kwargs: Any) -> Any: ...
|
||||||
|
async def users_list(self, **kwargs: Any) -> Any: ...
|
||||||
|
|
||||||
|
|
||||||
class SlackDMConfig(Base):
|
class SlackDMConfig(Base):
|
||||||
"""Slack DM policy configuration."""
|
"""Slack DM policy configuration."""
|
||||||
|
|
||||||
@ -90,6 +115,13 @@ class SlackChannel(BaseChannel):
|
|||||||
self._target_cache: dict[str, str] = {}
|
self._target_cache: dict[str, str] = {}
|
||||||
self._thread_context_attempted: set[str] = set()
|
self._thread_context_attempted: set[str] = set()
|
||||||
|
|
||||||
|
def _require_web_api(self) -> _SlackWebAPI:
|
||||||
|
if self._web_client is None:
|
||||||
|
raise RuntimeError("Slack Web API client is not started")
|
||||||
|
# slack-sdk's public methods are runtime-stable but its annotations do
|
||||||
|
# not expose a useful shared interface, so narrow once at the SDK edge.
|
||||||
|
return cast(_SlackWebAPI, self._web_client)
|
||||||
|
|
||||||
async def start(self) -> None:
|
async def start(self) -> None:
|
||||||
"""Start the Slack Socket Mode client."""
|
"""Start the Slack Socket Mode client."""
|
||||||
if not self.config.bot_token or not self.config.app_token:
|
if not self.config.bot_token or not self.config.app_token:
|
||||||
@ -111,7 +143,8 @@ class SlackChannel(BaseChannel):
|
|||||||
|
|
||||||
# Resolve bot user ID for mention handling
|
# Resolve bot user ID for mention handling
|
||||||
try:
|
try:
|
||||||
auth = await self._web_client.auth_test()
|
web_api = self._require_web_api()
|
||||||
|
auth = await web_api.auth_test()
|
||||||
self._bot_user_id = auth.get("user_id")
|
self._bot_user_id = auth.get("user_id")
|
||||||
self.logger.info("bot connected as {}", self._bot_user_id)
|
self.logger.info("bot connected as {}", self._bot_user_id)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@ -155,10 +188,17 @@ class SlackChannel(BaseChannel):
|
|||||||
self.logger.warning("client not running")
|
self.logger.warning("client not running")
|
||||||
return
|
return
|
||||||
try:
|
try:
|
||||||
|
web_api = self._require_web_api()
|
||||||
target_chat_id = await self._resolve_target_chat_id(msg.chat_id)
|
target_chat_id = await self._resolve_target_chat_id(msg.chat_id)
|
||||||
slack_meta = msg.metadata.get("slack", {}) if msg.metadata else {}
|
raw_slack_meta: Any = msg.metadata.get("slack", {}) if msg.metadata else {}
|
||||||
|
slack_meta: dict[str, Any] = (
|
||||||
|
cast(dict[str, Any], raw_slack_meta)
|
||||||
|
if isinstance(raw_slack_meta, dict)
|
||||||
|
else {}
|
||||||
|
)
|
||||||
thread_ts = slack_meta.get("thread_ts")
|
thread_ts = slack_meta.get("thread_ts")
|
||||||
origin_chat_id = str((slack_meta.get("event", {}) or {}).get("channel") or msg.chat_id)
|
event_meta = cast(dict[str, Any], slack_meta.get("event", {}) or {})
|
||||||
|
origin_chat_id = str(event_meta.get("channel") or msg.chat_id)
|
||||||
# Reply in the same thread the inbound message belongs to (works
|
# Reply in the same thread the inbound message belongs to (works
|
||||||
# for both real channel threads and DM threads). When the agent
|
# for both real channel threads and DM threads). When the agent
|
||||||
# is forwarding to a different channel, drop thread_ts because it
|
# is forwarding to a different channel, drop thread_ts because it
|
||||||
@ -170,7 +210,20 @@ class SlackChannel(BaseChannel):
|
|||||||
pass # skip empty progress messages (e.g. tool-event-only updates)
|
pass # skip empty progress messages (e.g. tool-event-only updates)
|
||||||
elif msg.content or not (msg.media or []):
|
elif msg.content or not (msg.media or []):
|
||||||
mrkdwn = self._to_mrkdwn(msg.content) if msg.content else " "
|
mrkdwn = self._to_mrkdwn(msg.content) if msg.content else " "
|
||||||
buttons = getattr(msg, "buttons", None) or []
|
raw_buttons = getattr(msg, "buttons", None)
|
||||||
|
buttons: list[list[str]] = (
|
||||||
|
cast(list[list[str]], raw_buttons)
|
||||||
|
if isinstance(raw_buttons, list)
|
||||||
|
and all(
|
||||||
|
isinstance(row, list)
|
||||||
|
and all(
|
||||||
|
isinstance(label, str)
|
||||||
|
for label in cast(list[object], row)
|
||||||
|
)
|
||||||
|
for row in cast(list[object], raw_buttons)
|
||||||
|
)
|
||||||
|
else []
|
||||||
|
)
|
||||||
chunks = split_message(mrkdwn, SLACK_MAX_MESSAGE_LEN)
|
chunks = split_message(mrkdwn, SLACK_MAX_MESSAGE_LEN)
|
||||||
for index, chunk in enumerate(chunks):
|
for index, chunk in enumerate(chunks):
|
||||||
kwargs: dict[str, Any] = dict(
|
kwargs: dict[str, Any] = dict(
|
||||||
@ -178,11 +231,11 @@ class SlackChannel(BaseChannel):
|
|||||||
)
|
)
|
||||||
if buttons and index == len(chunks) - 1:
|
if buttons and index == len(chunks) - 1:
|
||||||
kwargs["blocks"] = self._build_button_blocks(chunk, buttons)
|
kwargs["blocks"] = self._build_button_blocks(chunk, buttons)
|
||||||
await self._web_client.chat_postMessage(**kwargs)
|
await web_api.chat_postMessage(**kwargs)
|
||||||
|
|
||||||
for media_path in msg.media or []:
|
for media_path in msg.media or []:
|
||||||
try:
|
try:
|
||||||
await self._web_client.files_upload_v2(
|
await web_api.files_upload_v2(
|
||||||
channel=target_chat_id,
|
channel=target_chat_id,
|
||||||
file=media_path,
|
file=media_path,
|
||||||
thread_ts=thread_ts_param,
|
thread_ts=thread_ts_param,
|
||||||
@ -192,8 +245,16 @@ class SlackChannel(BaseChannel):
|
|||||||
|
|
||||||
# Update reaction emoji when the final (non-progress) response is sent
|
# Update reaction emoji when the final (non-progress) response is sent
|
||||||
if not is_progress:
|
if not is_progress:
|
||||||
event = slack_meta.get("event", {})
|
raw_event = slack_meta.get("event", {})
|
||||||
await self._update_react_emoji(origin_chat_id, event.get("ts"))
|
event = (
|
||||||
|
cast(dict[str, Any], raw_event)
|
||||||
|
if isinstance(raw_event, dict)
|
||||||
|
else {}
|
||||||
|
)
|
||||||
|
await self._update_react_emoji(
|
||||||
|
origin_chat_id,
|
||||||
|
cast(str | None, event.get("ts")),
|
||||||
|
)
|
||||||
|
|
||||||
except Exception:
|
except Exception:
|
||||||
self.logger.exception("Error sending message")
|
self.logger.exception("Error sending message")
|
||||||
@ -237,20 +298,26 @@ class SlackChannel(BaseChannel):
|
|||||||
return self._target_cache[cache_key]
|
return self._target_cache[cache_key]
|
||||||
|
|
||||||
cursor: str | None = None
|
cursor: str | None = None
|
||||||
|
web_api = self._require_web_api()
|
||||||
while True:
|
while True:
|
||||||
response = await self._web_client.conversations_list(
|
response = cast(dict[str, Any], await web_api.conversations_list(
|
||||||
types="public_channel,private_channel",
|
types="public_channel,private_channel",
|
||||||
exclude_archived=True,
|
exclude_archived=True,
|
||||||
limit=200,
|
limit=200,
|
||||||
cursor=cursor,
|
cursor=cursor,
|
||||||
)
|
))
|
||||||
for channel in response.get("channels", []):
|
for channel_value in cast(list[object], response.get("channels", [])):
|
||||||
|
channel = cast(dict[str, Any], channel_value)
|
||||||
if self._normalize_target_name(str(channel.get("name") or "")) == normalized:
|
if self._normalize_target_name(str(channel.get("name") or "")) == normalized:
|
||||||
channel_id = str(channel.get("id") or "")
|
channel_id = str(channel.get("id") or "")
|
||||||
if channel_id:
|
if channel_id:
|
||||||
self._target_cache[cache_key] = channel_id
|
self._target_cache[cache_key] = channel_id
|
||||||
return channel_id
|
return channel_id
|
||||||
cursor = ((response.get("response_metadata") or {}).get("next_cursor") or "").strip()
|
response_metadata = cast(
|
||||||
|
dict[str, Any],
|
||||||
|
response.get("response_metadata") or {},
|
||||||
|
)
|
||||||
|
cursor = str(response_metadata.get("next_cursor") or "").strip()
|
||||||
if not cursor:
|
if not cursor:
|
||||||
break
|
break
|
||||||
|
|
||||||
@ -269,9 +336,14 @@ class SlackChannel(BaseChannel):
|
|||||||
return self._target_cache[cache_key]
|
return self._target_cache[cache_key]
|
||||||
|
|
||||||
cursor: str | None = None
|
cursor: str | None = None
|
||||||
|
web_api = self._require_web_api()
|
||||||
while True:
|
while True:
|
||||||
response = await self._web_client.users_list(limit=200, cursor=cursor)
|
response = cast(
|
||||||
for member in response.get("members", []):
|
dict[str, Any],
|
||||||
|
await web_api.users_list(limit=200, cursor=cursor),
|
||||||
|
)
|
||||||
|
for member_value in cast(list[object], response.get("members", [])):
|
||||||
|
member = cast(dict[str, Any], member_value)
|
||||||
if self._member_matches_handle(member, normalized):
|
if self._member_matches_handle(member, normalized):
|
||||||
user_id = str(member.get("id") or "")
|
user_id = str(member.get("id") or "")
|
||||||
if not user_id:
|
if not user_id:
|
||||||
@ -279,7 +351,11 @@ class SlackChannel(BaseChannel):
|
|||||||
dm_id = await self._open_dm_for_user(user_id)
|
dm_id = await self._open_dm_for_user(user_id)
|
||||||
self._target_cache[cache_key] = dm_id
|
self._target_cache[cache_key] = dm_id
|
||||||
return dm_id
|
return dm_id
|
||||||
cursor = ((response.get("response_metadata") or {}).get("next_cursor") or "").strip()
|
response_metadata = cast(
|
||||||
|
dict[str, Any],
|
||||||
|
response.get("response_metadata") or {},
|
||||||
|
)
|
||||||
|
cursor = str(response_metadata.get("next_cursor") or "").strip()
|
||||||
if not cursor:
|
if not cursor:
|
||||||
break
|
break
|
||||||
|
|
||||||
@ -288,8 +364,13 @@ class SlackChannel(BaseChannel):
|
|||||||
)
|
)
|
||||||
|
|
||||||
async def _open_dm_for_user(self, user_id: str) -> str:
|
async def _open_dm_for_user(self, user_id: str) -> str:
|
||||||
response = await self._web_client.conversations_open(users=user_id)
|
web_api = self._require_web_api()
|
||||||
channel_id = str(((response.get("channel") or {}).get("id")) or "")
|
response = cast(
|
||||||
|
dict[str, Any],
|
||||||
|
await web_api.conversations_open(users=user_id),
|
||||||
|
)
|
||||||
|
channel = cast(dict[str, Any], response.get("channel") or {})
|
||||||
|
channel_id = str(channel.get("id") or "")
|
||||||
if not channel_id:
|
if not channel_id:
|
||||||
raise ValueError(f"Slack DM target for user '{user_id}' could not be opened.")
|
raise ValueError(f"Slack DM target for user '{user_id}' could not be opened.")
|
||||||
return channel_id
|
return channel_id
|
||||||
@ -300,7 +381,7 @@ class SlackChannel(BaseChannel):
|
|||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def _member_matches_handle(cls, member: dict[str, Any], normalized: str) -> bool:
|
def _member_matches_handle(cls, member: dict[str, Any], normalized: str) -> bool:
|
||||||
profile = member.get("profile") or {}
|
profile = cast(dict[str, Any], member.get("profile") or {})
|
||||||
candidates = {
|
candidates = {
|
||||||
str(member.get("name") or ""),
|
str(member.get("name") or ""),
|
||||||
str(profile.get("display_name") or ""),
|
str(profile.get("display_name") or ""),
|
||||||
@ -312,7 +393,7 @@ class SlackChannel(BaseChannel):
|
|||||||
|
|
||||||
async def _on_socket_request(
|
async def _on_socket_request(
|
||||||
self,
|
self,
|
||||||
client: SocketModeClient,
|
client: AsyncBaseSocketModeClient,
|
||||||
req: SocketModeRequest,
|
req: SocketModeRequest,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Handle incoming Socket Mode requests."""
|
"""Handle incoming Socket Mode requests."""
|
||||||
@ -327,8 +408,8 @@ class SlackChannel(BaseChannel):
|
|||||||
SocketModeResponse(envelope_id=req.envelope_id)
|
SocketModeResponse(envelope_id=req.envelope_id)
|
||||||
)
|
)
|
||||||
|
|
||||||
payload = req.payload or {}
|
payload = _as_json_object(cast(Any, req).payload) or {}
|
||||||
event = payload.get("event") or {}
|
event = _as_json_object(payload.get("event")) or {}
|
||||||
event_type = event.get("type")
|
event_type = event.get("type")
|
||||||
|
|
||||||
# Handle app mentions or plain messages
|
# Handle app mentions or plain messages
|
||||||
@ -349,6 +430,8 @@ class SlackChannel(BaseChannel):
|
|||||||
# Avoid double-processing: Slack sends both `message` and `app_mention`
|
# Avoid double-processing: Slack sends both `message` and `app_mention`
|
||||||
# for mentions in channels. Prefer `app_mention`.
|
# for mentions in channels. Prefer `app_mention`.
|
||||||
text = event.get("text") or ""
|
text = event.get("text") or ""
|
||||||
|
if not isinstance(text, str):
|
||||||
|
return
|
||||||
if event_type == "message" and self._bot_user_id and f"<@{self._bot_user_id}>" in text:
|
if event_type == "message" and self._bot_user_id and f"<@{self._bot_user_id}>" in text:
|
||||||
return
|
return
|
||||||
|
|
||||||
@ -362,10 +445,12 @@ class SlackChannel(BaseChannel):
|
|||||||
event.get("channel_type"),
|
event.get("channel_type"),
|
||||||
text[:80],
|
text[:80],
|
||||||
)
|
)
|
||||||
if not sender_id or not chat_id:
|
if not isinstance(sender_id, str) or not sender_id or not isinstance(chat_id, str) or not chat_id:
|
||||||
return
|
return
|
||||||
|
|
||||||
channel_type = event.get("channel_type") or ""
|
channel_type = event.get("channel_type") or ""
|
||||||
|
if not isinstance(channel_type, str):
|
||||||
|
channel_type = ""
|
||||||
|
|
||||||
if not self._is_allowed(sender_id, chat_id, channel_type):
|
if not self._is_allowed(sender_id, chat_id, channel_type):
|
||||||
if channel_type == "im" and self.config.dm.enabled:
|
if channel_type == "im" and self.config.dm.enabled:
|
||||||
@ -383,7 +468,9 @@ class SlackChannel(BaseChannel):
|
|||||||
text = self._strip_bot_mention(text)
|
text = self._strip_bot_mention(text)
|
||||||
|
|
||||||
event_ts = event.get("ts")
|
event_ts = event.get("ts")
|
||||||
|
event_ts = event_ts if isinstance(event_ts, str) else None
|
||||||
raw_thread_ts = event.get("thread_ts")
|
raw_thread_ts = event.get("thread_ts")
|
||||||
|
raw_thread_ts = raw_thread_ts if isinstance(raw_thread_ts, str) else None
|
||||||
thread_ts = raw_thread_ts
|
thread_ts = raw_thread_ts
|
||||||
# In DMs we don't auto-open a thread on top-level messages (it would
|
# In DMs we don't auto-open a thread on top-level messages (it would
|
||||||
# bury replies under "1 reply"). But if the user explicitly opened a
|
# bury replies under "1 reply"). But if the user explicitly opened a
|
||||||
@ -396,11 +483,12 @@ class SlackChannel(BaseChannel):
|
|||||||
thread_ts = event_ts
|
thread_ts = event_ts
|
||||||
# Add :eyes: reaction to the triggering message (best-effort)
|
# Add :eyes: reaction to the triggering message (best-effort)
|
||||||
try:
|
try:
|
||||||
if self._web_client and event.get("ts"):
|
if self._web_client and event_ts:
|
||||||
await self._web_client.reactions_add(
|
web_api = self._require_web_api()
|
||||||
|
await web_api.reactions_add(
|
||||||
channel=chat_id,
|
channel=chat_id,
|
||||||
name=self.config.react_emoji,
|
name=self.config.react_emoji,
|
||||||
timestamp=event.get("ts"),
|
timestamp=event_ts,
|
||||||
)
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
self.logger.debug("reactions_add failed: {}", e)
|
self.logger.debug("reactions_add failed: {}", e)
|
||||||
@ -413,10 +501,11 @@ class SlackChannel(BaseChannel):
|
|||||||
)
|
)
|
||||||
media_paths: list[str] = []
|
media_paths: list[str] = []
|
||||||
file_markers: list[str] = []
|
file_markers: list[str] = []
|
||||||
for file_info in event.get("files") or []:
|
for file_info in _as_json_list(event.get("files")) or []:
|
||||||
if not isinstance(file_info, dict):
|
file_info_object = _as_json_object(file_info)
|
||||||
|
if file_info_object is None:
|
||||||
continue
|
continue
|
||||||
file_path, marker = await self._download_slack_file(file_info)
|
file_path, marker = await self._download_slack_file(file_info_object)
|
||||||
if file_path:
|
if file_path:
|
||||||
media_paths.append(file_path)
|
media_paths.append(file_path)
|
||||||
if marker:
|
if marker:
|
||||||
@ -503,22 +592,30 @@ class SlackChannel(BaseChannel):
|
|||||||
preview = response.content[:256].lstrip().lower()
|
preview = response.content[:256].lstrip().lower()
|
||||||
return preview.startswith(_HTML_DOWNLOAD_PREFIXES)
|
return preview.startswith(_HTML_DOWNLOAD_PREFIXES)
|
||||||
|
|
||||||
async def _on_block_action(self, client: SocketModeClient, req: SocketModeRequest) -> None:
|
async def _on_block_action(
|
||||||
|
self,
|
||||||
|
client: AsyncBaseSocketModeClient,
|
||||||
|
req: SocketModeRequest,
|
||||||
|
) -> None:
|
||||||
"""Handle button clicks from inline action buttons."""
|
"""Handle button clicks from inline action buttons."""
|
||||||
await client.send_socket_mode_response(SocketModeResponse(envelope_id=req.envelope_id))
|
await client.send_socket_mode_response(SocketModeResponse(envelope_id=req.envelope_id))
|
||||||
payload = req.payload or {}
|
payload = cast(dict[str, Any], cast(Any, req).payload or {})
|
||||||
actions = payload.get("actions") or []
|
actions = cast(list[Any], payload.get("actions") or [])
|
||||||
if not actions:
|
if not actions:
|
||||||
return
|
return
|
||||||
value = str(actions[0].get("value") or "")
|
action = cast(dict[str, Any], actions[0])
|
||||||
user_info = payload.get("user") or {}
|
value = str(action.get("value") or "")
|
||||||
|
user_info = cast(dict[str, Any], payload.get("user") or {})
|
||||||
sender_id = str(user_info.get("id") or "")
|
sender_id = str(user_info.get("id") or "")
|
||||||
channel_info = payload.get("channel") or {}
|
channel_info = cast(dict[str, Any], payload.get("channel") or {})
|
||||||
chat_id = str(channel_info.get("id") or "")
|
chat_id = str(channel_info.get("id") or "")
|
||||||
if not sender_id or not chat_id or not value:
|
if not sender_id or not chat_id or not value:
|
||||||
return
|
return
|
||||||
message_info = payload.get("message") or {}
|
message_info = cast(dict[str, Any], payload.get("message") or {})
|
||||||
thread_ts = message_info.get("thread_ts") or message_info.get("ts")
|
thread_ts = cast(
|
||||||
|
str | None,
|
||||||
|
message_info.get("thread_ts") or message_info.get("ts"),
|
||||||
|
)
|
||||||
channel_type = self._infer_channel_type(chat_id)
|
channel_type = self._infer_channel_type(chat_id)
|
||||||
if not self._is_allowed(sender_id, chat_id, channel_type):
|
if not self._is_allowed(sender_id, chat_id, channel_type):
|
||||||
return
|
return
|
||||||
@ -563,17 +660,18 @@ class SlackChannel(BaseChannel):
|
|||||||
self._thread_context_attempted.add(key)
|
self._thread_context_attempted.add(key)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
response = await self._web_client.conversations_replies(
|
web_api = self._require_web_api()
|
||||||
|
response = cast(dict[str, Any], await web_api.conversations_replies(
|
||||||
channel=chat_id,
|
channel=chat_id,
|
||||||
ts=thread_ts,
|
ts=thread_ts,
|
||||||
limit=max(1, self.config.thread_context_limit),
|
limit=max(1, self.config.thread_context_limit),
|
||||||
)
|
))
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
self.logger.warning("thread context unavailable for {}: {}", key, e)
|
self.logger.warning("thread context unavailable for {}: {}", key, e)
|
||||||
return text
|
return text
|
||||||
|
|
||||||
lines = self._format_thread_context(
|
lines = self._format_thread_context(
|
||||||
response.get("messages", []),
|
cast(list[dict[str, Any]], response.get("messages", [])),
|
||||||
current_ts=current_ts,
|
current_ts=current_ts,
|
||||||
)
|
)
|
||||||
if not lines:
|
if not lines:
|
||||||
@ -605,7 +703,7 @@ class SlackChannel(BaseChannel):
|
|||||||
blocks: list[dict[str, Any]] = [
|
blocks: list[dict[str, Any]] = [
|
||||||
{"type": "section", "text": {"type": "mrkdwn", "text": text[:3000]}},
|
{"type": "section", "text": {"type": "mrkdwn", "text": text[:3000]}},
|
||||||
]
|
]
|
||||||
elements = []
|
elements: list[dict[str, Any]] = []
|
||||||
for row in buttons:
|
for row in buttons:
|
||||||
for label in row:
|
for label in row:
|
||||||
elements.append({
|
elements.append({
|
||||||
@ -622,8 +720,9 @@ class SlackChannel(BaseChannel):
|
|||||||
"""Remove the in-progress reaction and optionally add a done reaction."""
|
"""Remove the in-progress reaction and optionally add a done reaction."""
|
||||||
if not self._web_client or not ts:
|
if not self._web_client or not ts:
|
||||||
return
|
return
|
||||||
|
web_api = self._require_web_api()
|
||||||
try:
|
try:
|
||||||
await self._web_client.reactions_remove(
|
await web_api.reactions_remove(
|
||||||
channel=chat_id,
|
channel=chat_id,
|
||||||
name=self.config.react_emoji,
|
name=self.config.react_emoji,
|
||||||
timestamp=ts,
|
timestamp=ts,
|
||||||
@ -632,7 +731,7 @@ class SlackChannel(BaseChannel):
|
|||||||
self.logger.debug("reactions_remove failed: {}", e)
|
self.logger.debug("reactions_remove failed: {}", e)
|
||||||
if self.config.done_emoji:
|
if self.config.done_emoji:
|
||||||
try:
|
try:
|
||||||
await self._web_client.reactions_add(
|
await web_api.reactions_add(
|
||||||
channel=chat_id,
|
channel=chat_id,
|
||||||
name=self.config.done_emoji,
|
name=self.config.done_emoji,
|
||||||
timestamp=ts,
|
timestamp=ts,
|
||||||
@ -703,7 +802,7 @@ class SlackChannel(BaseChannel):
|
|||||||
return ""
|
return ""
|
||||||
code_blocks: list[str] = []
|
code_blocks: list[str] = []
|
||||||
|
|
||||||
def _save_fence(m: re.Match) -> str:
|
def _save_fence(m: re.Match[str]) -> str:
|
||||||
code_blocks.append(m.group(0))
|
code_blocks.append(m.group(0))
|
||||||
return f"\x00CB{len(code_blocks) - 1}\x00"
|
return f"\x00CB{len(code_blocks) - 1}\x00"
|
||||||
|
|
||||||
@ -718,7 +817,7 @@ class SlackChannel(BaseChannel):
|
|||||||
"""Fix markdown artifacts that slackify_markdown misses."""
|
"""Fix markdown artifacts that slackify_markdown misses."""
|
||||||
code_blocks: list[str] = []
|
code_blocks: list[str] = []
|
||||||
|
|
||||||
def _save_code(m: re.Match) -> str:
|
def _save_code(m: re.Match[str]) -> str:
|
||||||
code_blocks.append(m.group(0))
|
code_blocks.append(m.group(0))
|
||||||
return f"\x00CB{len(code_blocks) - 1}\x00"
|
return f"\x00CB{len(code_blocks) - 1}\x00"
|
||||||
|
|
||||||
@ -726,14 +825,17 @@ class SlackChannel(BaseChannel):
|
|||||||
text = cls._INLINE_CODE_RE.sub(_save_code, text)
|
text = cls._INLINE_CODE_RE.sub(_save_code, text)
|
||||||
text = cls._LEFTOVER_BOLD_RE.sub(r"*\1*", text)
|
text = cls._LEFTOVER_BOLD_RE.sub(r"*\1*", text)
|
||||||
text = cls._LEFTOVER_HEADER_RE.sub(r"*\1*", text)
|
text = cls._LEFTOVER_HEADER_RE.sub(r"*\1*", text)
|
||||||
text = cls._BARE_URL_RE.sub(lambda m: m.group(0).replace("&", "&"), text)
|
text = cls._BARE_URL_RE.sub(
|
||||||
|
lambda m: m.group(0).replace("&", "&"),
|
||||||
|
text,
|
||||||
|
)
|
||||||
|
|
||||||
for i, block in enumerate(code_blocks):
|
for i, block in enumerate(code_blocks):
|
||||||
text = text.replace(f"\x00CB{i}\x00", block)
|
text = text.replace(f"\x00CB{i}\x00", block)
|
||||||
return text
|
return text
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _convert_table(match: re.Match) -> str:
|
def _convert_table(match: re.Match[str]) -> str:
|
||||||
"""Convert a Markdown table to a Slack-readable list."""
|
"""Convert a Markdown table to a Slack-readable list."""
|
||||||
lines = [ln.strip() for ln in match.group(0).strip().splitlines() if ln.strip()]
|
lines = [ln.strip() for ln in match.group(0).strip().splitlines() if ln.strip()]
|
||||||
if len(lines) < 2:
|
if len(lines) < 2:
|
||||||
|
|||||||
@ -8,8 +8,9 @@ import time
|
|||||||
import unicodedata
|
import unicodedata
|
||||||
from contextlib import suppress
|
from contextlib import suppress
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
|
from datetime import timedelta
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any, Literal
|
from typing import Any, Awaitable, Callable, Literal, TypeAlias, TypeVar, cast
|
||||||
from urllib.parse import urlparse
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
from pydantic import Field, field_validator, model_validator
|
from pydantic import Field, field_validator, model_validator
|
||||||
@ -17,9 +18,12 @@ from telegram import (
|
|||||||
BotCommand,
|
BotCommand,
|
||||||
InlineKeyboardButton,
|
InlineKeyboardButton,
|
||||||
InlineKeyboardMarkup,
|
InlineKeyboardMarkup,
|
||||||
|
Message,
|
||||||
|
MessageEntity,
|
||||||
ReactionTypeEmoji,
|
ReactionTypeEmoji,
|
||||||
ReplyParameters,
|
ReplyParameters,
|
||||||
Update,
|
Update,
|
||||||
|
User,
|
||||||
)
|
)
|
||||||
from telegram.error import BadRequest, NetworkError, TimedOut
|
from telegram.error import BadRequest, NetworkError, TimedOut
|
||||||
from telegram.ext import Application, CallbackQueryHandler, ContextTypes, MessageHandler, filters
|
from telegram.ext import Application, CallbackQueryHandler, ContextTypes, MessageHandler, filters
|
||||||
@ -43,6 +47,12 @@ TELEGRAM_MAX_MESSAGE_LEN = 4000 # Telegram message character limit
|
|||||||
TELEGRAM_HTML_MAX_LEN = 4096
|
TELEGRAM_HTML_MAX_LEN = 4096
|
||||||
TELEGRAM_REPLY_CONTEXT_MAX_LEN = TELEGRAM_MAX_MESSAGE_LEN # Max length for reply context in user message
|
TELEGRAM_REPLY_CONTEXT_MAX_LEN = TELEGRAM_MAX_MESSAGE_LEN # Max length for reply context in user message
|
||||||
|
|
||||||
|
# python-telegram-bot exposes a six-parameter Application generic. Nanobot
|
||||||
|
# doesn't customize its context/data/job-queue types, so keep that SDK boundary
|
||||||
|
# explicit rather than allowing unspecialized generics to spread Unknown.
|
||||||
|
TelegramApplication: TypeAlias = Application[Any, Any, Any, Any, Any, Any]
|
||||||
|
_T = TypeVar("_T")
|
||||||
|
|
||||||
|
|
||||||
def _split_telegram_markdown(content: str, max_len: int) -> list[str]:
|
def _split_telegram_markdown(content: str, max_len: int) -> list[str]:
|
||||||
"""Split raw Telegram Markdown without leaving fenced code blocks unbalanced."""
|
"""Split raw Telegram Markdown without leaving fenced code blocks unbalanced."""
|
||||||
@ -218,7 +228,7 @@ def _markdown_to_telegram_html(text: str) -> str:
|
|||||||
|
|
||||||
# 1. Extract and protect code blocks (preserve content from other processing)
|
# 1. Extract and protect code blocks (preserve content from other processing)
|
||||||
code_blocks: list[str] = []
|
code_blocks: list[str] = []
|
||||||
def save_code_block(m: re.Match) -> str:
|
def save_code_block(m: re.Match[str]) -> str:
|
||||||
code_blocks.append(m.group(1))
|
code_blocks.append(m.group(1))
|
||||||
return f"\x00CB{len(code_blocks) - 1}\x00"
|
return f"\x00CB{len(code_blocks) - 1}\x00"
|
||||||
|
|
||||||
@ -247,7 +257,7 @@ def _markdown_to_telegram_html(text: str) -> str:
|
|||||||
|
|
||||||
# 2. Extract and protect inline code
|
# 2. Extract and protect inline code
|
||||||
inline_codes: list[str] = []
|
inline_codes: list[str] = []
|
||||||
def save_inline_code(m: re.Match) -> str:
|
def save_inline_code(m: re.Match[str]) -> str:
|
||||||
inline_codes.append(m.group(1))
|
inline_codes.append(m.group(1))
|
||||||
return f"\x00IC{len(inline_codes) - 1}\x00"
|
return f"\x00IC{len(inline_codes) - 1}\x00"
|
||||||
|
|
||||||
@ -350,7 +360,7 @@ class _QueuedTelegramUpdate:
|
|||||||
|
|
||||||
kind: Literal["command", "message"]
|
kind: Literal["command", "message"]
|
||||||
update: Update
|
update: Update
|
||||||
context: Any
|
context: ContextTypes.DEFAULT_TYPE
|
||||||
sort_key: tuple[int, int]
|
sort_key: tuple[int, int]
|
||||||
|
|
||||||
|
|
||||||
@ -421,7 +431,7 @@ class TelegramChannel(BaseChannel):
|
|||||||
display_name = "Telegram"
|
display_name = "Telegram"
|
||||||
|
|
||||||
# Commands registered with Telegram's command menu
|
# Commands registered with Telegram's command menu
|
||||||
BOT_COMMANDS = [
|
BOT_COMMANDS: list[BotCommand] = [
|
||||||
BotCommand("start", "Start the bot"),
|
BotCommand("start", "Start the bot"),
|
||||||
BotCommand("new", "Start a new conversation"),
|
BotCommand("new", "Start a new conversation"),
|
||||||
BotCommand("stop", "Stop the current task"),
|
BotCommand("stop", "Stop the current task"),
|
||||||
@ -455,19 +465,24 @@ class TelegramChannel(BaseChannel):
|
|||||||
config = TelegramConfig.model_validate(config)
|
config = TelegramConfig.model_validate(config)
|
||||||
super().__init__(config, bus)
|
super().__init__(config, bus)
|
||||||
self.config: TelegramConfig = config
|
self.config: TelegramConfig = config
|
||||||
self._app: Application | None = None
|
self._app: TelegramApplication | None = None
|
||||||
self._chat_ids: dict[str, int] = {} # Map sender_id to chat_id for replies
|
self._chat_ids: dict[str, int] = {} # Map sender_id to chat_id for replies
|
||||||
self._typing_tasks: dict[str, asyncio.Task] = {} # chat_id -> typing loop task
|
self._typing_tasks: dict[str, asyncio.Task[None]] = {} # chat_id -> typing loop task
|
||||||
self._media_group_buffers: dict[str, dict] = {}
|
self._media_group_buffers: dict[str, dict[str, Any]] = {}
|
||||||
self._media_group_tasks: dict[str, asyncio.Task] = {}
|
self._media_group_tasks: dict[str, asyncio.Task[None]] = {}
|
||||||
self._message_threads: dict[tuple[str, int], int] = {}
|
self._message_threads: dict[tuple[str, int], int] = {}
|
||||||
self._bot_user_id: int | None = None
|
self._bot_user_id: int | None = None
|
||||||
self._bot_username: str | None = None
|
self._bot_username: str | None = None
|
||||||
self._stream_bufs: dict[str, _StreamBuf] = {} # chat_id -> streaming state
|
self._stream_bufs: dict[str, _StreamBuf] = {} # chat_id -> streaming state
|
||||||
self._inbound_buffers: dict[str, list[_QueuedTelegramUpdate]] = {}
|
self._inbound_buffers: dict[str, list[_QueuedTelegramUpdate]] = {}
|
||||||
self._inbound_workers: dict[str, asyncio.Task] = {}
|
self._inbound_workers: dict[str, asyncio.Task[None]] = {}
|
||||||
self._rich_send_disabled: bool = False # Latch off if Bot API < 10.1
|
self._rich_send_disabled: bool = False # Latch off if Bot API < 10.1
|
||||||
|
|
||||||
|
def _require_app(self) -> TelegramApplication:
|
||||||
|
if self._app is None:
|
||||||
|
raise RuntimeError("Telegram application is not started")
|
||||||
|
return self._app
|
||||||
|
|
||||||
def is_allowed(self, sender_id: str) -> bool:
|
def is_allowed(self, sender_id: str) -> bool:
|
||||||
"""Preserve Telegram's legacy id|username allowlist matching."""
|
"""Preserve Telegram's legacy id|username allowlist matching."""
|
||||||
if super().is_allowed(sender_id):
|
if super().is_allowed(sender_id):
|
||||||
@ -595,7 +610,7 @@ class TelegramChannel(BaseChannel):
|
|||||||
if self.config.mode == "webhook":
|
if self.config.mode == "webhook":
|
||||||
# ``url_path`` is the local HTTP route. ``webhook_url`` is the
|
# ``url_path`` is the local HTTP route. ``webhook_url`` is the
|
||||||
# public HTTPS URL Telegram calls; reverse proxies may rewrite it.
|
# public HTTPS URL Telegram calls; reverse proxies may rewrite it.
|
||||||
await self._app.updater.start_webhook(
|
await cast(Any, self._app.updater).start_webhook(
|
||||||
listen=self.config.webhook_listen_host,
|
listen=self.config.webhook_listen_host,
|
||||||
port=self.config.webhook_listen_port,
|
port=self.config.webhook_listen_port,
|
||||||
url_path=self.config.webhook_path.lstrip("/"),
|
url_path=self.config.webhook_path.lstrip("/"),
|
||||||
@ -607,7 +622,7 @@ class TelegramChannel(BaseChannel):
|
|||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
# Start polling (this runs until stopped)
|
# Start polling (this runs until stopped)
|
||||||
await self._app.updater.start_polling(
|
await cast(Any, self._app.updater).start_polling(
|
||||||
allowed_updates=allowed_updates,
|
allowed_updates=allowed_updates,
|
||||||
drop_pending_updates=False, # Process pending messages on startup
|
drop_pending_updates=False, # Process pending messages on startup
|
||||||
error_callback=self._on_polling_error,
|
error_callback=self._on_polling_error,
|
||||||
@ -637,7 +652,7 @@ class TelegramChannel(BaseChannel):
|
|||||||
|
|
||||||
if self._app:
|
if self._app:
|
||||||
self.logger.info("Stopping bot...")
|
self.logger.info("Stopping bot...")
|
||||||
await self._app.updater.stop()
|
await cast(Any, self._app.updater).stop()
|
||||||
await self._app.stop()
|
await self._app.stop()
|
||||||
await self._app.shutdown()
|
await self._app.shutdown()
|
||||||
self._app = None
|
self._app = None
|
||||||
@ -674,9 +689,9 @@ class TelegramChannel(BaseChannel):
|
|||||||
self,
|
self,
|
||||||
chat_id: int,
|
chat_id: int,
|
||||||
content: str,
|
content: str,
|
||||||
reply_params=None,
|
reply_params: ReplyParameters | dict[str, int | bool] | None = None,
|
||||||
thread_kwargs: dict | None = None,
|
thread_kwargs: dict[str, int] | None = None,
|
||||||
reply_markup=None,
|
reply_markup: InlineKeyboardMarkup | None = None,
|
||||||
) -> bool:
|
) -> bool:
|
||||||
"""Attempt sendRichMessage (Bot API 10.1). Returns True on success."""
|
"""Attempt sendRichMessage (Bot API 10.1). Returns True on success."""
|
||||||
if not self._app:
|
if not self._app:
|
||||||
@ -692,13 +707,17 @@ class TelegramChannel(BaseChannel):
|
|||||||
# sendRichMessage uses reply_parameters (object), not reply_to_message_id.
|
# sendRichMessage uses reply_parameters (object), not reply_to_message_id.
|
||||||
if hasattr(reply_params, "message_id"):
|
if hasattr(reply_params, "message_id"):
|
||||||
payload["reply_parameters"] = {
|
payload["reply_parameters"] = {
|
||||||
"message_id": reply_params.message_id,
|
"message_id": cast(ReplyParameters, reply_params).message_id,
|
||||||
"allow_sending_without_reply": True,
|
"allow_sending_without_reply": True,
|
||||||
}
|
}
|
||||||
else:
|
else:
|
||||||
payload["reply_parameters"] = reply_params
|
payload["reply_parameters"] = reply_params
|
||||||
if thread_kwargs:
|
if thread_kwargs:
|
||||||
payload.update({k: v for k, v in thread_kwargs.items() if v is not None})
|
payload.update({
|
||||||
|
k: v
|
||||||
|
for k, v in thread_kwargs.items()
|
||||||
|
if v is not None # pyright: ignore[reportUnnecessaryComparison]
|
||||||
|
})
|
||||||
if reply_markup is not None:
|
if reply_markup is not None:
|
||||||
payload["reply_markup"] = reply_markup
|
payload["reply_markup"] = reply_markup
|
||||||
|
|
||||||
@ -749,7 +768,7 @@ class TelegramChannel(BaseChannel):
|
|||||||
message_thread_id = msg.metadata.get("message_thread_id")
|
message_thread_id = msg.metadata.get("message_thread_id")
|
||||||
if message_thread_id is None and reply_to_message_id is not None:
|
if message_thread_id is None and reply_to_message_id is not None:
|
||||||
message_thread_id = self._message_threads.get((msg.chat_id, reply_to_message_id))
|
message_thread_id = self._message_threads.get((msg.chat_id, reply_to_message_id))
|
||||||
thread_kwargs = {}
|
thread_kwargs: dict[str, int] = {}
|
||||||
if message_thread_id is not None:
|
if message_thread_id is not None:
|
||||||
thread_kwargs["message_thread_id"] = message_thread_id
|
thread_kwargs["message_thread_id"] = message_thread_id
|
||||||
|
|
||||||
@ -820,7 +839,7 @@ class TelegramChannel(BaseChannel):
|
|||||||
# Send text content
|
# Send text content
|
||||||
if msg.content and msg.content != "[empty message]":
|
if msg.content and msg.content != "[empty message]":
|
||||||
render_as_blockquote = bool(progress_event and progress_event.tool_hint)
|
render_as_blockquote = bool(progress_event and progress_event.tool_hint)
|
||||||
buttons = getattr(msg, "buttons", None) or []
|
buttons = cast(list[list[str]], getattr(msg, "buttons", None) or [])
|
||||||
reply_markup = self._build_keyboard(buttons) if buttons else None
|
reply_markup = self._build_keyboard(buttons) if buttons else None
|
||||||
text = msg.content
|
text = msg.content
|
||||||
# Fallback: no native keyboard → splice labels into the message so the choices survive.
|
# Fallback: no native keyboard → splice labels into the message so the choices survive.
|
||||||
@ -850,7 +869,12 @@ class TelegramChannel(BaseChannel):
|
|||||||
reply_markup=reply_markup if is_last else None,
|
reply_markup=reply_markup if is_last else None,
|
||||||
)
|
)
|
||||||
|
|
||||||
async def _call_with_retry(self, fn, *args, **kwargs):
|
async def _call_with_retry(
|
||||||
|
self,
|
||||||
|
fn: Callable[..., Awaitable[_T]],
|
||||||
|
*args: Any,
|
||||||
|
**kwargs: Any,
|
||||||
|
) -> _T:
|
||||||
"""Call an async Telegram API function with retry on pool/network timeout and RetryAfter."""
|
"""Call an async Telegram API function with retry on pool/network timeout and RetryAfter."""
|
||||||
from telegram.error import RetryAfter
|
from telegram.error import RetryAfter
|
||||||
|
|
||||||
@ -869,27 +893,34 @@ class TelegramChannel(BaseChannel):
|
|||||||
except RetryAfter as e:
|
except RetryAfter as e:
|
||||||
if attempt == _SEND_MAX_RETRIES:
|
if attempt == _SEND_MAX_RETRIES:
|
||||||
raise
|
raise
|
||||||
delay = float(e.retry_after)
|
retry_after = e.retry_after
|
||||||
|
delay = (
|
||||||
|
retry_after.total_seconds()
|
||||||
|
if isinstance(retry_after, timedelta)
|
||||||
|
else float(retry_after)
|
||||||
|
)
|
||||||
self.logger.warning(
|
self.logger.warning(
|
||||||
"Flood Control (attempt {}/{}), retrying in {:.1f}s",
|
"Flood Control (attempt {}/{}), retrying in {:.1f}s",
|
||||||
attempt, _SEND_MAX_RETRIES, delay,
|
attempt, _SEND_MAX_RETRIES, delay,
|
||||||
)
|
)
|
||||||
await asyncio.sleep(delay)
|
await asyncio.sleep(delay)
|
||||||
|
raise RuntimeError("Telegram retry loop exited unexpectedly")
|
||||||
|
|
||||||
async def _send_text(
|
async def _send_text(
|
||||||
self,
|
self,
|
||||||
chat_id: int,
|
chat_id: int,
|
||||||
text: str,
|
text: str,
|
||||||
reply_params=None,
|
reply_params: ReplyParameters | None = None,
|
||||||
thread_kwargs: dict | None = None,
|
thread_kwargs: dict[str, int] | None = None,
|
||||||
render_as_blockquote: bool = False,
|
render_as_blockquote: bool = False,
|
||||||
reply_markup=None,
|
reply_markup: InlineKeyboardMarkup | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Send a plain text message with HTML fallback."""
|
"""Send a plain text message with HTML fallback."""
|
||||||
|
app = self._require_app()
|
||||||
try:
|
try:
|
||||||
html = _tool_hint_to_telegram_blockquote(text) if render_as_blockquote else _markdown_to_telegram_html(text)
|
html = _tool_hint_to_telegram_blockquote(text) if render_as_blockquote else _markdown_to_telegram_html(text)
|
||||||
await self._call_with_retry(
|
await self._call_with_retry(
|
||||||
self._app.bot.send_message,
|
app.bot.send_message,
|
||||||
chat_id=chat_id, text=html, parse_mode="HTML",
|
chat_id=chat_id, text=html, parse_mode="HTML",
|
||||||
reply_parameters=reply_params,
|
reply_parameters=reply_params,
|
||||||
reply_markup=reply_markup,
|
reply_markup=reply_markup,
|
||||||
@ -899,7 +930,7 @@ class TelegramChannel(BaseChannel):
|
|||||||
self.logger.warning("HTML parse failed, falling back to plain text: {}", e)
|
self.logger.warning("HTML parse failed, falling back to plain text: {}", e)
|
||||||
try:
|
try:
|
||||||
await self._call_with_retry(
|
await self._call_with_retry(
|
||||||
self._app.bot.send_message,
|
app.bot.send_message,
|
||||||
chat_id=chat_id,
|
chat_id=chat_id,
|
||||||
text=text,
|
text=text,
|
||||||
reply_parameters=reply_params,
|
reply_parameters=reply_params,
|
||||||
@ -945,7 +976,7 @@ class TelegramChannel(BaseChannel):
|
|||||||
if reply_to_message_id := meta.get("message_id"):
|
if reply_to_message_id := meta.get("message_id"):
|
||||||
with suppress(ValueError):
|
with suppress(ValueError):
|
||||||
await self._remove_reaction(chat_id, int(reply_to_message_id))
|
await self._remove_reaction(chat_id, int(reply_to_message_id))
|
||||||
thread_kwargs = {}
|
thread_kwargs: dict[str, int] = {}
|
||||||
if message_thread_id := meta.get("message_thread_id"):
|
if message_thread_id := meta.get("message_thread_id"):
|
||||||
thread_kwargs["message_thread_id"] = message_thread_id
|
thread_kwargs["message_thread_id"] = message_thread_id
|
||||||
raw_text = buf.text
|
raw_text = buf.text
|
||||||
@ -1032,16 +1063,16 @@ class TelegramChannel(BaseChannel):
|
|||||||
return
|
return
|
||||||
|
|
||||||
now = time.monotonic()
|
now = time.monotonic()
|
||||||
thread_kwargs = {}
|
stream_thread_kwargs: dict[str, int] = {}
|
||||||
if message_thread_id := meta.get("message_thread_id"):
|
if message_thread_id := meta.get("message_thread_id"):
|
||||||
thread_kwargs["message_thread_id"] = message_thread_id
|
stream_thread_kwargs["message_thread_id"] = message_thread_id
|
||||||
if buf.message_id is None:
|
if buf.message_id is None:
|
||||||
preview = _strip_md_block(buf.text)
|
preview = _strip_md_block(buf.text)
|
||||||
try:
|
try:
|
||||||
sent = await self._call_with_retry(
|
sent = await self._call_with_retry(
|
||||||
self._app.bot.send_message,
|
self._app.bot.send_message,
|
||||||
chat_id=int_chat_id, text=preview,
|
chat_id=int_chat_id, text=preview,
|
||||||
**thread_kwargs,
|
**stream_thread_kwargs,
|
||||||
)
|
)
|
||||||
buf.message_id = sent.message_id
|
buf.message_id = sent.message_id
|
||||||
buf.last_edit = now
|
buf.last_edit = now
|
||||||
@ -1050,7 +1081,7 @@ class TelegramChannel(BaseChannel):
|
|||||||
raise # Let ChannelManager handle retry
|
raise # Let ChannelManager handle retry
|
||||||
elif (now - buf.last_edit) >= self.config.stream_edit_interval:
|
elif (now - buf.last_edit) >= self.config.stream_edit_interval:
|
||||||
if len(buf.text) > TELEGRAM_MAX_MESSAGE_LEN:
|
if len(buf.text) > TELEGRAM_MAX_MESSAGE_LEN:
|
||||||
await self._flush_stream_overflow(int_chat_id, buf, thread_kwargs)
|
await self._flush_stream_overflow(int_chat_id, buf, stream_thread_kwargs)
|
||||||
buf.last_edit = now
|
buf.last_edit = now
|
||||||
return
|
return
|
||||||
preview = _strip_md_block(buf.text)
|
preview = _strip_md_block(buf.text)
|
||||||
@ -1072,7 +1103,7 @@ class TelegramChannel(BaseChannel):
|
|||||||
self,
|
self,
|
||||||
chat_id: int,
|
chat_id: int,
|
||||||
buf: "_StreamBuf",
|
buf: "_StreamBuf",
|
||||||
thread_kwargs: dict,
|
thread_kwargs: dict[str, int],
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Split an oversized stream buffer mid-flight.
|
"""Split an oversized stream buffer mid-flight.
|
||||||
|
|
||||||
@ -1083,10 +1114,11 @@ class TelegramChannel(BaseChannel):
|
|||||||
chunks = _split_telegram_markdown_html_chunks(buf.text, TELEGRAM_HTML_MAX_LEN)
|
chunks = _split_telegram_markdown_html_chunks(buf.text, TELEGRAM_HTML_MAX_LEN)
|
||||||
if len(chunks) <= 1:
|
if len(chunks) <= 1:
|
||||||
return
|
return
|
||||||
|
app = self._require_app()
|
||||||
first_markdown, first_html = chunks[0]
|
first_markdown, first_html = chunks[0]
|
||||||
try:
|
try:
|
||||||
await self._call_with_retry(
|
await self._call_with_retry(
|
||||||
self._app.bot.edit_message_text,
|
app.bot.edit_message_text,
|
||||||
chat_id=chat_id, message_id=buf.message_id,
|
chat_id=chat_id, message_id=buf.message_id,
|
||||||
text=first_html,
|
text=first_html,
|
||||||
parse_mode="HTML",
|
parse_mode="HTML",
|
||||||
@ -1098,7 +1130,7 @@ class TelegramChannel(BaseChannel):
|
|||||||
)
|
)
|
||||||
try:
|
try:
|
||||||
await self._call_with_retry(
|
await self._call_with_retry(
|
||||||
self._app.bot.edit_message_text,
|
app.bot.edit_message_text,
|
||||||
chat_id=chat_id, message_id=buf.message_id,
|
chat_id=chat_id, message_id=buf.message_id,
|
||||||
text=first_markdown,
|
text=first_markdown,
|
||||||
)
|
)
|
||||||
@ -1113,7 +1145,7 @@ class TelegramChannel(BaseChannel):
|
|||||||
async def send_chunk(markdown: str, html: str) -> Any:
|
async def send_chunk(markdown: str, html: str) -> Any:
|
||||||
try:
|
try:
|
||||||
return await self._call_with_retry(
|
return await self._call_with_retry(
|
||||||
self._app.bot.send_message,
|
app.bot.send_message,
|
||||||
chat_id=chat_id, text=html, parse_mode="HTML", **thread_kwargs,
|
chat_id=chat_id, text=html, parse_mode="HTML", **thread_kwargs,
|
||||||
)
|
)
|
||||||
except BadRequest as e:
|
except BadRequest as e:
|
||||||
@ -1121,7 +1153,7 @@ class TelegramChannel(BaseChannel):
|
|||||||
"Stream overflow HTML send failed, falling back to plain text: {}", e
|
"Stream overflow HTML send failed, falling back to plain text: {}", e
|
||||||
)
|
)
|
||||||
return await self._call_with_retry(
|
return await self._call_with_retry(
|
||||||
self._app.bot.send_message,
|
app.bot.send_message,
|
||||||
chat_id=chat_id, text=markdown, **thread_kwargs,
|
chat_id=chat_id, text=markdown, **thread_kwargs,
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -1160,12 +1192,14 @@ class TelegramChannel(BaseChannel):
|
|||||||
await update.message.reply_text(build_help_text())
|
await update.message.reply_text(build_help_text())
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _sender_id(user) -> str:
|
def _sender_id(user: User) -> str:
|
||||||
"""Build sender_id with username for allowlist matching."""
|
"""Build sender_id with username for allowlist matching."""
|
||||||
sid = str(user.id)
|
sid = str(user.id)
|
||||||
return f"{sid}|{user.username}" if user.username else sid
|
return f"{sid}|{user.username}" if user.username else sid
|
||||||
|
|
||||||
async def _send_pairing_code_if_private(self, sender_id: str, message, user) -> None:
|
async def _send_pairing_code_if_private(
|
||||||
|
self, sender_id: str, message: Message, user: User
|
||||||
|
) -> None:
|
||||||
if message.chat.type != "private":
|
if message.chat.type != "private":
|
||||||
return
|
return
|
||||||
await self._handle_message(
|
await self._handle_message(
|
||||||
@ -1177,7 +1211,7 @@ class TelegramChannel(BaseChannel):
|
|||||||
)
|
)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _derive_topic_session_key(message) -> str | None:
|
def _derive_topic_session_key(message: Message) -> str | None:
|
||||||
"""Derive topic-scoped session key for Telegram chats with threads."""
|
"""Derive topic-scoped session key for Telegram chats with threads."""
|
||||||
message_thread_id = getattr(message, "message_thread_id", None)
|
message_thread_id = getattr(message, "message_thread_id", None)
|
||||||
if message_thread_id is None:
|
if message_thread_id is None:
|
||||||
@ -1185,7 +1219,7 @@ class TelegramChannel(BaseChannel):
|
|||||||
return f"telegram:{message.chat_id}:topic:{message_thread_id}"
|
return f"telegram:{message.chat_id}:topic:{message_thread_id}"
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _build_message_metadata(message, user) -> dict:
|
def _build_message_metadata(message: Message, user: User) -> dict[str, Any]:
|
||||||
"""Build common Telegram inbound metadata payload."""
|
"""Build common Telegram inbound metadata payload."""
|
||||||
reply_to = getattr(message, "reply_to_message", None)
|
reply_to = getattr(message, "reply_to_message", None)
|
||||||
return {
|
return {
|
||||||
@ -1199,7 +1233,7 @@ class TelegramChannel(BaseChannel):
|
|||||||
"reply_to_message_id": getattr(reply_to, "message_id", None) if reply_to else None,
|
"reply_to_message_id": getattr(reply_to, "message_id", None) if reply_to else None,
|
||||||
}
|
}
|
||||||
|
|
||||||
async def _extract_reply_context(self, message) -> str | None:
|
async def _extract_reply_context(self, message: Message) -> str | None:
|
||||||
"""Extract text from the message being replied to, if any."""
|
"""Extract text from the message being replied to, if any."""
|
||||||
reply = getattr(message, "reply_to_message", None)
|
reply = getattr(message, "reply_to_message", None)
|
||||||
if not reply:
|
if not reply:
|
||||||
@ -1224,7 +1258,7 @@ class TelegramChannel(BaseChannel):
|
|||||||
return f"[Reply to: {text}]"
|
return f"[Reply to: {text}]"
|
||||||
|
|
||||||
async def _download_message_media(
|
async def _download_message_media(
|
||||||
self, msg, *, add_failure_content: bool = False
|
self, msg: Message, *, add_failure_content: bool = False
|
||||||
) -> tuple[list[str], list[str]]:
|
) -> tuple[list[str], list[str]]:
|
||||||
"""Download media from a message (current or reply). Returns (media_paths, content_parts)."""
|
"""Download media from a message (current or reply). Returns (media_paths, content_parts)."""
|
||||||
media_file = None
|
media_file = None
|
||||||
@ -1255,7 +1289,7 @@ class TelegramChannel(BaseChannel):
|
|||||||
try:
|
try:
|
||||||
file = await self._app.bot.get_file(media_file.file_id)
|
file = await self._app.bot.get_file(media_file.file_id)
|
||||||
ext = self._get_extension(
|
ext = self._get_extension(
|
||||||
media_type,
|
cast(str, media_type),
|
||||||
getattr(media_file, "mime_type", None),
|
getattr(media_file, "mime_type", None),
|
||||||
getattr(media_file, "file_name", None),
|
getattr(media_file, "file_name", None),
|
||||||
)
|
)
|
||||||
@ -1291,7 +1325,7 @@ class TelegramChannel(BaseChannel):
|
|||||||
@staticmethod
|
@staticmethod
|
||||||
def _has_mention_entity(
|
def _has_mention_entity(
|
||||||
text: str,
|
text: str,
|
||||||
entities,
|
entities: list[MessageEntity] | None,
|
||||||
bot_username: str,
|
bot_username: str,
|
||||||
bot_id: int | None,
|
bot_id: int | None,
|
||||||
) -> bool:
|
) -> bool:
|
||||||
@ -1314,7 +1348,7 @@ class TelegramChannel(BaseChannel):
|
|||||||
return True
|
return True
|
||||||
return handle in text.lower()
|
return handle in text.lower()
|
||||||
|
|
||||||
async def _is_group_message_for_bot(self, message) -> bool:
|
async def _is_group_message_for_bot(self, message: Message) -> bool:
|
||||||
"""Allow group messages when policy is open, @mentioned, or replying to the bot."""
|
"""Allow group messages when policy is open, @mentioned, or replying to the bot."""
|
||||||
if message.chat.type == "private" or self.config.group_policy == "open":
|
if message.chat.type == "private" or self.config.group_policy == "open":
|
||||||
return True
|
return True
|
||||||
@ -1341,7 +1375,7 @@ class TelegramChannel(BaseChannel):
|
|||||||
reply_user = getattr(getattr(message, "reply_to_message", None), "from_user", None)
|
reply_user = getattr(getattr(message, "reply_to_message", None), "from_user", None)
|
||||||
return bool(bot_id and reply_user and reply_user.id == bot_id)
|
return bool(bot_id and reply_user and reply_user.id == bot_id)
|
||||||
|
|
||||||
def _remember_thread_context(self, message) -> None:
|
def _remember_thread_context(self, message: Message) -> None:
|
||||||
"""Cache Telegram thread context by chat/message id for follow-up replies."""
|
"""Cache Telegram thread context by chat/message id for follow-up replies."""
|
||||||
message_thread_id = getattr(message, "message_thread_id", None)
|
message_thread_id = getattr(message, "message_thread_id", None)
|
||||||
if message_thread_id is None:
|
if message_thread_id is None:
|
||||||
@ -1352,7 +1386,7 @@ class TelegramChannel(BaseChannel):
|
|||||||
self._message_threads.pop(next(iter(self._message_threads)))
|
self._message_threads.pop(next(iter(self._message_threads)))
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _queue_key_for_message(message) -> str:
|
def _queue_key_for_message(message: Message) -> str:
|
||||||
"""Return the final nanobot session key used for ordered Telegram ingress."""
|
"""Return the final nanobot session key used for ordered Telegram ingress."""
|
||||||
return TelegramChannel._derive_topic_session_key(message) or f"telegram:{message.chat_id}"
|
return TelegramChannel._derive_topic_session_key(message) or f"telegram:{message.chat_id}"
|
||||||
|
|
||||||
@ -1373,6 +1407,8 @@ class TelegramChannel(BaseChannel):
|
|||||||
) -> None:
|
) -> None:
|
||||||
"""Stage a Telegram update behind a short per-session reorder window."""
|
"""Stage a Telegram update behind a short per-session reorder window."""
|
||||||
message = update.message
|
message = update.message
|
||||||
|
if message is None:
|
||||||
|
return
|
||||||
key = self._queue_key_for_message(message)
|
key = self._queue_key_for_message(message)
|
||||||
self._inbound_buffers.setdefault(key, []).append(
|
self._inbound_buffers.setdefault(key, []).append(
|
||||||
_QueuedTelegramUpdate(
|
_QueuedTelegramUpdate(
|
||||||
@ -1432,6 +1468,8 @@ class TelegramChannel(BaseChannel):
|
|||||||
"""Process a queued slash command."""
|
"""Process a queued slash command."""
|
||||||
message = update.message
|
message = update.message
|
||||||
user = update.effective_user
|
user = update.effective_user
|
||||||
|
if message is None or user is None:
|
||||||
|
return
|
||||||
sender_id = self._sender_id(user)
|
sender_id = self._sender_id(user)
|
||||||
if not self.is_allowed(sender_id):
|
if not self.is_allowed(sender_id):
|
||||||
await self._send_pairing_code_if_private(sender_id, message, user)
|
await self._send_pairing_code_if_private(sender_id, message, user)
|
||||||
@ -1469,6 +1507,8 @@ class TelegramChannel(BaseChannel):
|
|||||||
|
|
||||||
message = update.message
|
message = update.message
|
||||||
user = update.effective_user
|
user = update.effective_user
|
||||||
|
if message is None or user is None:
|
||||||
|
return
|
||||||
chat_id = message.chat_id
|
chat_id = message.chat_id
|
||||||
sender_id = self._sender_id(user)
|
sender_id = self._sender_id(user)
|
||||||
if not self.is_allowed(sender_id):
|
if not self.is_allowed(sender_id):
|
||||||
@ -1483,8 +1523,8 @@ class TelegramChannel(BaseChannel):
|
|||||||
return
|
return
|
||||||
|
|
||||||
# Build content from text and/or media
|
# Build content from text and/or media
|
||||||
content_parts = []
|
content_parts: list[str] = []
|
||||||
media_paths = []
|
media_paths: list[str] = []
|
||||||
|
|
||||||
# Text content
|
# Text content
|
||||||
if message.text:
|
if message.text:
|
||||||
@ -1625,8 +1665,10 @@ class TelegramChannel(BaseChannel):
|
|||||||
self.logger.debug("Typing indicator stopped for {}: {}", chat_id, e)
|
self.logger.debug("Typing indicator stopped for {}: {}", chat_id, e)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _format_telegram_error(exc: Exception) -> str:
|
def _format_telegram_error(exc: Exception | None) -> str:
|
||||||
"""Return a short, readable error summary for logs."""
|
"""Return a short, readable error summary for logs."""
|
||||||
|
if exc is None:
|
||||||
|
return "None"
|
||||||
text = str(exc).strip()
|
text = str(exc).strip()
|
||||||
if text:
|
if text:
|
||||||
return text
|
return text
|
||||||
@ -1682,7 +1724,7 @@ class TelegramChannel(BaseChannel):
|
|||||||
|
|
||||||
return ""
|
return ""
|
||||||
|
|
||||||
def _build_keyboard(self, buttons: list) -> InlineKeyboardMarkup | None:
|
def _build_keyboard(self, buttons: list[list[str]]) -> InlineKeyboardMarkup | None:
|
||||||
"""Build inline keyboard markup if inline_keyboards is enabled."""
|
"""Build inline keyboard markup if inline_keyboards is enabled."""
|
||||||
if not buttons or not self.config.inline_keyboards:
|
if not buttons or not self.config.inline_keyboards:
|
||||||
return None
|
return None
|
||||||
@ -1711,7 +1753,8 @@ class TelegramChannel(BaseChannel):
|
|||||||
return
|
return
|
||||||
query = update.callback_query
|
query = update.callback_query
|
||||||
user = update.effective_user
|
user = update.effective_user
|
||||||
chat_id = query.message.chat_id if query.message else None
|
query_message = query.message
|
||||||
|
chat_id = query_message.chat.id if query_message else None
|
||||||
sender_id = self._sender_id(user)
|
sender_id = self._sender_id(user)
|
||||||
if not chat_id:
|
if not chat_id:
|
||||||
self.logger.warning("Callback query without chat_id")
|
self.logger.warning("Callback query without chat_id")
|
||||||
@ -1720,9 +1763,9 @@ class TelegramChannel(BaseChannel):
|
|||||||
return
|
return
|
||||||
button_label = query.data or ""
|
button_label = query.data or ""
|
||||||
await query.answer()
|
await query.answer()
|
||||||
if query.message:
|
if isinstance(query_message, Message):
|
||||||
with suppress(Exception):
|
with suppress(Exception):
|
||||||
await query.message.edit_reply_markup(reply_markup=None)
|
await query_message.edit_reply_markup(reply_markup=None)
|
||||||
self.logger.debug("Inline button tap from {}: {}", sender_id, button_label)
|
self.logger.debug("Inline button tap from {}: {}", sender_id, button_label)
|
||||||
self._start_typing(str(chat_id))
|
self._start_typing(str(chat_id))
|
||||||
await self._handle_message(
|
await self._handle_message(
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
import asyncio
|
import asyncio
|
||||||
|
from datetime import timedelta
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from types import SimpleNamespace
|
from types import SimpleNamespace
|
||||||
from unittest.mock import AsyncMock
|
from unittest.mock import AsyncMock
|
||||||
@ -1911,6 +1912,36 @@ async def test_on_message_location_with_text() -> None:
|
|||||||
# Tests for retry amplification fix (issue #3050)
|
# Tests for retry amplification fix (issue #3050)
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_call_with_retry_accepts_timedelta_retry_after(
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
from telegram.error import RetryAfter
|
||||||
|
|
||||||
|
channel = TelegramChannel(
|
||||||
|
TelegramConfig(enabled=True, token="123:abc", allow_from=["*"]),
|
||||||
|
MessageBus(),
|
||||||
|
)
|
||||||
|
attempts = 0
|
||||||
|
|
||||||
|
async def retry_once() -> str:
|
||||||
|
nonlocal attempts
|
||||||
|
attempts += 1
|
||||||
|
if attempts == 1:
|
||||||
|
raise RetryAfter(timedelta(seconds=1.5))
|
||||||
|
return "ok"
|
||||||
|
|
||||||
|
sleep = AsyncMock()
|
||||||
|
monkeypatch.setenv("PTB_TIMEDELTA", "1")
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"nanobot.channels.telegram.runtime.asyncio.sleep",
|
||||||
|
sleep,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert await channel._call_with_retry(retry_once) == "ok"
|
||||||
|
sleep.assert_awaited_once_with(1.5)
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_send_text_does_not_fallback_on_network_timeout() -> None:
|
async def test_send_text_does_not_fallback_on_network_timeout() -> None:
|
||||||
"""TimedOut should propagate immediately, NOT trigger plain-text fallback.
|
"""TimedOut should propagate immediately, NOT trigger plain-text fallback.
|
||||||
@ -2318,7 +2349,7 @@ async def test_callback_query_ignores_unauthorized_user_before_side_effects() ->
|
|||||||
data="Yes",
|
data="Yes",
|
||||||
answer=AsyncMock(),
|
answer=AsyncMock(),
|
||||||
message=SimpleNamespace(
|
message=SimpleNamespace(
|
||||||
chat_id=123,
|
chat=SimpleNamespace(id=123),
|
||||||
edit_reply_markup=AsyncMock(),
|
edit_reply_markup=AsyncMock(),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
@ -2332,3 +2363,35 @@ async def test_callback_query_ignores_unauthorized_user_before_side_effects() ->
|
|||||||
query.answer.assert_not_awaited()
|
query.answer.assert_not_awaited()
|
||||||
query.message.edit_reply_markup.assert_not_awaited()
|
query.message.edit_reply_markup.assert_not_awaited()
|
||||||
channel._handle_message.assert_not_awaited()
|
channel._handle_message.assert_not_awaited()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_callback_query_handles_inaccessible_message() -> None:
|
||||||
|
from telegram import Chat, InaccessibleMessage
|
||||||
|
|
||||||
|
channel = TelegramChannel(
|
||||||
|
TelegramConfig(enabled=True, token="123:abc", allow_from=["*"], inline_keyboards=True),
|
||||||
|
MessageBus(),
|
||||||
|
)
|
||||||
|
channel._handle_message = AsyncMock()
|
||||||
|
channel._start_typing = lambda _chat_id: None
|
||||||
|
|
||||||
|
query = SimpleNamespace(
|
||||||
|
id="cb_inaccessible",
|
||||||
|
data="Yes",
|
||||||
|
answer=AsyncMock(),
|
||||||
|
message=InaccessibleMessage(
|
||||||
|
chat=Chat(id=123, type="private"),
|
||||||
|
message_id=456,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
update = SimpleNamespace(
|
||||||
|
callback_query=query,
|
||||||
|
effective_user=SimpleNamespace(id=12345, username="alice", first_name="Alice"),
|
||||||
|
)
|
||||||
|
|
||||||
|
await channel._on_callback_query(update, None)
|
||||||
|
|
||||||
|
query.answer.assert_awaited_once()
|
||||||
|
channel._handle_message.assert_awaited_once()
|
||||||
|
assert channel._handle_message.await_args.kwargs["chat_id"] == "123"
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
"""Telegram setup validation owned by the channel package."""
|
"""Telegram setup validation owned by the channel package."""
|
||||||
|
|
||||||
import re
|
import re
|
||||||
from typing import Any
|
from typing import Any, cast
|
||||||
from urllib.parse import urlparse
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
@ -39,7 +39,7 @@ def _get_me(token: str, proxy: str | None) -> dict[str, Any]:
|
|||||||
response = client.get(f"https://api.telegram.org/bot{token}/getMe")
|
response = client.get(f"https://api.telegram.org/bot{token}/getMe")
|
||||||
response.raise_for_status()
|
response.raise_for_status()
|
||||||
data = response.json()
|
data = response.json()
|
||||||
return data if isinstance(data, dict) else {}
|
return cast(dict[str, Any], data) if isinstance(data, dict) else {}
|
||||||
|
|
||||||
|
|
||||||
def validate(values: dict[str, Any], _context: ChannelValidationContext) -> dict[str, Any]:
|
def validate(values: dict[str, Any], _context: ChannelValidationContext) -> dict[str, Any]:
|
||||||
|
|||||||
@ -11,7 +11,7 @@ import re
|
|||||||
import socket
|
import socket
|
||||||
import ssl
|
import ssl
|
||||||
from datetime import UTC, datetime
|
from datetime import UTC, datetime
|
||||||
from typing import Any
|
from typing import Any, cast
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
|
|
||||||
@ -76,7 +76,7 @@ def validate_channel_config(
|
|||||||
allow_local_service_access=config.tools.webui_allow_local_service_access,
|
allow_local_service_access=config.tools.webui_allow_local_service_access,
|
||||||
)
|
)
|
||||||
custom_payload = setup_spec.validator(values, context)
|
custom_payload = setup_spec.validator(values, context)
|
||||||
if custom_payload is not None:
|
if cast(object, custom_payload) is not None:
|
||||||
payload = dict(custom_payload)
|
payload = dict(custom_payload)
|
||||||
payload.setdefault("checks", [])
|
payload.setdefault("checks", [])
|
||||||
payload.setdefault("missing_fields", [])
|
payload.setdefault("missing_fields", [])
|
||||||
@ -116,7 +116,7 @@ def _channel_config(
|
|||||||
if hasattr(section, "model_dump"):
|
if hasattr(section, "model_dump"):
|
||||||
return dict(section.model_dump(mode="json", by_alias=True))
|
return dict(section.model_dump(mode="json", by_alias=True))
|
||||||
if isinstance(section, dict):
|
if isinstance(section, dict):
|
||||||
return dict(section)
|
return dict(cast(dict[str, Any], section))
|
||||||
return {}
|
return {}
|
||||||
|
|
||||||
|
|
||||||
@ -130,9 +130,9 @@ def _merge_form_values(
|
|||||||
merged = dict(values)
|
merged = dict(values)
|
||||||
prefix = f"channels.{name}."
|
prefix = f"channels.{name}."
|
||||||
spec = setup_spec
|
spec = setup_spec
|
||||||
secrets = spec.secrets if spec is not None else frozenset()
|
secrets: frozenset[str] = spec.secrets if spec is not None else frozenset()
|
||||||
for raw_key, raw_value in raw_values.items():
|
for raw_key, raw_value in raw_values.items():
|
||||||
if not isinstance(raw_key, str) or not raw_key:
|
if not raw_key:
|
||||||
continue
|
continue
|
||||||
field = raw_key[len(prefix):] if raw_key.startswith(prefix) else raw_key
|
field = raw_key[len(prefix):] if raw_key.startswith(prefix) else raw_key
|
||||||
if field in secrets and not _str(raw_value):
|
if field in secrets and not _str(raw_value):
|
||||||
@ -281,7 +281,7 @@ def _assign(values: dict[str, Any], field: str, value: Any) -> None:
|
|||||||
if not isinstance(current, dict):
|
if not isinstance(current, dict):
|
||||||
current = {}
|
current = {}
|
||||||
target[part] = current
|
target[part] = current
|
||||||
target = current
|
target = cast(dict[str, Any], current)
|
||||||
target[parts[-1]] = value
|
target[parts[-1]] = value
|
||||||
|
|
||||||
|
|
||||||
@ -290,7 +290,7 @@ def _get(values: dict[str, Any], field: str) -> Any:
|
|||||||
for part in field.split("."):
|
for part in field.split("."):
|
||||||
if not isinstance(target, dict):
|
if not isinstance(target, dict):
|
||||||
return None
|
return None
|
||||||
target = target.get(part)
|
target = cast(dict[str, Any], target).get(part)
|
||||||
return target
|
return target
|
||||||
|
|
||||||
|
|
||||||
@ -346,7 +346,7 @@ def _http_get(url: str, *, headers: dict[str, str] | None = None) -> dict[str, A
|
|||||||
response = client.get(url, headers=headers)
|
response = client.get(url, headers=headers)
|
||||||
response.raise_for_status()
|
response.raise_for_status()
|
||||||
data = response.json()
|
data = response.json()
|
||||||
return data if isinstance(data, dict) else {}
|
return cast(dict[str, Any], data) if isinstance(data, dict) else {}
|
||||||
|
|
||||||
|
|
||||||
def _http_post(url: str, *, headers: dict[str, str] | None = None) -> dict[str, Any]:
|
def _http_post(url: str, *, headers: dict[str, str] | None = None) -> dict[str, Any]:
|
||||||
@ -354,7 +354,7 @@ def _http_post(url: str, *, headers: dict[str, str] | None = None) -> dict[str,
|
|||||||
response = client.post(url, headers=headers)
|
response = client.post(url, headers=headers)
|
||||||
response.raise_for_status()
|
response.raise_for_status()
|
||||||
data = response.json()
|
data = response.json()
|
||||||
return data if isinstance(data, dict) else {}
|
return cast(dict[str, Any], data) if isinstance(data, dict) else {}
|
||||||
|
|
||||||
|
|
||||||
def _probe_tcp(host: str, port: int, *, allow_loopback: bool = False) -> None:
|
def _probe_tcp(host: str, port: int, *, allow_loopback: bool = False) -> None:
|
||||||
|
|||||||
@ -11,7 +11,7 @@ import uuid
|
|||||||
from collections.abc import Callable
|
from collections.abc import Callable
|
||||||
from contextlib import suppress
|
from contextlib import suppress
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any, Self
|
from typing import Any, Self, TypeGuard, cast
|
||||||
|
|
||||||
from pydantic import Field, field_validator, model_validator
|
from pydantic import Field, field_validator, model_validator
|
||||||
from websockets.asyncio.server import ServerConnection, serve, unix_serve
|
from websockets.asyncio.server import ServerConnection, serve, unix_serve
|
||||||
@ -191,12 +191,13 @@ def _parse_inbound_payload(raw: str) -> str | None:
|
|||||||
return None
|
return None
|
||||||
if text.startswith("{"):
|
if text.startswith("{"):
|
||||||
try:
|
try:
|
||||||
data = json.loads(text)
|
data = cast(object, json.loads(text))
|
||||||
except json.JSONDecodeError:
|
except json.JSONDecodeError:
|
||||||
return text
|
return text
|
||||||
if isinstance(data, dict):
|
if isinstance(data, dict):
|
||||||
|
payload = cast(dict[str, Any], data)
|
||||||
for key in ("content", "text", "message"):
|
for key in ("content", "text", "message"):
|
||||||
value = data.get(key)
|
value = payload.get(key)
|
||||||
if isinstance(value, str) and value.strip():
|
if isinstance(value, str) and value.strip():
|
||||||
return value
|
return value
|
||||||
return None
|
return None
|
||||||
@ -209,7 +210,7 @@ def _parse_inbound_payload(raw: str) -> str | None:
|
|||||||
_CHAT_ID_RE = re.compile(r"^[A-Za-z0-9_:-]{1,64}$")
|
_CHAT_ID_RE = re.compile(r"^[A-Za-z0-9_:-]{1,64}$")
|
||||||
|
|
||||||
|
|
||||||
def _is_valid_chat_id(value: Any) -> bool:
|
def _is_valid_chat_id(value: Any) -> TypeGuard[str]:
|
||||||
return isinstance(value, str) and _CHAT_ID_RE.match(value) is not None
|
return isinstance(value, str) and _CHAT_ID_RE.match(value) is not None
|
||||||
|
|
||||||
|
|
||||||
@ -224,15 +225,16 @@ def _parse_envelope(raw: str) -> dict[str, Any] | None:
|
|||||||
if not text.startswith("{"):
|
if not text.startswith("{"):
|
||||||
return None
|
return None
|
||||||
try:
|
try:
|
||||||
data = json.loads(text)
|
data = cast(object, json.loads(text))
|
||||||
except json.JSONDecodeError:
|
except json.JSONDecodeError:
|
||||||
return None
|
return None
|
||||||
if not isinstance(data, dict):
|
if not isinstance(data, dict):
|
||||||
return None
|
return None
|
||||||
t = data.get("type")
|
envelope = cast(dict[str, Any], data)
|
||||||
|
t = envelope.get("type")
|
||||||
if not isinstance(t, str):
|
if not isinstance(t, str):
|
||||||
return None
|
return None
|
||||||
return data
|
return envelope
|
||||||
|
|
||||||
|
|
||||||
def _is_websocket_upgrade(request: WsRequest) -> bool:
|
def _is_websocket_upgrade(request: WsRequest) -> bool:
|
||||||
@ -264,13 +266,13 @@ class WebSocketChannel(BaseChannel):
|
|||||||
super().__init__(config, bus)
|
super().__init__(config, bus)
|
||||||
self.config: WebSocketConfig = config
|
self.config: WebSocketConfig = config
|
||||||
# chat_id -> connections subscribed to it (fan-out target).
|
# chat_id -> connections subscribed to it (fan-out target).
|
||||||
self._subs: dict[str, set[Any]] = {}
|
self._subs: dict[str, set[ServerConnection]] = {}
|
||||||
# connection -> chat_ids it is subscribed to (O(1) cleanup on disconnect).
|
# connection -> chat_ids it is subscribed to (O(1) cleanup on disconnect).
|
||||||
self._conn_chats: dict[Any, set[str]] = {}
|
self._conn_chats: dict[ServerConnection, set[str]] = {}
|
||||||
# connection -> default chat_id for legacy frames that omit routing.
|
# connection -> default chat_id for legacy frames that omit routing.
|
||||||
self._conn_default: dict[Any, str] = {}
|
self._conn_default: dict[ServerConnection, str] = {}
|
||||||
# Connections authenticated with a one-time token from /webui/bootstrap.
|
# Connections authenticated with a one-time token from /webui/bootstrap.
|
||||||
self._webui_connections: set[Any] = set()
|
self._webui_connections: set[ServerConnection] = set()
|
||||||
self._stop_event: asyncio.Event | None = None
|
self._stop_event: asyncio.Event | None = None
|
||||||
self._server_task: asyncio.Task[None] | None = None
|
self._server_task: asyncio.Task[None] | None = None
|
||||||
|
|
||||||
@ -286,15 +288,43 @@ class WebSocketChannel(BaseChannel):
|
|||||||
|
|
||||||
# -- Subscription bookkeeping -------------------------------------------
|
# -- Subscription bookkeeping -------------------------------------------
|
||||||
|
|
||||||
def _workspace_controls_available(self, connection: Any) -> bool:
|
def _workspace_controls_available(self, connection: ServerConnection) -> bool:
|
||||||
return self._http_router.workspace_controls_available(connection)
|
return self._http_router.workspace_controls_available(connection)
|
||||||
|
|
||||||
def _attach(self, connection: Any, chat_id: str) -> None:
|
def _attach(self, connection: ServerConnection, chat_id: str) -> None:
|
||||||
"""Idempotently subscribe *connection* to *chat_id*."""
|
"""Idempotently subscribe *connection* to *chat_id*."""
|
||||||
self._subs.setdefault(chat_id, set()).add(connection)
|
self._subs.setdefault(chat_id, set()).add(connection)
|
||||||
self._conn_chats.setdefault(connection, set()).add(chat_id)
|
self._conn_chats.setdefault(connection, set()).add(chat_id)
|
||||||
|
|
||||||
def _cleanup_connection(self, connection: Any) -> None:
|
async def send_webui_protocol_error(
|
||||||
|
self,
|
||||||
|
connection: ServerConnection,
|
||||||
|
detail: str,
|
||||||
|
) -> None:
|
||||||
|
"""Send a stable protocol error from a WebUI-owned orchestration helper."""
|
||||||
|
await self._send_event(connection, "error", detail=detail)
|
||||||
|
|
||||||
|
async def attach_webui_fork(
|
||||||
|
self,
|
||||||
|
connection: ServerConnection,
|
||||||
|
*,
|
||||||
|
fork_id: str,
|
||||||
|
fork_key: str,
|
||||||
|
) -> None:
|
||||||
|
"""Attach and hydrate a newly created WebUI chat fork."""
|
||||||
|
scope = self._workspaces.scope_for_session_key(fork_key)
|
||||||
|
self._attach(connection, fork_id)
|
||||||
|
await self._send_event(connection, "attached", chat_id=fork_id)
|
||||||
|
await self._send_event(
|
||||||
|
connection,
|
||||||
|
"session_updated",
|
||||||
|
chat_id=fork_id,
|
||||||
|
scope="metadata",
|
||||||
|
workspace_scope=scope.payload(),
|
||||||
|
)
|
||||||
|
await self._hydrate_after_subscribe(fork_id)
|
||||||
|
|
||||||
|
def _cleanup_connection(self, connection: ServerConnection) -> None:
|
||||||
"""Remove *connection* from every subscription set; safe to call multiple times."""
|
"""Remove *connection* from every subscription set; safe to call multiple times."""
|
||||||
chat_ids = self._conn_chats.pop(connection, set())
|
chat_ids = self._conn_chats.pop(connection, set())
|
||||||
for cid in chat_ids:
|
for cid in chat_ids:
|
||||||
@ -317,10 +347,11 @@ class WebSocketChannel(BaseChannel):
|
|||||||
if self.gateway.session_manager is None:
|
if self.gateway.session_manager is None:
|
||||||
return
|
return
|
||||||
row = self.gateway.session_manager.read_session_file(f"websocket:{chat_id}")
|
row = self.gateway.session_manager.read_session_file(f"websocket:{chat_id}")
|
||||||
meta = row.get("metadata", {}) if isinstance(row, dict) else {}
|
row_data = row if isinstance(row, dict) else {}
|
||||||
|
meta = row_data.get("metadata", {})
|
||||||
if not isinstance(meta, dict):
|
if not isinstance(meta, dict):
|
||||||
meta = {}
|
meta = {}
|
||||||
blob = goal_state_ws_blob(meta)
|
blob = goal_state_ws_blob(cast(dict[str, Any], meta))
|
||||||
if not blob.get("active"):
|
if not blob.get("active"):
|
||||||
return
|
return
|
||||||
await self.send_goal_state(chat_id, blob)
|
await self.send_goal_state(chat_id, blob)
|
||||||
@ -342,7 +373,12 @@ class WebSocketChannel(BaseChannel):
|
|||||||
await self._maybe_push_active_goal_state(chat_id)
|
await self._maybe_push_active_goal_state(chat_id)
|
||||||
await self._maybe_push_turn_run_wall_clock(chat_id)
|
await self._maybe_push_turn_run_wall_clock(chat_id)
|
||||||
|
|
||||||
async def _send_event(self, connection: Any, event: str, **fields: Any) -> None:
|
async def _send_event(
|
||||||
|
self,
|
||||||
|
connection: ServerConnection,
|
||||||
|
event: str,
|
||||||
|
**fields: Any,
|
||||||
|
) -> None:
|
||||||
"""Send a control event (attached, error, ...) to a single connection."""
|
"""Send a control event (attached, error, ...) to a single connection."""
|
||||||
payload: dict[str, Any] = {"event": event}
|
payload: dict[str, Any] = {"event": event}
|
||||||
payload.update(fields)
|
payload.update(fields)
|
||||||
@ -377,7 +413,7 @@ class WebSocketChannel(BaseChannel):
|
|||||||
|
|
||||||
# -- HTTP dispatch ------------------------------------------------------
|
# -- HTTP dispatch ------------------------------------------------------
|
||||||
|
|
||||||
async def _dispatch_http(self, connection: Any, request: WsRequest) -> Any:
|
async def _dispatch_http(self, connection: ServerConnection, request: WsRequest) -> Any:
|
||||||
"""Route an inbound HTTP request to the HTTP handler or WS upgrade."""
|
"""Route an inbound HTTP request to the HTTP handler or WS upgrade."""
|
||||||
got, query = _parse_request_path(request.path)
|
got, query = _parse_request_path(request.path)
|
||||||
|
|
||||||
@ -394,7 +430,11 @@ class WebSocketChannel(BaseChannel):
|
|||||||
# Everything else goes to the HTTP handler
|
# Everything else goes to the HTTP handler
|
||||||
return await self._http_router.dispatch(connection, request)
|
return await self._http_router.dispatch(connection, request)
|
||||||
|
|
||||||
def _authorize_websocket_handshake(self, connection: Any, query: dict[str, list[str]]) -> Any:
|
def _authorize_websocket_handshake(
|
||||||
|
self,
|
||||||
|
connection: ServerConnection,
|
||||||
|
query: dict[str, list[str]],
|
||||||
|
) -> Any:
|
||||||
supplied = _query_first(query, "token")
|
supplied = _query_first(query, "token")
|
||||||
static_token = self.config.token.strip()
|
static_token = self.config.token.strip()
|
||||||
|
|
||||||
@ -414,7 +454,7 @@ class WebSocketChannel(BaseChannel):
|
|||||||
self._consume_issued_token(connection, supplied)
|
self._consume_issued_token(connection, supplied)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def _consume_issued_token(self, connection: Any, token: str) -> bool:
|
def _consume_issued_token(self, connection: ServerConnection, token: str) -> bool:
|
||||||
audience = self._tokens.take_issued_token_audience(token)
|
audience = self._tokens.take_issued_token_audience(token)
|
||||||
if audience == "webui":
|
if audience == "webui":
|
||||||
self._webui_connections.add(connection)
|
self._webui_connections.add(connection)
|
||||||
@ -509,7 +549,7 @@ class WebSocketChannel(BaseChannel):
|
|||||||
self._server_task = asyncio.create_task(runner())
|
self._server_task = asyncio.create_task(runner())
|
||||||
await self._server_task
|
await self._server_task
|
||||||
|
|
||||||
async def _connection_loop(self, connection: Any) -> None:
|
async def _connection_loop(self, connection: ServerConnection) -> None:
|
||||||
request = connection.request
|
request = connection.request
|
||||||
path_part = request.path if request else "/"
|
path_part = request.path if request else "/"
|
||||||
_, query = _parse_request_path(path_part)
|
_, query = _parse_request_path(path_part)
|
||||||
@ -574,7 +614,7 @@ class WebSocketChannel(BaseChannel):
|
|||||||
|
|
||||||
async def _dispatch_envelope(
|
async def _dispatch_envelope(
|
||||||
self,
|
self,
|
||||||
connection: Any,
|
connection: ServerConnection,
|
||||||
client_id: str,
|
client_id: str,
|
||||||
envelope: dict[str, Any],
|
envelope: dict[str, Any],
|
||||||
) -> None:
|
) -> None:
|
||||||
@ -700,7 +740,7 @@ class WebSocketChannel(BaseChannel):
|
|||||||
**rejection_fields,
|
**rejection_fields,
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
media_paths, reason = self._media.store_inbound_attachments(raw_media)
|
media_paths, reason = self._media.store_inbound_attachments(cast(list[Any], raw_media))
|
||||||
if reason is not None:
|
if reason is not None:
|
||||||
await self._send_event(
|
await self._send_event(
|
||||||
connection,
|
connection,
|
||||||
@ -810,7 +850,7 @@ class WebSocketChannel(BaseChannel):
|
|||||||
|
|
||||||
async def _workspace_scope_or_error(
|
async def _workspace_scope_or_error(
|
||||||
self,
|
self,
|
||||||
connection: Any,
|
connection: ServerConnection,
|
||||||
resolver: Callable[[], Any],
|
resolver: Callable[[], Any],
|
||||||
*,
|
*,
|
||||||
chat_id: str | None = None,
|
chat_id: str | None = None,
|
||||||
@ -841,7 +881,8 @@ class WebSocketChannel(BaseChannel):
|
|||||||
try:
|
try:
|
||||||
await self._server_task
|
await self._server_task
|
||||||
except asyncio.CancelledError:
|
except asyncio.CancelledError:
|
||||||
if asyncio.current_task() and asyncio.current_task().cancelling():
|
current_task = asyncio.current_task()
|
||||||
|
if current_task is not None and current_task.cancelling():
|
||||||
raise
|
raise
|
||||||
self.logger.debug("server task was already cancelled during shutdown")
|
self.logger.debug("server task was already cancelled during shutdown")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@ -853,7 +894,13 @@ class WebSocketChannel(BaseChannel):
|
|||||||
self._webui_connections.clear()
|
self._webui_connections.clear()
|
||||||
self._tokens.clear()
|
self._tokens.clear()
|
||||||
|
|
||||||
async def _safe_send_to(self, connection: Any, raw: str, *, label: str = "") -> None:
|
async def _safe_send_to(
|
||||||
|
self,
|
||||||
|
connection: ServerConnection,
|
||||||
|
raw: str,
|
||||||
|
*,
|
||||||
|
label: str = "",
|
||||||
|
) -> None:
|
||||||
"""Send a raw frame to one connection, cleaning up on ConnectionClosed."""
|
"""Send a raw frame to one connection, cleaning up on ConnectionClosed."""
|
||||||
try:
|
try:
|
||||||
await connection.send(raw)
|
await connection.send(raw)
|
||||||
|
|||||||
@ -1,3 +1,4 @@
|
|||||||
|
# pyright: reportMissingTypeStubs=false
|
||||||
"""WeCom (Enterprise WeChat) channel implementation using wecom_aibot_sdk."""
|
"""WeCom (Enterprise WeChat) channel implementation using wecom_aibot_sdk."""
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
@ -7,8 +8,9 @@ import importlib.util
|
|||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
from collections import OrderedDict
|
from collections import OrderedDict
|
||||||
|
from collections.abc import Callable
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any
|
from typing import Any, cast
|
||||||
|
|
||||||
from pydantic import Field
|
from pydantic import Field
|
||||||
|
|
||||||
@ -96,7 +98,7 @@ class WecomChannel(BaseChannel):
|
|||||||
self._client: Any = None
|
self._client: Any = None
|
||||||
self._processed_message_ids: OrderedDict[str, None] = OrderedDict()
|
self._processed_message_ids: OrderedDict[str, None] = OrderedDict()
|
||||||
self._loop: asyncio.AbstractEventLoop | None = None
|
self._loop: asyncio.AbstractEventLoop | None = None
|
||||||
self._generate_req_id = None
|
self._generate_req_id: Callable[[str], str] | None = None
|
||||||
# Store frame headers for each chat to enable replies
|
# Store frame headers for each chat to enable replies
|
||||||
self._chat_frames: dict[str, Any] = {}
|
self._chat_frames: dict[str, Any] = {}
|
||||||
|
|
||||||
@ -117,7 +119,8 @@ class WecomChannel(BaseChannel):
|
|||||||
self._generate_req_id = generate_req_id
|
self._generate_req_id = generate_req_id
|
||||||
|
|
||||||
# Create WebSocket client
|
# Create WebSocket client
|
||||||
self._client = WSClient({
|
ws_client = cast(Any, WSClient)
|
||||||
|
self._client = ws_client({
|
||||||
"bot_id": self.config.bot_id,
|
"bot_id": self.config.bot_id,
|
||||||
"secret": self.config.secret,
|
"secret": self.config.secret,
|
||||||
"reconnect_interval": 1000,
|
"reconnect_interval": 1000,
|
||||||
@ -195,14 +198,16 @@ class WecomChannel(BaseChannel):
|
|||||||
"""Handle enter_chat event (user opens chat with bot)."""
|
"""Handle enter_chat event (user opens chat with bot)."""
|
||||||
try:
|
try:
|
||||||
# Extract body from WsFrame dataclass or dict
|
# Extract body from WsFrame dataclass or dict
|
||||||
if hasattr(frame, 'body'):
|
if hasattr(frame, "body"):
|
||||||
body = frame.body or {}
|
body: Any = frame.body or {}
|
||||||
elif isinstance(frame, dict):
|
elif isinstance(frame, dict):
|
||||||
body = frame.get("body", frame)
|
frame_dict = cast(dict[str, Any], frame)
|
||||||
|
body = frame_dict.get("body", frame_dict)
|
||||||
else:
|
else:
|
||||||
body = {}
|
body = {}
|
||||||
|
|
||||||
chat_id = body.get("chatid", "") if isinstance(body, dict) else ""
|
body_dict = cast(dict[str, Any], body) if isinstance(body, dict) else {}
|
||||||
|
chat_id = cast(str, body_dict.get("chatid", ""))
|
||||||
|
|
||||||
if chat_id and not self.is_allowed(chat_id):
|
if chat_id and not self.is_allowed(chat_id):
|
||||||
return
|
return
|
||||||
@ -219,26 +224,32 @@ class WecomChannel(BaseChannel):
|
|||||||
"""Process incoming message and forward to bus."""
|
"""Process incoming message and forward to bus."""
|
||||||
try:
|
try:
|
||||||
# Extract body from WsFrame dataclass or dict
|
# Extract body from WsFrame dataclass or dict
|
||||||
if hasattr(frame, 'body'):
|
if hasattr(frame, "body"):
|
||||||
body = frame.body or {}
|
body: Any = frame.body or {}
|
||||||
elif isinstance(frame, dict):
|
elif isinstance(frame, dict):
|
||||||
body = frame.get("body", frame)
|
frame_dict = cast(dict[str, Any], frame)
|
||||||
|
body = frame_dict.get("body", frame_dict)
|
||||||
else:
|
else:
|
||||||
body = {}
|
body = {}
|
||||||
|
|
||||||
# Ensure body is a dict
|
# Ensure body is a dict
|
||||||
if not isinstance(body, dict):
|
if not isinstance(body, dict):
|
||||||
self.logger.warning("Invalid body type: {}", type(body))
|
self.logger.warning("Invalid body type: {}", type(cast(object, body)))
|
||||||
return
|
return
|
||||||
|
body = cast(dict[str, Any], body)
|
||||||
|
|
||||||
# Extract message info
|
# Extract message info
|
||||||
msg_id = body.get("msgid", "")
|
msg_id = cast(str, body.get("msgid", ""))
|
||||||
if not msg_id:
|
if not msg_id:
|
||||||
msg_id = f"{body.get('chatid', '')}_{body.get('sendertime', '')}"
|
msg_id = f"{body.get('chatid', '')}_{body.get('sendertime', '')}"
|
||||||
|
|
||||||
# Extract sender info from "from" field (SDK format)
|
# Extract sender info from "from" field (SDK format)
|
||||||
from_info = body.get("from", {})
|
from_info = body.get("from", {})
|
||||||
sender_id = from_info.get("userid", "unknown") if isinstance(from_info, dict) else "unknown"
|
sender_id = (
|
||||||
|
cast(str, cast(dict[str, Any], from_info).get("userid", "unknown"))
|
||||||
|
if isinstance(from_info, dict)
|
||||||
|
else "unknown"
|
||||||
|
)
|
||||||
if not self.is_allowed(sender_id):
|
if not self.is_allowed(sender_id):
|
||||||
return
|
return
|
||||||
|
|
||||||
@ -253,21 +264,22 @@ class WecomChannel(BaseChannel):
|
|||||||
|
|
||||||
# For single chat, chatid is the sender's userid
|
# For single chat, chatid is the sender's userid
|
||||||
# For group chat, chatid is provided in body
|
# For group chat, chatid is provided in body
|
||||||
chat_type = body.get("chattype", "single")
|
chat_type = cast(str, body.get("chattype", "single"))
|
||||||
chat_id = body.get("chatid", sender_id)
|
chat_id = cast(str, body.get("chatid", sender_id))
|
||||||
|
|
||||||
content_parts = []
|
content_parts: list[str] = []
|
||||||
media_paths: list[str] = []
|
media_paths: list[str] = []
|
||||||
|
|
||||||
if msg_type == "text":
|
if msg_type == "text":
|
||||||
text = body.get("text", {}).get("content", "")
|
text_info = cast(dict[str, Any], body.get("text", {}))
|
||||||
|
text = cast(str, text_info.get("content", ""))
|
||||||
if text:
|
if text:
|
||||||
content_parts.append(text)
|
content_parts.append(text)
|
||||||
|
|
||||||
elif msg_type == "image":
|
elif msg_type == "image":
|
||||||
image_info = body.get("image", {})
|
image_info = cast(dict[str, Any], body.get("image", {}))
|
||||||
file_url = image_info.get("url", "")
|
file_url = cast(str, image_info.get("url", ""))
|
||||||
aes_key = image_info.get("aeskey", "")
|
aes_key = cast(str, image_info.get("aeskey", ""))
|
||||||
|
|
||||||
if file_url and aes_key:
|
if file_url and aes_key:
|
||||||
file_path = await self._download_and_save_media(file_url, aes_key, "image")
|
file_path = await self._download_and_save_media(file_url, aes_key, "image")
|
||||||
@ -281,19 +293,19 @@ class WecomChannel(BaseChannel):
|
|||||||
content_parts.append("[image: download failed]")
|
content_parts.append("[image: download failed]")
|
||||||
|
|
||||||
elif msg_type == "voice":
|
elif msg_type == "voice":
|
||||||
voice_info = body.get("voice", {})
|
voice_info = cast(dict[str, Any], body.get("voice", {}))
|
||||||
# Voice message already contains transcribed content from WeCom
|
# Voice message already contains transcribed content from WeCom
|
||||||
voice_content = voice_info.get("content", "")
|
voice_content = cast(str, voice_info.get("content", ""))
|
||||||
if voice_content:
|
if voice_content:
|
||||||
content_parts.append(f"[voice] {voice_content}")
|
content_parts.append(f"[voice] {voice_content}")
|
||||||
else:
|
else:
|
||||||
content_parts.append("[voice]")
|
content_parts.append("[voice]")
|
||||||
|
|
||||||
elif msg_type == "file":
|
elif msg_type == "file":
|
||||||
file_info = body.get("file", {})
|
file_info = cast(dict[str, Any], body.get("file", {}))
|
||||||
file_url = file_info.get("url", "")
|
file_url = cast(str, file_info.get("url", ""))
|
||||||
aes_key = file_info.get("aeskey", "")
|
aes_key = cast(str, file_info.get("aeskey", ""))
|
||||||
file_name = file_info.get("name") or None
|
file_name = cast(str | None, file_info.get("name") or None)
|
||||||
|
|
||||||
if file_url and aes_key:
|
if file_url and aes_key:
|
||||||
file_path = await self._download_and_save_media(file_url, aes_key, "file", file_name)
|
file_path = await self._download_and_save_media(file_url, aes_key, "file", file_name)
|
||||||
@ -308,16 +320,20 @@ class WecomChannel(BaseChannel):
|
|||||||
|
|
||||||
elif msg_type == "mixed":
|
elif msg_type == "mixed":
|
||||||
# Mixed content contains multiple message items
|
# Mixed content contains multiple message items
|
||||||
msg_items = body.get("mixed", {}).get("msg_item", [])
|
mixed_info = cast(dict[str, Any], body.get("mixed", {}))
|
||||||
for item in msg_items:
|
msg_items = cast(list[Any], mixed_info.get("msg_item", []))
|
||||||
item_type = item.get("msgtype", "")
|
for raw_item in msg_items:
|
||||||
|
item = cast(dict[str, Any], raw_item)
|
||||||
|
item_type = cast(str, item.get("msgtype", ""))
|
||||||
if item_type == "text":
|
if item_type == "text":
|
||||||
text = item.get("text", {}).get("content", "")
|
text_info = cast(dict[str, Any], item.get("text", {}))
|
||||||
|
text = cast(str, text_info.get("content", ""))
|
||||||
if text:
|
if text:
|
||||||
content_parts.append(text)
|
content_parts.append(text)
|
||||||
elif item_type == "image":
|
elif item_type == "image":
|
||||||
file_url = item.get("image", {}).get("url", "")
|
image_info = cast(dict[str, Any], item.get("image", {}))
|
||||||
aes_key = item.get("image", {}).get("aeskey", "")
|
file_url = cast(str, image_info.get("url", ""))
|
||||||
|
aes_key = cast(str, image_info.get("aeskey", ""))
|
||||||
if file_url and aes_key:
|
if file_url and aes_key:
|
||||||
file_path = await self._download_and_save_media(file_url, aes_key, "image")
|
file_path = await self._download_and_save_media(file_url, aes_key, "image")
|
||||||
if file_path:
|
if file_path:
|
||||||
@ -385,7 +401,7 @@ class WecomChannel(BaseChannel):
|
|||||||
media_dir = get_media_dir("wecom")
|
media_dir = get_media_dir("wecom")
|
||||||
if not filename:
|
if not filename:
|
||||||
filename = fname or f"{media_type}_{hash(file_url) % 100000}"
|
filename = fname or f"{media_type}_{hash(file_url) % 100000}"
|
||||||
filename = _sanitize_filename(filename)
|
filename = _sanitize_filename(cast(str, filename))
|
||||||
|
|
||||||
file_path = media_dir / filename
|
file_path = media_dir / filename
|
||||||
await asyncio.to_thread(file_path.write_bytes, data)
|
await asyncio.to_thread(file_path.write_bytes, data)
|
||||||
@ -397,8 +413,10 @@ class WecomChannel(BaseChannel):
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
async def _upload_media_ws(
|
async def _upload_media_ws(
|
||||||
self, client: Any, file_path: str,
|
self,
|
||||||
) -> "tuple[str, str] | tuple[None, None]":
|
client: Any,
|
||||||
|
file_path: str,
|
||||||
|
) -> tuple[str, str] | tuple[None, None]:
|
||||||
"""Upload a local file to WeCom via WebSocket 3-step protocol (base64).
|
"""Upload a local file to WeCom via WebSocket 3-step protocol (base64).
|
||||||
|
|
||||||
Uses the WeCom WebSocket upload commands directly via
|
Uses the WeCom WebSocket upload commands directly via
|
||||||
@ -417,7 +435,7 @@ class WecomChannel(BaseChannel):
|
|||||||
media_type = _guess_wecom_media_type(fname)
|
media_type = _guess_wecom_media_type(fname)
|
||||||
|
|
||||||
# Read file size and data in a thread to avoid blocking the event loop
|
# Read file size and data in a thread to avoid blocking the event loop
|
||||||
def _read_file():
|
def _read_file() -> tuple[int, bytes]:
|
||||||
file_size = os.path.getsize(file_path)
|
file_size = os.path.getsize(file_path)
|
||||||
if file_size > WECOM_UPLOAD_MAX_BYTES:
|
if file_size > WECOM_UPLOAD_MAX_BYTES:
|
||||||
raise ValueError(
|
raise ValueError(
|
||||||
@ -530,7 +548,10 @@ class WecomChannel(BaseChannel):
|
|||||||
# Both progress and final messages must use reply_stream (cmd="aibot_respond_msg").
|
# Both progress and final messages must use reply_stream (cmd="aibot_respond_msg").
|
||||||
# The plain reply() uses cmd="reply" which does not support "text" msgtype
|
# The plain reply() uses cmd="reply" which does not support "text" msgtype
|
||||||
# and causes errcode=40008 from WeCom API.
|
# and causes errcode=40008 from WeCom API.
|
||||||
stream_id = self._generate_req_id("stream")
|
generate_req_id = self._generate_req_id
|
||||||
|
if generate_req_id is None:
|
||||||
|
raise RuntimeError("WeCom request-id generator is not initialized")
|
||||||
|
stream_id = generate_req_id("stream")
|
||||||
await self._client.reply_stream(
|
await self._client.reply_stream(
|
||||||
frame,
|
frame,
|
||||||
stream_id,
|
stream_id,
|
||||||
|
|||||||
@ -4,22 +4,22 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import secrets
|
import secrets
|
||||||
import time
|
import time
|
||||||
from contextlib import suppress
|
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from typing import Any
|
from typing import TYPE_CHECKING, Any, cast
|
||||||
|
|
||||||
import httpx
|
|
||||||
|
|
||||||
from nanobot.channels.connect import ChannelConnectError, QueryParams, query_first
|
from nanobot.channels.connect import ChannelConnectError, QueryParams, query_first
|
||||||
from nanobot.config.loader import load_config
|
from nanobot.config.loader import load_config
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from nanobot.channels.weixin.runtime import WeixinChannel
|
||||||
|
|
||||||
|
|
||||||
@dataclass(slots=True)
|
@dataclass(slots=True)
|
||||||
class WeixinConnectSession:
|
class WeixinConnectSession:
|
||||||
id: str
|
id: str
|
||||||
qrcode_id: str
|
qrcode_id: str
|
||||||
qr_url: str
|
qr_url: str
|
||||||
channel: Any
|
channel: WeixinChannel
|
||||||
current_poll_base_url: str
|
current_poll_base_url: str
|
||||||
refresh_count: int
|
refresh_count: int
|
||||||
created_wall: float
|
created_wall: float
|
||||||
@ -58,9 +58,8 @@ class WeixinConnectStore:
|
|||||||
channel = self._build_channel()
|
channel = self._build_channel()
|
||||||
if force:
|
if force:
|
||||||
# Preserve the working account until a replacement scan succeeds.
|
# Preserve the working account until a replacement scan succeeds.
|
||||||
channel._token = ""
|
channel.connect_reset_pending_credentials()
|
||||||
channel._get_updates_buf = ""
|
elif channel.connect_load_state():
|
||||||
elif channel._load_state():
|
|
||||||
return {
|
return {
|
||||||
"session_id": "",
|
"session_id": "",
|
||||||
"status": "succeeded",
|
"status": "succeeded",
|
||||||
@ -68,13 +67,9 @@ class WeixinConnectStore:
|
|||||||
"interval_ms": 2000,
|
"interval_ms": 2000,
|
||||||
}
|
}
|
||||||
|
|
||||||
channel._client = httpx.AsyncClient(
|
channel.connect_open_client()
|
||||||
timeout=httpx.Timeout(60, connect=30),
|
|
||||||
follow_redirects=True,
|
|
||||||
)
|
|
||||||
channel._running = True
|
|
||||||
try:
|
try:
|
||||||
qrcode_id, qr_url = await channel._fetch_qr_code()
|
qrcode_id, qr_url = await channel.connect_fetch_qr_code()
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
await self._close_channel(channel)
|
await self._close_channel(channel)
|
||||||
raise ChannelConnectError(
|
raise ChannelConnectError(
|
||||||
@ -89,7 +84,7 @@ class WeixinConnectStore:
|
|||||||
qrcode_id=qrcode_id,
|
qrcode_id=qrcode_id,
|
||||||
qr_url=qr_url,
|
qr_url=qr_url,
|
||||||
channel=channel,
|
channel=channel,
|
||||||
current_poll_base_url=channel.config.base_url,
|
current_poll_base_url=channel.connect_base_url,
|
||||||
refresh_count=0,
|
refresh_count=0,
|
||||||
created_wall=now_wall,
|
created_wall=now_wall,
|
||||||
deadline=time.monotonic() + 600,
|
deadline=time.monotonic() + 600,
|
||||||
@ -107,14 +102,12 @@ class WeixinConnectStore:
|
|||||||
}
|
}
|
||||||
|
|
||||||
try:
|
try:
|
||||||
status_data = await session.channel._api_get_with_base(
|
status_data = await session.channel.connect_poll_qr_code(
|
||||||
base_url=session.current_poll_base_url,
|
base_url=session.current_poll_base_url,
|
||||||
endpoint="ilink/bot/get_qrcode_status",
|
qrcode_id=session.qrcode_id,
|
||||||
params={"qrcode": session.qrcode_id},
|
|
||||||
auth=False,
|
|
||||||
)
|
)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
if session.channel._is_retryable_qr_poll_error(exc):
|
if session.channel.connect_poll_error_is_retryable(exc):
|
||||||
session.last_error = str(exc)
|
session.last_error = str(exc)
|
||||||
return self._pending_payload(session)
|
return self._pending_payload(session)
|
||||||
self._sessions.pop(session_id, None)
|
self._sessions.pop(session_id, None)
|
||||||
@ -125,10 +118,8 @@ class WeixinConnectStore:
|
|||||||
"message": f"WeChat QR login failed: {exc}",
|
"message": f"WeChat QR login failed: {exc}",
|
||||||
}
|
}
|
||||||
|
|
||||||
if not isinstance(status_data, dict):
|
status_payload = status_data
|
||||||
return self._pending_payload(session)
|
status = status_payload.get("status", "")
|
||||||
|
|
||||||
status = status_data.get("status", "")
|
|
||||||
if status == "confirmed":
|
if status == "confirmed":
|
||||||
if self._sessions.get(session_id) is not session:
|
if self._sessions.get(session_id) is not session:
|
||||||
return {
|
return {
|
||||||
@ -136,7 +127,7 @@ class WeixinConnectStore:
|
|||||||
"status": "cancelled",
|
"status": "cancelled",
|
||||||
"message": "WeChat login cancelled.",
|
"message": "WeChat login cancelled.",
|
||||||
}
|
}
|
||||||
token = str(status_data.get("bot_token", "") or "")
|
token = str(status_payload.get("bot_token", "") or "")
|
||||||
if not token:
|
if not token:
|
||||||
self._sessions.pop(session_id, None)
|
self._sessions.pop(session_id, None)
|
||||||
await self._close_channel(session.channel)
|
await self._close_channel(session.channel)
|
||||||
@ -145,22 +136,19 @@ class WeixinConnectStore:
|
|||||||
"status": "failed",
|
"status": "failed",
|
||||||
"message": "WeChat confirmed the scan but returned no token.",
|
"message": "WeChat confirmed the scan but returned no token.",
|
||||||
}
|
}
|
||||||
base_url = str(status_data.get("baseurl", "") or "")
|
base_url = str(status_payload.get("baseurl", "") or "")
|
||||||
session.channel._token = token
|
session.channel.connect_commit_account(token=token, base_url=base_url)
|
||||||
if base_url:
|
|
||||||
session.channel.config.base_url = base_url
|
|
||||||
session.channel._save_state()
|
|
||||||
self._sessions.pop(session_id, None)
|
self._sessions.pop(session_id, None)
|
||||||
await self._close_channel(session.channel)
|
await self._close_channel(session.channel)
|
||||||
return {
|
return {
|
||||||
"session_id": session_id,
|
"session_id": session_id,
|
||||||
"status": "succeeded",
|
"status": "succeeded",
|
||||||
"message": "WeChat is connected.",
|
"message": "WeChat is connected.",
|
||||||
"account": str(status_data.get("ilink_user_id", "") or ""),
|
"account": str(status_payload.get("ilink_user_id", "") or ""),
|
||||||
}
|
}
|
||||||
|
|
||||||
if status == "scaned_but_redirect":
|
if status == "scaned_but_redirect":
|
||||||
redirect_host = str(status_data.get("redirect_host", "") or "").strip()
|
redirect_host = str(status_payload.get("redirect_host", "") or "").strip()
|
||||||
if redirect_host:
|
if redirect_host:
|
||||||
session.current_poll_base_url = (
|
session.current_poll_base_url = (
|
||||||
redirect_host
|
redirect_host
|
||||||
@ -182,7 +170,9 @@ class WeixinConnectStore:
|
|||||||
"message": "This WeChat QR code expired. Start again.",
|
"message": "This WeChat QR code expired. Start again.",
|
||||||
}
|
}
|
||||||
try:
|
try:
|
||||||
session.qrcode_id, session.qr_url = await session.channel._fetch_qr_code()
|
session.qrcode_id, session.qr_url = (
|
||||||
|
await session.channel.connect_fetch_qr_code()
|
||||||
|
)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
self._sessions.pop(session_id, None)
|
self._sessions.pop(session_id, None)
|
||||||
await self._close_channel(session.channel)
|
await self._close_channel(session.channel)
|
||||||
@ -191,7 +181,7 @@ class WeixinConnectStore:
|
|||||||
"status": "failed",
|
"status": "failed",
|
||||||
"message": f"Could not refresh WeChat QR code: {exc}",
|
"message": f"Could not refresh WeChat QR code: {exc}",
|
||||||
}
|
}
|
||||||
session.current_poll_base_url = session.channel.config.base_url
|
session.current_poll_base_url = session.channel.connect_base_url
|
||||||
return self._pending_payload(session)
|
return self._pending_payload(session)
|
||||||
|
|
||||||
return self._pending_payload(session)
|
return self._pending_payload(session)
|
||||||
@ -219,27 +209,22 @@ class WeixinConnectStore:
|
|||||||
await self._close_channel(session.channel)
|
await self._close_channel(session.channel)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _build_channel() -> Any:
|
def _build_channel() -> WeixinChannel:
|
||||||
from nanobot.bus.queue import MessageBus
|
from nanobot.bus.queue import MessageBus
|
||||||
from nanobot.channels.weixin.runtime import WeixinChannel
|
from nanobot.channels.weixin.runtime import WeixinChannel
|
||||||
|
|
||||||
section = getattr(load_config().channels, "weixin", None)
|
section = getattr(load_config().channels, "weixin", None)
|
||||||
if hasattr(section, "model_dump"):
|
if section is not None and hasattr(section, "model_dump"):
|
||||||
config = section.model_dump(mode="json", by_alias=True)
|
config = section.model_dump(mode="json", by_alias=True)
|
||||||
elif isinstance(section, dict):
|
elif isinstance(section, dict):
|
||||||
config = dict(section)
|
config = dict(cast(dict[str, Any], section))
|
||||||
else:
|
else:
|
||||||
config = {}
|
config = {}
|
||||||
return WeixinChannel(config, MessageBus())
|
return WeixinChannel(config, MessageBus())
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
async def _close_channel(channel: Any) -> None:
|
async def _close_channel(channel: WeixinChannel) -> None:
|
||||||
channel._running = False
|
await channel.connect_close_client()
|
||||||
client = getattr(channel, "_client", None)
|
|
||||||
if client is not None:
|
|
||||||
with suppress(Exception):
|
|
||||||
await client.aclose()
|
|
||||||
channel._client = None
|
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _start_payload(session: WeixinConnectSession) -> dict[str, Any]:
|
def _start_payload(session: WeixinConnectSession) -> dict[str, Any]:
|
||||||
|
|||||||
@ -21,7 +21,7 @@ import uuid
|
|||||||
from collections import OrderedDict
|
from collections import OrderedDict
|
||||||
from contextlib import suppress
|
from contextlib import suppress
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any
|
from typing import Any, cast
|
||||||
from urllib.parse import quote
|
from urllib.parse import quote
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
@ -168,10 +168,10 @@ class WeixinChannel(BaseChannel):
|
|||||||
self._processed_ids: OrderedDict[str, None] = OrderedDict()
|
self._processed_ids: OrderedDict[str, None] = OrderedDict()
|
||||||
self._state_dir: Path | None = None
|
self._state_dir: Path | None = None
|
||||||
self._token: str = ""
|
self._token: str = ""
|
||||||
self._poll_task: asyncio.Task | None = None
|
self._poll_task: asyncio.Task[None] | None = None
|
||||||
self._next_poll_timeout_s: int = DEFAULT_LONG_POLL_TIMEOUT_S
|
self._next_poll_timeout_s: int = DEFAULT_LONG_POLL_TIMEOUT_S
|
||||||
self._session_pause_until: float = 0.0
|
self._session_pause_until: float = 0.0
|
||||||
self._typing_tasks: dict[str, asyncio.Task] = {}
|
self._typing_tasks: dict[str, asyncio.Task[None]] = {}
|
||||||
self._typing_tickets: dict[str, dict[str, Any]] = {}
|
self._typing_tickets: dict[str, dict[str, Any]] = {}
|
||||||
self._context_token_at: dict[str, float] = {}
|
self._context_token_at: dict[str, float] = {}
|
||||||
self._pending_tool_hints: dict[str, list[str]] = {}
|
self._pending_tool_hints: dict[str, list[str]] = {}
|
||||||
@ -201,14 +201,14 @@ class WeixinChannel(BaseChannel):
|
|||||||
if not state_file.exists():
|
if not state_file.exists():
|
||||||
return False
|
return False
|
||||||
try:
|
try:
|
||||||
data = json.loads(state_file.read_text())
|
data = cast(dict[str, Any], json.loads(state_file.read_text()))
|
||||||
self._token = data.get("token", "")
|
self._token = data.get("token", "")
|
||||||
self._get_updates_buf = data.get("get_updates_buf", "")
|
self._get_updates_buf = data.get("get_updates_buf", "")
|
||||||
context_tokens = data.get("context_tokens", {})
|
context_tokens = data.get("context_tokens", {})
|
||||||
if isinstance(context_tokens, dict):
|
if isinstance(context_tokens, dict):
|
||||||
self._context_tokens = {
|
self._context_tokens = {
|
||||||
str(user_id): str(token)
|
str(user_id): str(token)
|
||||||
for user_id, token in context_tokens.items()
|
for user_id, token in cast(dict[object, object], context_tokens).items()
|
||||||
if str(user_id).strip() and str(token).strip()
|
if str(user_id).strip() and str(token).strip()
|
||||||
}
|
}
|
||||||
else:
|
else:
|
||||||
@ -216,8 +216,8 @@ class WeixinChannel(BaseChannel):
|
|||||||
typing_tickets = data.get("typing_tickets", {})
|
typing_tickets = data.get("typing_tickets", {})
|
||||||
if isinstance(typing_tickets, dict):
|
if isinstance(typing_tickets, dict):
|
||||||
self._typing_tickets = {
|
self._typing_tickets = {
|
||||||
str(user_id): ticket
|
str(user_id): cast(dict[str, Any], ticket)
|
||||||
for user_id, ticket in typing_tickets.items()
|
for user_id, ticket in cast(dict[object, object], typing_tickets).items()
|
||||||
if str(user_id).strip() and isinstance(ticket, dict)
|
if str(user_id).strip() and isinstance(ticket, dict)
|
||||||
}
|
}
|
||||||
else:
|
else:
|
||||||
@ -276,18 +276,22 @@ class WeixinChannel(BaseChannel):
|
|||||||
if isinstance(err, httpx.TimeoutException | httpx.TransportError):
|
if isinstance(err, httpx.TimeoutException | httpx.TransportError):
|
||||||
return True
|
return True
|
||||||
if isinstance(err, httpx.HTTPStatusError):
|
if isinstance(err, httpx.HTTPStatusError):
|
||||||
status_code = err.response.status_code if err.response is not None else 0
|
status_code = (
|
||||||
|
err.response.status_code
|
||||||
|
if cast(object, err.response) is not None
|
||||||
|
else 0
|
||||||
|
)
|
||||||
return status_code >= 500
|
return status_code >= 500
|
||||||
return False
|
return False
|
||||||
|
|
||||||
async def _api_get(
|
async def _api_get(
|
||||||
self,
|
self,
|
||||||
endpoint: str,
|
endpoint: str,
|
||||||
params: dict | None = None,
|
params: dict[str, Any] | None = None,
|
||||||
*,
|
*,
|
||||||
auth: bool = True,
|
auth: bool = True,
|
||||||
extra_headers: dict[str, str] | None = None,
|
extra_headers: dict[str, str] | None = None,
|
||||||
) -> dict:
|
) -> dict[str, Any]:
|
||||||
assert self._client is not None
|
assert self._client is not None
|
||||||
url = f"{self.config.base_url}/{endpoint}"
|
url = f"{self.config.base_url}/{endpoint}"
|
||||||
hdrs = self._make_headers(auth=auth)
|
hdrs = self._make_headers(auth=auth)
|
||||||
@ -295,17 +299,17 @@ class WeixinChannel(BaseChannel):
|
|||||||
hdrs.update(extra_headers)
|
hdrs.update(extra_headers)
|
||||||
resp = await self._client.get(url, params=params, headers=hdrs)
|
resp = await self._client.get(url, params=params, headers=hdrs)
|
||||||
resp.raise_for_status()
|
resp.raise_for_status()
|
||||||
return resp.json()
|
return cast(dict[str, Any], resp.json())
|
||||||
|
|
||||||
async def _api_get_with_base(
|
async def _api_get_with_base(
|
||||||
self,
|
self,
|
||||||
*,
|
*,
|
||||||
base_url: str,
|
base_url: str,
|
||||||
endpoint: str,
|
endpoint: str,
|
||||||
params: dict | None = None,
|
params: dict[str, Any] | None = None,
|
||||||
auth: bool = True,
|
auth: bool = True,
|
||||||
extra_headers: dict[str, str] | None = None,
|
extra_headers: dict[str, str] | None = None,
|
||||||
) -> dict:
|
) -> dict[str, Any]:
|
||||||
"""GET helper that allows overriding base_url for QR redirect polling."""
|
"""GET helper that allows overriding base_url for QR redirect polling."""
|
||||||
assert self._client is not None
|
assert self._client is not None
|
||||||
url = f"{base_url.rstrip('/')}/{endpoint}"
|
url = f"{base_url.rstrip('/')}/{endpoint}"
|
||||||
@ -314,15 +318,15 @@ class WeixinChannel(BaseChannel):
|
|||||||
hdrs.update(extra_headers)
|
hdrs.update(extra_headers)
|
||||||
resp = await self._client.get(url, params=params, headers=hdrs)
|
resp = await self._client.get(url, params=params, headers=hdrs)
|
||||||
resp.raise_for_status()
|
resp.raise_for_status()
|
||||||
return resp.json()
|
return cast(dict[str, Any], resp.json())
|
||||||
|
|
||||||
async def _api_post(
|
async def _api_post(
|
||||||
self,
|
self,
|
||||||
endpoint: str,
|
endpoint: str,
|
||||||
body: dict | None = None,
|
body: dict[str, Any] | None = None,
|
||||||
*,
|
*,
|
||||||
auth: bool = True,
|
auth: bool = True,
|
||||||
) -> dict:
|
) -> dict[str, Any]:
|
||||||
assert self._client is not None
|
assert self._client is not None
|
||||||
url = f"{self.config.base_url}/{endpoint}"
|
url = f"{self.config.base_url}/{endpoint}"
|
||||||
payload = body or {}
|
payload = body or {}
|
||||||
@ -330,7 +334,7 @@ class WeixinChannel(BaseChannel):
|
|||||||
payload["base_info"] = BASE_INFO
|
payload["base_info"] = BASE_INFO
|
||||||
resp = await self._client.post(url, json=payload, headers=self._make_headers(auth=auth))
|
resp = await self._client.post(url, json=payload, headers=self._make_headers(auth=auth))
|
||||||
resp.raise_for_status()
|
resp.raise_for_status()
|
||||||
return resp.json()
|
return cast(dict[str, Any], resp.json())
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
# QR Code Login (matches login-qr.ts)
|
# QR Code Login (matches login-qr.ts)
|
||||||
@ -343,8 +347,8 @@ class WeixinChannel(BaseChannel):
|
|||||||
params={"bot_type": "3"},
|
params={"bot_type": "3"},
|
||||||
auth=False,
|
auth=False,
|
||||||
)
|
)
|
||||||
qrcode_img_content = data.get("qrcode_img_content", "")
|
qrcode_img_content = cast(str, data.get("qrcode_img_content", ""))
|
||||||
qrcode_id = data.get("qrcode", "")
|
qrcode_id = cast(str, data.get("qrcode", ""))
|
||||||
if not qrcode_id:
|
if not qrcode_id:
|
||||||
raise RuntimeError(f"Failed to get QR code from WeChat API: {data}")
|
raise RuntimeError(f"Failed to get QR code from WeChat API: {data}")
|
||||||
return qrcode_id, (qrcode_img_content or qrcode_id)
|
return qrcode_id, (qrcode_img_content or qrcode_id)
|
||||||
@ -371,7 +375,7 @@ class WeixinChannel(BaseChannel):
|
|||||||
continue
|
continue
|
||||||
raise
|
raise
|
||||||
|
|
||||||
if not isinstance(status_data, dict):
|
if not isinstance(cast(object, status_data), dict):
|
||||||
await asyncio.sleep(1)
|
await asyncio.sleep(1)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
@ -431,15 +435,73 @@ class WeixinChannel(BaseChannel):
|
|||||||
if isinstance(err, httpx.TimeoutException | httpx.TransportError):
|
if isinstance(err, httpx.TimeoutException | httpx.TransportError):
|
||||||
return True
|
return True
|
||||||
if isinstance(err, httpx.HTTPStatusError):
|
if isinstance(err, httpx.HTTPStatusError):
|
||||||
status_code = err.response.status_code if err.response is not None else 0
|
status_code = (
|
||||||
|
err.response.status_code
|
||||||
|
if cast(object, err.response) is not None
|
||||||
|
else 0
|
||||||
|
)
|
||||||
if status_code >= 500:
|
if status_code >= 500:
|
||||||
return True
|
return True
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
@property
|
||||||
|
def connect_base_url(self) -> str:
|
||||||
|
"""Base URL currently selected for the interactive connection flow."""
|
||||||
|
return self.config.base_url
|
||||||
|
|
||||||
|
def connect_reset_pending_credentials(self) -> None:
|
||||||
|
"""Clear only in-memory credentials while a replacement QR login is pending."""
|
||||||
|
self._token = ""
|
||||||
|
self._get_updates_buf = ""
|
||||||
|
|
||||||
|
def connect_load_state(self) -> bool:
|
||||||
|
"""Load an existing account for the interactive connection flow."""
|
||||||
|
return self._load_state()
|
||||||
|
|
||||||
|
def connect_open_client(self) -> None:
|
||||||
|
"""Open the short-lived HTTP client used by WebUI QR login."""
|
||||||
|
self._client = httpx.AsyncClient(
|
||||||
|
timeout=httpx.Timeout(60, connect=30),
|
||||||
|
follow_redirects=True,
|
||||||
|
)
|
||||||
|
self._running = True
|
||||||
|
|
||||||
|
async def connect_fetch_qr_code(self) -> tuple[str, str]:
|
||||||
|
return await self._fetch_qr_code()
|
||||||
|
|
||||||
|
async def connect_poll_qr_code(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
base_url: str,
|
||||||
|
qrcode_id: str,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
return await self._api_get_with_base(
|
||||||
|
base_url=base_url,
|
||||||
|
endpoint="ilink/bot/get_qrcode_status",
|
||||||
|
params={"qrcode": qrcode_id},
|
||||||
|
auth=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
def connect_poll_error_is_retryable(self, err: Exception) -> bool:
|
||||||
|
return self._is_retryable_qr_poll_error(err)
|
||||||
|
|
||||||
|
def connect_commit_account(self, *, token: str, base_url: str) -> None:
|
||||||
|
self._token = token
|
||||||
|
if base_url:
|
||||||
|
self.config.base_url = base_url
|
||||||
|
self._save_state()
|
||||||
|
|
||||||
|
async def connect_close_client(self) -> None:
|
||||||
|
self._running = False
|
||||||
|
if self._client is not None:
|
||||||
|
with suppress(Exception):
|
||||||
|
await self._client.aclose()
|
||||||
|
self._client = None
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _print_qr_code(url: str) -> None:
|
def _print_qr_code(url: str) -> None:
|
||||||
try:
|
try:
|
||||||
import qrcode as qr_lib
|
import qrcode as qr_lib # pyright: ignore[reportMissingModuleSource]
|
||||||
|
|
||||||
qr = qr_lib.QRCode(border=1)
|
qr = qr_lib.QRCode(border=1)
|
||||||
qr.add_data(url)
|
qr.add_data(url)
|
||||||
@ -596,7 +658,7 @@ class WeixinChannel(BaseChannel):
|
|||||||
self._save_state()
|
self._save_state()
|
||||||
|
|
||||||
# Process messages (WeixinMessage[] from types.ts)
|
# Process messages (WeixinMessage[] from types.ts)
|
||||||
msgs: list[dict] = data.get("msgs", []) or []
|
msgs = cast(list[dict[str, Any]], data.get("msgs", []) or [])
|
||||||
for msg in msgs:
|
for msg in msgs:
|
||||||
try:
|
try:
|
||||||
await self._process_message(msg)
|
await self._process_message(msg)
|
||||||
@ -607,7 +669,7 @@ class WeixinChannel(BaseChannel):
|
|||||||
# Inbound message processing (matches inbound.ts + process-message.ts)
|
# Inbound message processing (matches inbound.ts + process-message.ts)
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
async def _process_message(self, msg: dict) -> None:
|
async def _process_message(self, msg: dict[str, Any]) -> None:
|
||||||
"""Process a single WeixinMessage from getUpdates."""
|
"""Process a single WeixinMessage from getUpdates."""
|
||||||
# Skip bot's own messages (message_type 2 = BOT)
|
# Skip bot's own messages (message_type 2 = BOT)
|
||||||
if msg.get("message_type") == MESSAGE_TYPE_BOT:
|
if msg.get("message_type") == MESSAGE_TYPE_BOT:
|
||||||
@ -679,7 +741,7 @@ class WeixinChannel(BaseChannel):
|
|||||||
self._save_state()
|
self._save_state()
|
||||||
|
|
||||||
# Parse item_list (WeixinMessage.item_list — types.ts:161)
|
# Parse item_list (WeixinMessage.item_list — types.ts:161)
|
||||||
item_list: list[dict] = msg.get("item_list") or []
|
item_list = cast(list[dict[str, Any]], msg.get("item_list") or [])
|
||||||
content_parts: list[str] = []
|
content_parts: list[str] = []
|
||||||
media_paths: list[str] = []
|
media_paths: list[str] = []
|
||||||
has_top_level_downloadable_media = False
|
has_top_level_downloadable_media = False
|
||||||
@ -688,12 +750,16 @@ class WeixinChannel(BaseChannel):
|
|||||||
item_type = item.get("type", 0)
|
item_type = item.get("type", 0)
|
||||||
|
|
||||||
if item_type == ITEM_TEXT:
|
if item_type == ITEM_TEXT:
|
||||||
text = (item.get("text_item") or {}).get("text", "")
|
text_item = cast(dict[str, Any], item.get("text_item") or {})
|
||||||
|
text = cast(str, text_item.get("text", ""))
|
||||||
if text:
|
if text:
|
||||||
# Handle quoted/ref messages (inbound.ts:86-98)
|
# Handle quoted/ref messages (inbound.ts:86-98)
|
||||||
ref = item.get("ref_msg")
|
ref = cast(dict[str, Any] | None, item.get("ref_msg"))
|
||||||
if ref:
|
if ref:
|
||||||
ref_item = ref.get("message_item")
|
ref_item = cast(
|
||||||
|
dict[str, Any] | None,
|
||||||
|
ref.get("message_item"),
|
||||||
|
)
|
||||||
# If quoted message is media, just pass the text
|
# If quoted message is media, just pass the text
|
||||||
if ref_item and ref_item.get("type", 0) in (
|
if ref_item and ref_item.get("type", 0) in (
|
||||||
ITEM_IMAGE,
|
ITEM_IMAGE,
|
||||||
@ -705,9 +771,13 @@ class WeixinChannel(BaseChannel):
|
|||||||
else:
|
else:
|
||||||
parts: list[str] = []
|
parts: list[str] = []
|
||||||
if ref.get("title"):
|
if ref.get("title"):
|
||||||
parts.append(ref["title"])
|
parts.append(cast(str, ref["title"]))
|
||||||
if ref_item:
|
if ref_item:
|
||||||
ref_text = (ref_item.get("text_item") or {}).get("text", "")
|
ref_text_item = cast(
|
||||||
|
dict[str, Any],
|
||||||
|
ref_item.get("text_item") or {},
|
||||||
|
)
|
||||||
|
ref_text = cast(str, ref_text_item.get("text", ""))
|
||||||
if ref_text:
|
if ref_text:
|
||||||
parts.append(ref_text)
|
parts.append(ref_text)
|
||||||
if parts:
|
if parts:
|
||||||
@ -718,7 +788,7 @@ class WeixinChannel(BaseChannel):
|
|||||||
content_parts.append(text)
|
content_parts.append(text)
|
||||||
|
|
||||||
elif item_type == ITEM_IMAGE:
|
elif item_type == ITEM_IMAGE:
|
||||||
image_item = item.get("image_item") or {}
|
image_item = cast(dict[str, Any], item.get("image_item") or {})
|
||||||
if _has_downloadable_media_locator(image_item.get("media")):
|
if _has_downloadable_media_locator(image_item.get("media")):
|
||||||
has_top_level_downloadable_media = True
|
has_top_level_downloadable_media = True
|
||||||
file_path = await self._download_media_item(image_item, "image")
|
file_path = await self._download_media_item(image_item, "image")
|
||||||
@ -729,9 +799,9 @@ class WeixinChannel(BaseChannel):
|
|||||||
content_parts.append("[image]")
|
content_parts.append("[image]")
|
||||||
|
|
||||||
elif item_type == ITEM_VOICE:
|
elif item_type == ITEM_VOICE:
|
||||||
voice_item = item.get("voice_item") or {}
|
voice_item = cast(dict[str, Any], item.get("voice_item") or {})
|
||||||
# Voice-to-text provided by WeChat (inbound.ts:101-103)
|
# Voice-to-text provided by WeChat (inbound.ts:101-103)
|
||||||
voice_text = voice_item.get("text", "")
|
voice_text = cast(str, voice_item.get("text", ""))
|
||||||
if voice_text:
|
if voice_text:
|
||||||
content_parts.append(f"[voice] {voice_text}")
|
content_parts.append(f"[voice] {voice_text}")
|
||||||
else:
|
else:
|
||||||
@ -749,10 +819,10 @@ class WeixinChannel(BaseChannel):
|
|||||||
content_parts.append("[voice]")
|
content_parts.append("[voice]")
|
||||||
|
|
||||||
elif item_type == ITEM_FILE:
|
elif item_type == ITEM_FILE:
|
||||||
file_item = item.get("file_item") or {}
|
file_item = cast(dict[str, Any], item.get("file_item") or {})
|
||||||
if _has_downloadable_media_locator(file_item.get("media")):
|
if _has_downloadable_media_locator(file_item.get("media")):
|
||||||
has_top_level_downloadable_media = True
|
has_top_level_downloadable_media = True
|
||||||
file_name = file_item.get("file_name", "unknown")
|
file_name = cast(str, file_item.get("file_name", "unknown"))
|
||||||
file_path = await self._download_media_item(
|
file_path = await self._download_media_item(
|
||||||
file_item,
|
file_item,
|
||||||
"file",
|
"file",
|
||||||
@ -765,7 +835,7 @@ class WeixinChannel(BaseChannel):
|
|||||||
content_parts.append(f"[file: {file_name}]")
|
content_parts.append(f"[file: {file_name}]")
|
||||||
|
|
||||||
elif item_type == ITEM_VIDEO:
|
elif item_type == ITEM_VIDEO:
|
||||||
video_item = item.get("video_item") or {}
|
video_item = cast(dict[str, Any], item.get("video_item") or {})
|
||||||
if _has_downloadable_media_locator(video_item.get("media")):
|
if _has_downloadable_media_locator(video_item.get("media")):
|
||||||
has_top_level_downloadable_media = True
|
has_top_level_downloadable_media = True
|
||||||
file_path = await self._download_media_item(video_item, "video")
|
file_path = await self._download_media_item(video_item, "video")
|
||||||
@ -783,8 +853,8 @@ class WeixinChannel(BaseChannel):
|
|||||||
for item in item_list:
|
for item in item_list:
|
||||||
if item.get("type", 0) != ITEM_TEXT:
|
if item.get("type", 0) != ITEM_TEXT:
|
||||||
continue
|
continue
|
||||||
ref = item.get("ref_msg") or {}
|
ref = cast(dict[str, Any], item.get("ref_msg") or {})
|
||||||
candidate = ref.get("message_item") or {}
|
candidate = cast(dict[str, Any], ref.get("message_item") or {})
|
||||||
if candidate.get("type", 0) in (ITEM_IMAGE, ITEM_VOICE, ITEM_FILE, ITEM_VIDEO):
|
if candidate.get("type", 0) in (ITEM_IMAGE, ITEM_VOICE, ITEM_FILE, ITEM_VIDEO):
|
||||||
ref_media_item = candidate
|
ref_media_item = candidate
|
||||||
break
|
break
|
||||||
@ -792,13 +862,19 @@ class WeixinChannel(BaseChannel):
|
|||||||
if ref_media_item:
|
if ref_media_item:
|
||||||
ref_type = ref_media_item.get("type", 0)
|
ref_type = ref_media_item.get("type", 0)
|
||||||
if ref_type == ITEM_IMAGE:
|
if ref_type == ITEM_IMAGE:
|
||||||
image_item = ref_media_item.get("image_item") or {}
|
image_item = cast(
|
||||||
|
dict[str, Any],
|
||||||
|
ref_media_item.get("image_item") or {},
|
||||||
|
)
|
||||||
file_path = await self._download_media_item(image_item, "image")
|
file_path = await self._download_media_item(image_item, "image")
|
||||||
if file_path:
|
if file_path:
|
||||||
content_parts.append(f"[image]\n[Image: source: {file_path}]")
|
content_parts.append(f"[image]\n[Image: source: {file_path}]")
|
||||||
media_paths.append(file_path)
|
media_paths.append(file_path)
|
||||||
elif ref_type == ITEM_VOICE:
|
elif ref_type == ITEM_VOICE:
|
||||||
voice_item = ref_media_item.get("voice_item") or {}
|
voice_item = cast(
|
||||||
|
dict[str, Any],
|
||||||
|
ref_media_item.get("voice_item") or {},
|
||||||
|
)
|
||||||
file_path = await self._download_media_item(voice_item, "voice")
|
file_path = await self._download_media_item(voice_item, "voice")
|
||||||
if file_path:
|
if file_path:
|
||||||
transcription = await self.transcribe_audio(file_path)
|
transcription = await self.transcribe_audio(file_path)
|
||||||
@ -808,14 +884,20 @@ class WeixinChannel(BaseChannel):
|
|||||||
content_parts.append(f"[voice]\n[Audio: source: {file_path}]")
|
content_parts.append(f"[voice]\n[Audio: source: {file_path}]")
|
||||||
media_paths.append(file_path)
|
media_paths.append(file_path)
|
||||||
elif ref_type == ITEM_FILE:
|
elif ref_type == ITEM_FILE:
|
||||||
file_item = ref_media_item.get("file_item") or {}
|
file_item = cast(
|
||||||
file_name = file_item.get("file_name", "unknown")
|
dict[str, Any],
|
||||||
|
ref_media_item.get("file_item") or {},
|
||||||
|
)
|
||||||
|
file_name = cast(str, file_item.get("file_name", "unknown"))
|
||||||
file_path = await self._download_media_item(file_item, "file", file_name)
|
file_path = await self._download_media_item(file_item, "file", file_name)
|
||||||
if file_path:
|
if file_path:
|
||||||
content_parts.append(f"[file: {file_name}]\n[File: source: {file_path}]")
|
content_parts.append(f"[file: {file_name}]\n[File: source: {file_path}]")
|
||||||
media_paths.append(file_path)
|
media_paths.append(file_path)
|
||||||
elif ref_type == ITEM_VIDEO:
|
elif ref_type == ITEM_VIDEO:
|
||||||
video_item = ref_media_item.get("video_item") or {}
|
video_item = cast(
|
||||||
|
dict[str, Any],
|
||||||
|
ref_media_item.get("video_item") or {},
|
||||||
|
)
|
||||||
file_path = await self._download_media_item(video_item, "video")
|
file_path = await self._download_media_item(video_item, "video")
|
||||||
if file_path:
|
if file_path:
|
||||||
content_parts.append(f"[video]\n[Video: source: {file_path}]")
|
content_parts.append(f"[video]\n[Video: source: {file_path}]")
|
||||||
@ -848,13 +930,13 @@ class WeixinChannel(BaseChannel):
|
|||||||
|
|
||||||
async def _download_media_item(
|
async def _download_media_item(
|
||||||
self,
|
self,
|
||||||
typed_item: dict,
|
typed_item: dict[str, Any],
|
||||||
media_type: str,
|
media_type: str,
|
||||||
filename: str | None = None,
|
filename: str | None = None,
|
||||||
) -> str | None:
|
) -> str | None:
|
||||||
"""Download + AES-decrypt a media item. Returns local path or None."""
|
"""Download + AES-decrypt a media item. Returns local path or None."""
|
||||||
try:
|
try:
|
||||||
media = typed_item.get("media") or {}
|
media = cast(dict[str, Any], typed_item.get("media") or {})
|
||||||
encrypt_query_param = str(media.get("encrypt_query_param", "") or "")
|
encrypt_query_param = str(media.get("encrypt_query_param", "") or "")
|
||||||
full_url = str(media.get("full_url", "") or "").strip()
|
full_url = str(media.get("full_url", "") or "").strip()
|
||||||
|
|
||||||
@ -865,8 +947,8 @@ class WeixinChannel(BaseChannel):
|
|||||||
# image_item.aeskey is a raw hex string (16 bytes as 32 hex chars).
|
# image_item.aeskey is a raw hex string (16 bytes as 32 hex chars).
|
||||||
# media.aes_key is always base64-encoded.
|
# media.aes_key is always base64-encoded.
|
||||||
# For images, prefer image_item.aeskey; for others use media.aes_key.
|
# For images, prefer image_item.aeskey; for others use media.aes_key.
|
||||||
raw_aeskey_hex = typed_item.get("aeskey", "")
|
raw_aeskey_hex = cast(str, typed_item.get("aeskey", ""))
|
||||||
media_aes_key_b64 = media.get("aes_key", "")
|
media_aes_key_b64 = cast(str, media.get("aes_key", ""))
|
||||||
|
|
||||||
aes_key_b64: str = ""
|
aes_key_b64: str = ""
|
||||||
if raw_aeskey_hex:
|
if raw_aeskey_hex:
|
||||||
@ -1160,7 +1242,7 @@ class WeixinChannel(BaseChannel):
|
|||||||
await self._send_typing(msg.chat_id, typing_ticket, TYPING_STATUS_TYPING)
|
await self._send_typing(msg.chat_id, typing_ticket, TYPING_STATUS_TYPING)
|
||||||
|
|
||||||
typing_keepalive_stop = asyncio.Event()
|
typing_keepalive_stop = asyncio.Event()
|
||||||
typing_keepalive_task: asyncio.Task | None = None
|
typing_keepalive_task: asyncio.Task[None] | None = None
|
||||||
if typing_ticket:
|
if typing_ticket:
|
||||||
typing_keepalive_task = asyncio.create_task(
|
typing_keepalive_task = asyncio.create_task(
|
||||||
self._typing_keepalive_loop(msg.chat_id, typing_ticket, typing_keepalive_stop)
|
self._typing_keepalive_loop(msg.chat_id, typing_ticket, typing_keepalive_stop)
|
||||||
@ -1183,7 +1265,7 @@ class WeixinChannel(BaseChannel):
|
|||||||
except httpx.HTTPStatusError as http_err:
|
except httpx.HTTPStatusError as http_err:
|
||||||
status_code = (
|
status_code = (
|
||||||
http_err.response.status_code
|
http_err.response.status_code
|
||||||
if http_err.response is not None
|
if cast(object, http_err.response) is not None
|
||||||
else 0
|
else 0
|
||||||
)
|
)
|
||||||
if status_code >= 500:
|
if status_code >= 500:
|
||||||
@ -1192,7 +1274,7 @@ class WeixinChannel(BaseChannel):
|
|||||||
"Server error ({} {}) sending media {}",
|
"Server error ({} {}) sending media {}",
|
||||||
status_code,
|
status_code,
|
||||||
http_err.response.reason_phrase
|
http_err.response.reason_phrase
|
||||||
if http_err.response is not None
|
if cast(object, http_err.response) is not None
|
||||||
else "",
|
else "",
|
||||||
media_path,
|
media_path,
|
||||||
)
|
)
|
||||||
@ -1342,7 +1424,7 @@ class WeixinChannel(BaseChannel):
|
|||||||
"""Send a text message matching the exact protocol from send.ts."""
|
"""Send a text message matching the exact protocol from send.ts."""
|
||||||
client_id = f"nanobot-{uuid.uuid4().hex[:12]}"
|
client_id = f"nanobot-{uuid.uuid4().hex[:12]}"
|
||||||
|
|
||||||
item_list: list[dict] = []
|
item_list: list[dict[str, Any]] = []
|
||||||
if text:
|
if text:
|
||||||
item_list.append({"type": ITEM_TEXT, "text_item": {"text": text}})
|
item_list.append({"type": ITEM_TEXT, "text_item": {"text": text}})
|
||||||
|
|
||||||
@ -1496,7 +1578,9 @@ class WeixinChannel(BaseChannel):
|
|||||||
|
|
||||||
# Send each media item as its own message (matching reference plugin)
|
# Send each media item as its own message (matching reference plugin)
|
||||||
client_id = f"nanobot-{uuid.uuid4().hex[:12]}"
|
client_id = f"nanobot-{uuid.uuid4().hex[:12]}"
|
||||||
item_list: list[dict] = [{"type": item_type, item_key: media_item}]
|
item_list: list[dict[str, Any]] = [
|
||||||
|
{"type": item_type, item_key: media_item}
|
||||||
|
]
|
||||||
|
|
||||||
weixin_msg: dict[str, Any] = {
|
weixin_msg: dict[str, Any] = {
|
||||||
"from_user_id": "",
|
"from_user_id": "",
|
||||||
@ -1565,7 +1649,8 @@ def _encrypt_aes_ecb(data: bytes, aes_key_b64: str) -> bytes:
|
|||||||
with suppress(ImportError):
|
with suppress(ImportError):
|
||||||
from Crypto.Cipher import AES
|
from Crypto.Cipher import AES
|
||||||
|
|
||||||
cipher = AES.new(key, AES.MODE_ECB)
|
aes_module = cast(Any, AES)
|
||||||
|
cipher = aes_module.new(key, aes_module.MODE_ECB)
|
||||||
return cipher.encrypt(padded)
|
return cipher.encrypt(padded)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@ -1595,7 +1680,8 @@ def _decrypt_aes_ecb(data: bytes, aes_key_b64: str) -> bytes:
|
|||||||
with suppress(ImportError):
|
with suppress(ImportError):
|
||||||
from Crypto.Cipher import AES
|
from Crypto.Cipher import AES
|
||||||
|
|
||||||
cipher = AES.new(key, AES.MODE_ECB)
|
aes_module = cast(Any, AES)
|
||||||
|
cipher = aes_module.new(key, aes_module.MODE_ECB)
|
||||||
decrypted = cipher.decrypt(data)
|
decrypted = cipher.decrypt(data)
|
||||||
|
|
||||||
if decrypted is None:
|
if decrypted is None:
|
||||||
|
|||||||
@ -1,3 +1,4 @@
|
|||||||
|
# pyright: reportConstantRedefinition=false, reportMissingTypeStubs=false, reportUnusedFunction=false
|
||||||
"""WhatsApp channel implementation using neonize."""
|
"""WhatsApp channel implementation using neonize."""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
@ -10,7 +11,7 @@ import time
|
|||||||
from collections import OrderedDict
|
from collections import OrderedDict
|
||||||
from contextlib import suppress
|
from contextlib import suppress
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any, Literal, NamedTuple
|
from typing import Any, Literal, NamedTuple, cast
|
||||||
|
|
||||||
from pydantic import Field
|
from pydantic import Field
|
||||||
|
|
||||||
@ -100,7 +101,8 @@ def _has_field(message: Any, name: str) -> bool:
|
|||||||
list_fields = getattr(message, "ListFields", None)
|
list_fields = getattr(message, "ListFields", None)
|
||||||
if callable(list_fields):
|
if callable(list_fields):
|
||||||
try:
|
try:
|
||||||
return any(getattr(field, "name", "") == name for field, _ in list_fields())
|
fields = cast(list[tuple[Any, Any]], list_fields())
|
||||||
|
return any(getattr(field, "name", "") == name for field, _ in fields)
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
@ -277,7 +279,10 @@ class WhatsAppChannel(BaseChannel):
|
|||||||
return WhatsAppConfig().model_dump(by_alias=True)
|
return WhatsAppConfig().model_dump(by_alias=True)
|
||||||
|
|
||||||
def __init__(self, config: Any, bus: MessageBus):
|
def __init__(self, config: Any, bus: MessageBus):
|
||||||
legacy_bridge_fields = _legacy_bridge_config_fields(config) if isinstance(config, dict) else []
|
legacy_bridge_fields = (
|
||||||
|
_legacy_bridge_config_fields(cast(dict[str, Any], config))
|
||||||
|
if isinstance(config, dict) else []
|
||||||
|
)
|
||||||
if isinstance(config, dict):
|
if isinstance(config, dict):
|
||||||
config = WhatsAppConfig.model_validate(config)
|
config = WhatsAppConfig.model_validate(config)
|
||||||
super().__init__(config, bus)
|
super().__init__(config, bus)
|
||||||
@ -649,12 +654,13 @@ class WhatsAppChannel(BaseChannel):
|
|||||||
if not self._self_jids:
|
if not self._self_jids:
|
||||||
return False
|
return False
|
||||||
for context in _context_infos(message):
|
for context in _context_infos(message):
|
||||||
mentioned = (
|
raw_mentioned: Any = (
|
||||||
_safe_attr(context, "mentionedJID")
|
_safe_attr(context, "mentionedJID")
|
||||||
or _safe_attr(context, "mentionedJid")
|
or _safe_attr(context, "mentionedJid")
|
||||||
or _safe_attr(context, "mentioned_jid")
|
or _safe_attr(context, "mentioned_jid")
|
||||||
or []
|
or []
|
||||||
)
|
)
|
||||||
|
mentioned: list[Any] = cast(list[Any], raw_mentioned)
|
||||||
for jid in mentioned:
|
for jid in mentioned:
|
||||||
normalized = _normalize_jid(jid)
|
normalized = _normalize_jid(jid)
|
||||||
if normalized in self._self_jids or _bare_jid(normalized) in self._self_jids:
|
if normalized in self._self_jids or _bare_jid(normalized) in self._self_jids:
|
||||||
|
|||||||
@ -1,15 +1,23 @@
|
|||||||
"""CLI commands for nanobot."""
|
"""CLI commands for nanobot."""
|
||||||
|
|
||||||
|
# pyright: reportConstantRedefinition=false, reportMissingTypeStubs=false, reportPrivateUsage=false, reportUnusedFunction=false
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
import os
|
import os
|
||||||
import select
|
import select
|
||||||
import signal
|
import signal
|
||||||
import sys
|
import sys
|
||||||
import time
|
import time
|
||||||
from collections.abc import Callable, Iterable
|
from collections.abc import Awaitable, Callable, Coroutine, Iterable
|
||||||
from contextlib import nullcontext, suppress
|
from contextlib import nullcontext, suppress
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any
|
from types import FrameType
|
||||||
|
from typing import TYPE_CHECKING, Any, Literal, cast
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from nanobot.gateway.runtime import GatewayRuntime
|
||||||
|
from nanobot.providers.registry import ProviderSpec
|
||||||
|
|
||||||
|
|
||||||
# Force UTF-8 encoding for Windows console
|
# Force UTF-8 encoding for Windows console
|
||||||
if sys.platform == "win32":
|
if sys.platform == "win32":
|
||||||
@ -17,8 +25,10 @@ if sys.platform == "win32":
|
|||||||
os.environ["PYTHONIOENCODING"] = "utf-8"
|
os.environ["PYTHONIOENCODING"] = "utf-8"
|
||||||
# Re-open stdout/stderr with UTF-8 encoding
|
# Re-open stdout/stderr with UTF-8 encoding
|
||||||
with suppress(Exception):
|
with suppress(Exception):
|
||||||
sys.stdout.reconfigure(encoding="utf-8", errors="replace")
|
for stream in (sys.stdout, sys.stderr):
|
||||||
sys.stderr.reconfigure(encoding="utf-8", errors="replace")
|
reconfigure = getattr(stream, "reconfigure", None)
|
||||||
|
if callable(reconfigure):
|
||||||
|
reconfigure(encoding="utf-8", errors="replace")
|
||||||
|
|
||||||
# Keep console encoding setup before importing CLI UI/logging libraries.
|
# Keep console encoding setup before importing CLI UI/logging libraries.
|
||||||
import typer # noqa: E402
|
import typer # noqa: E402
|
||||||
@ -52,6 +62,7 @@ from prompt_toolkit.application import run_in_terminal # noqa: E402
|
|||||||
from prompt_toolkit.formatted_text import ANSI, HTML # noqa: E402
|
from prompt_toolkit.formatted_text import ANSI, HTML # noqa: E402
|
||||||
from prompt_toolkit.history import FileHistory # noqa: E402
|
from prompt_toolkit.history import FileHistory # noqa: E402
|
||||||
from prompt_toolkit.key_binding import KeyBindings # noqa: E402
|
from prompt_toolkit.key_binding import KeyBindings # noqa: E402
|
||||||
|
from prompt_toolkit.key_binding.key_processor import KeyPressEvent # noqa: E402
|
||||||
from prompt_toolkit.keys import Keys # noqa: E402
|
from prompt_toolkit.keys import Keys # noqa: E402
|
||||||
from prompt_toolkit.patch_stdout import patch_stdout # noqa: E402
|
from prompt_toolkit.patch_stdout import patch_stdout # noqa: E402
|
||||||
from pydantic import ValidationError # noqa: E402
|
from pydantic import ValidationError # noqa: E402
|
||||||
@ -139,7 +150,7 @@ def _ensure_interactive_tty_mode() -> None:
|
|||||||
def _install_gateway_shutdown_handlers(
|
def _install_gateway_shutdown_handlers(
|
||||||
loop: asyncio.AbstractEventLoop,
|
loop: asyncio.AbstractEventLoop,
|
||||||
shutdown_event: asyncio.Event,
|
shutdown_event: asyncio.Event,
|
||||||
tasks: list[asyncio.Task],
|
tasks: list[asyncio.Task[Any]],
|
||||||
print_status: Callable[[str], None],
|
print_status: Callable[[str], None],
|
||||||
) -> Callable[[], None]:
|
) -> Callable[[], None]:
|
||||||
"""Install foreground gateway signal handlers and return a restore callback."""
|
"""Install foreground gateway signal handlers and return a restore callback."""
|
||||||
@ -298,8 +309,8 @@ def _pick_heartbeat_target_from_sessions(
|
|||||||
# CLI input: prompt_toolkit for editing, paste, history, and display
|
# CLI input: prompt_toolkit for editing, paste, history, and display
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
_PROMPT_SESSION: PromptSession | None = None
|
_PROMPT_SESSION: PromptSession[str] | None = None
|
||||||
_SAVED_TERM_ATTRS = None # original termios settings, restored on exit
|
_saved_term_attrs: list[Any] | None = None # original termios settings, restored on exit
|
||||||
|
|
||||||
|
|
||||||
def _flush_pending_tty_input() -> None:
|
def _flush_pending_tty_input() -> None:
|
||||||
@ -328,12 +339,12 @@ def _flush_pending_tty_input() -> None:
|
|||||||
|
|
||||||
def _restore_terminal() -> None:
|
def _restore_terminal() -> None:
|
||||||
"""Restore terminal to its original state (echo, line buffering, etc.)."""
|
"""Restore terminal to its original state (echo, line buffering, etc.)."""
|
||||||
if _SAVED_TERM_ATTRS is None:
|
if _saved_term_attrs is None:
|
||||||
return
|
return
|
||||||
with suppress(Exception):
|
with suppress(Exception):
|
||||||
import termios
|
import termios
|
||||||
|
|
||||||
termios.tcsetattr(sys.stdin.fileno(), termios.TCSADRAIN, _SAVED_TERM_ATTRS)
|
termios.tcsetattr(sys.stdin.fileno(), termios.TCSADRAIN, _saved_term_attrs)
|
||||||
|
|
||||||
|
|
||||||
def _build_cli_key_bindings() -> KeyBindings:
|
def _build_cli_key_bindings() -> KeyBindings:
|
||||||
@ -357,20 +368,20 @@ def _build_cli_key_bindings() -> KeyBindings:
|
|||||||
kb = KeyBindings()
|
kb = KeyBindings()
|
||||||
|
|
||||||
@kb.add("enter")
|
@kb.add("enter")
|
||||||
def _(event):
|
def _(event: KeyPressEvent) -> None:
|
||||||
event.current_buffer.validate_and_handle()
|
event.current_buffer.validate_and_handle()
|
||||||
|
|
||||||
@kb.add("escape", "enter") # Alt+Enter / Meta+Enter (ESC + CR, "\x1b\r")
|
@kb.add("escape", "enter") # Alt+Enter / Meta+Enter (ESC + CR, "\x1b\r")
|
||||||
def _(event):
|
def _(event: KeyPressEvent) -> None:
|
||||||
event.current_buffer.insert_text("\n")
|
event.current_buffer.insert_text("\n")
|
||||||
|
|
||||||
# LF-as-Enter terminals send Alt+Enter as ESC + LF rather than ESC + CR.
|
# LF-as-Enter terminals send Alt+Enter as ESC + LF rather than ESC + CR.
|
||||||
@kb.add("escape", Keys.ControlJ) # Alt+Enter on LF-as-Enter terminals
|
@kb.add("escape", Keys.ControlJ) # Alt+Enter on LF-as-Enter terminals
|
||||||
def _(event):
|
def _(event: KeyPressEvent) -> None:
|
||||||
event.current_buffer.insert_text("\n")
|
event.current_buffer.insert_text("\n")
|
||||||
|
|
||||||
@kb.add(Keys.ControlF3) # Shift+Enter on CSI-u capable terminals
|
@kb.add(Keys.ControlF3) # Shift+Enter on CSI-u capable terminals
|
||||||
def _(event):
|
def _(event: KeyPressEvent) -> None:
|
||||||
event.current_buffer.insert_text("\n")
|
event.current_buffer.insert_text("\n")
|
||||||
|
|
||||||
return kb
|
return kb
|
||||||
@ -378,13 +389,13 @@ def _build_cli_key_bindings() -> KeyBindings:
|
|||||||
|
|
||||||
def _init_prompt_session() -> None:
|
def _init_prompt_session() -> None:
|
||||||
"""Create the prompt_toolkit session with persistent file history."""
|
"""Create the prompt_toolkit session with persistent file history."""
|
||||||
global _PROMPT_SESSION, _SAVED_TERM_ATTRS
|
global _PROMPT_SESSION, _saved_term_attrs
|
||||||
|
|
||||||
# Save terminal state so we can restore it on exit
|
# Save terminal state so we can restore it on exit
|
||||||
with suppress(Exception):
|
with suppress(Exception):
|
||||||
import termios
|
import termios
|
||||||
|
|
||||||
_SAVED_TERM_ATTRS = termios.tcgetattr(sys.stdin.fileno())
|
_saved_term_attrs = termios.tcgetattr(sys.stdin.fileno())
|
||||||
|
|
||||||
from nanobot.config.paths import get_cli_history_path
|
from nanobot.config.paths import get_cli_history_path
|
||||||
|
|
||||||
@ -405,11 +416,14 @@ def _make_console() -> Console:
|
|||||||
return Console(file=sys.stdout)
|
return Console(file=sys.stdout)
|
||||||
|
|
||||||
|
|
||||||
def _render_interactive_ansi(render_fn) -> str:
|
def _render_interactive_ansi(render_fn: Callable[[Console], None]) -> str:
|
||||||
"""Render Rich output to ANSI so prompt_toolkit can print it safely."""
|
"""Render Rich output to ANSI so prompt_toolkit can print it safely."""
|
||||||
ansi_console = Console(
|
ansi_console = Console(
|
||||||
force_terminal=sys.stdout.isatty(),
|
force_terminal=sys.stdout.isatty(),
|
||||||
color_system=console.color_system or "standard",
|
color_system=cast(
|
||||||
|
Literal["auto", "standard", "256", "truecolor", "windows"],
|
||||||
|
console.color_system or "standard",
|
||||||
|
),
|
||||||
width=console.width,
|
width=console.width,
|
||||||
)
|
)
|
||||||
with ansi_console.capture() as capture:
|
with ansi_console.capture() as capture:
|
||||||
@ -420,7 +434,7 @@ def _render_interactive_ansi(render_fn) -> str:
|
|||||||
def _print_agent_response(
|
def _print_agent_response(
|
||||||
response: str,
|
response: str,
|
||||||
render_markdown: bool,
|
render_markdown: bool,
|
||||||
metadata: dict | None = None,
|
metadata: dict[str, Any] | None = None,
|
||||||
show_header: bool = True,
|
show_header: bool = True,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Render assistant response with consistent terminal styling."""
|
"""Render assistant response with consistent terminal styling."""
|
||||||
@ -434,7 +448,9 @@ def _print_agent_response(
|
|||||||
console.print()
|
console.print()
|
||||||
|
|
||||||
|
|
||||||
def _response_renderable(content: str, render_markdown: bool, metadata: dict | None = None):
|
def _response_renderable(
|
||||||
|
content: str, render_markdown: bool, metadata: dict[str, Any] | None = None
|
||||||
|
) -> Text | Markdown:
|
||||||
"""Render plain-text command output without markdown collapsing newlines."""
|
"""Render plain-text command output without markdown collapsing newlines."""
|
||||||
if not render_markdown:
|
if not render_markdown:
|
||||||
return Text(content)
|
return Text(content)
|
||||||
@ -457,19 +473,19 @@ async def _print_interactive_line(text: str) -> None:
|
|||||||
async def _print_interactive_response(
|
async def _print_interactive_response(
|
||||||
response: str,
|
response: str,
|
||||||
render_markdown: bool,
|
render_markdown: bool,
|
||||||
metadata: dict | None = None,
|
metadata: dict[str, Any] | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Print async interactive replies with prompt_toolkit-safe Rich styling."""
|
"""Print async interactive replies with prompt_toolkit-safe Rich styling."""
|
||||||
def _write() -> None:
|
def _write() -> None:
|
||||||
content = response or ""
|
content = response or ""
|
||||||
ansi = _render_interactive_ansi(
|
|
||||||
lambda c: (
|
def _render(target: Console) -> None:
|
||||||
c.print(),
|
target.print()
|
||||||
c.print(f"[cyan]{__logo__} nanobot[/cyan]"),
|
target.print(f"[cyan]{__logo__} nanobot[/cyan]")
|
||||||
c.print(_response_renderable(content, render_markdown, metadata)),
|
target.print(_response_renderable(content, render_markdown, metadata))
|
||||||
c.print(),
|
target.print()
|
||||||
)
|
|
||||||
)
|
ansi = _render_interactive_ansi(_render)
|
||||||
print_formatted_text(ANSI(ansi), end="")
|
print_formatted_text(ANSI(ansi), end="")
|
||||||
|
|
||||||
await run_in_terminal(_write)
|
await run_in_terminal(_write)
|
||||||
@ -663,10 +679,11 @@ def onboard(
|
|||||||
loaded.agents.defaults.workspace = workspace
|
loaded.agents.defaults.workspace = workspace
|
||||||
return loaded
|
return loaded
|
||||||
|
|
||||||
|
loaded_config: Config | None = None
|
||||||
# Create or update config
|
# Create or update config
|
||||||
if config_path.exists():
|
if config_path.exists():
|
||||||
if wizard:
|
if wizard:
|
||||||
config = _apply_workspace_override(load_config(config_path))
|
loaded_config = _apply_workspace_override(load_config(config_path))
|
||||||
else:
|
else:
|
||||||
should_refresh = non_interactive_refresh
|
should_refresh = non_interactive_refresh
|
||||||
if not non_interactive_refresh:
|
if not non_interactive_refresh:
|
||||||
@ -678,37 +695,39 @@ def onboard(
|
|||||||
" [bold]N[/bold] = refresh config, keeping existing values and adding new fields"
|
" [bold]N[/bold] = refresh config, keeping existing values and adding new fields"
|
||||||
)
|
)
|
||||||
if typer.confirm("Overwrite?"):
|
if typer.confirm("Overwrite?"):
|
||||||
config = _apply_workspace_override(Config())
|
loaded_config = _apply_workspace_override(Config())
|
||||||
save_config(config, config_path)
|
save_config(loaded_config, config_path)
|
||||||
console.print(f"[green]✓[/green] Config reset to defaults at {config_path}")
|
console.print(f"[green]✓[/green] Config reset to defaults at {config_path}")
|
||||||
else:
|
else:
|
||||||
should_refresh = True
|
should_refresh = True
|
||||||
|
|
||||||
if should_refresh:
|
if should_refresh:
|
||||||
config = _apply_workspace_override(load_config(config_path))
|
loaded_config = _apply_workspace_override(load_config(config_path))
|
||||||
save_config(config, config_path)
|
save_config(loaded_config, config_path)
|
||||||
console.print(
|
console.print(
|
||||||
f"[green]✓[/green] Config refreshed at {config_path} (existing values preserved)"
|
f"[green]✓[/green] Config refreshed at {config_path} (existing values preserved)"
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
config = _apply_workspace_override(Config())
|
loaded_config = _apply_workspace_override(Config())
|
||||||
# In wizard mode, don't save yet - the wizard will handle saving if should_save=True
|
# In wizard mode, don't save yet - the wizard will handle saving if should_save=True
|
||||||
if not wizard:
|
if not wizard:
|
||||||
save_config(config, config_path)
|
save_config(loaded_config, config_path)
|
||||||
console.print(f"[green]✓[/green] Created config at {config_path}")
|
console.print(f"[green]✓[/green] Created config at {config_path}")
|
||||||
|
|
||||||
|
assert loaded_config is not None
|
||||||
|
|
||||||
# Run interactive wizard if enabled
|
# Run interactive wizard if enabled
|
||||||
if wizard:
|
if wizard:
|
||||||
from nanobot.cli.onboard import run_onboard
|
from nanobot.cli.onboard import run_onboard
|
||||||
|
|
||||||
try:
|
try:
|
||||||
result = run_onboard(initial_config=config)
|
result = run_onboard(initial_config=loaded_config)
|
||||||
if not result.should_save:
|
if not result.should_save:
|
||||||
console.print("[yellow]Configuration discarded. No changes were saved.[/yellow]")
|
console.print("[yellow]Configuration discarded. No changes were saved.[/yellow]")
|
||||||
return
|
return
|
||||||
|
|
||||||
config = result.config
|
loaded_config = result.config
|
||||||
save_config(config, config_path)
|
save_config(loaded_config, config_path)
|
||||||
console.print(f"[green]✓[/green] Config saved at {config_path}")
|
console.print(f"[green]✓[/green] Config saved at {config_path}")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
console.print(f"[red]✗[/red] Error during configuration: {e}")
|
console.print(f"[red]✗[/red] Error during configuration: {e}")
|
||||||
@ -717,7 +736,7 @@ def onboard(
|
|||||||
_onboard_plugins(config_path)
|
_onboard_plugins(config_path)
|
||||||
|
|
||||||
# Create workspace, preferring the configured workspace path.
|
# Create workspace, preferring the configured workspace path.
|
||||||
workspace_path = get_workspace_path(config.workspace_path)
|
workspace_path = get_workspace_path(loaded_config.workspace_path)
|
||||||
if not workspace_path.exists():
|
if not workspace_path.exists():
|
||||||
workspace_path.mkdir(parents=True, exist_ok=True)
|
workspace_path.mkdir(parents=True, exist_ok=True)
|
||||||
console.print(f"[green]✓[/green] Created workspace at {workspace_path}")
|
console.print(f"[green]✓[/green] Created workspace at {workspace_path}")
|
||||||
@ -1000,7 +1019,7 @@ def _webui_config_dict(config: Config) -> dict[str, Any]:
|
|||||||
"""Return the current WebSocket config as a mutable alias-key dictionary."""
|
"""Return the current WebSocket config as a mutable alias-key dictionary."""
|
||||||
from nanobot.channels.websocket.runtime import WebSocketConfig
|
from nanobot.channels.websocket.runtime import WebSocketConfig
|
||||||
|
|
||||||
current = getattr(config.channels, "websocket", None) or {}
|
current: Any = getattr(config.channels, "websocket", None) or {}
|
||||||
model = WebSocketConfig.model_validate(current)
|
model = WebSocketConfig.model_validate(current)
|
||||||
return model.model_dump(by_alias=True, exclude_none=True)
|
return model.model_dump(by_alias=True, exclude_none=True)
|
||||||
|
|
||||||
@ -1008,7 +1027,7 @@ def _webui_config_dict(config: Config) -> dict[str, Any]:
|
|||||||
def _webui_channel_enabled(config: Config) -> bool:
|
def _webui_channel_enabled(config: Config) -> bool:
|
||||||
from nanobot.channels.websocket.runtime import WebSocketConfig
|
from nanobot.channels.websocket.runtime import WebSocketConfig
|
||||||
|
|
||||||
current = getattr(config.channels, "websocket", None) or {}
|
current: Any = getattr(config.channels, "websocket", None) or {}
|
||||||
return bool(WebSocketConfig.model_validate(current).enabled)
|
return bool(WebSocketConfig.model_validate(current).enabled)
|
||||||
|
|
||||||
|
|
||||||
@ -1167,7 +1186,7 @@ def _ensure_local_webui_channel(config: Config, *, port: int | None, yes: bool)
|
|||||||
"""Enable the local WebUI channel with safe localhost defaults."""
|
"""Enable the local WebUI channel with safe localhost defaults."""
|
||||||
from nanobot.channels.websocket.runtime import WebSocketConfig
|
from nanobot.channels.websocket.runtime import WebSocketConfig
|
||||||
|
|
||||||
current = getattr(config.channels, "websocket", None) or {}
|
current: Any = getattr(config.channels, "websocket", None) or {}
|
||||||
model = WebSocketConfig.model_validate(current)
|
model = WebSocketConfig.model_validate(current)
|
||||||
changed = False
|
changed = False
|
||||||
generated_secret = False
|
generated_secret = False
|
||||||
@ -1329,7 +1348,7 @@ def _print_webui_foreground_lifecycle(*, attached: bool) -> None:
|
|||||||
console.print("[dim]Press Ctrl+C here to stop nanobot.[/dim]")
|
console.print("[dim]Press Ctrl+C here to stop nanobot.[/dim]")
|
||||||
|
|
||||||
|
|
||||||
def _attach_to_background_gateway(runtime: Any) -> None:
|
def _attach_to_background_gateway(runtime: "GatewayRuntime") -> None:
|
||||||
"""Keep a foreground WebUI command attached to a managed gateway."""
|
"""Keep a foreground WebUI command attached to a managed gateway."""
|
||||||
_print_webui_foreground_lifecycle(attached=True)
|
_print_webui_foreground_lifecycle(attached=True)
|
||||||
try:
|
try:
|
||||||
@ -1512,16 +1531,19 @@ def serve(
|
|||||||
api_key=api_key,
|
api_key=api_key,
|
||||||
)
|
)
|
||||||
|
|
||||||
async def on_startup(_app):
|
async def on_startup(_app: Any) -> None:
|
||||||
await agent_loop._connect_mcp()
|
await agent_loop._connect_mcp()
|
||||||
|
|
||||||
async def on_cleanup(_app):
|
async def on_cleanup(_app: Any) -> None:
|
||||||
await agent_loop.close_mcp()
|
await agent_loop.close_mcp()
|
||||||
|
|
||||||
api_app.on_startup.append(on_startup)
|
api_app.on_startup.append(on_startup)
|
||||||
api_app.on_cleanup.append(on_cleanup)
|
api_app.on_cleanup.append(on_cleanup)
|
||||||
|
|
||||||
web.run_app(api_app, host=host, port=port, print=lambda msg: logger.info(msg))
|
def _log_aiohttp(message: object) -> None:
|
||||||
|
logger.info("{}", message)
|
||||||
|
|
||||||
|
web.run_app(api_app, host=host, port=port, print=_log_aiohttp)
|
||||||
|
|
||||||
|
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
@ -1778,6 +1800,7 @@ def _run_gateway(
|
|||||||
from nanobot.cron.session_turns import is_bound_cron_job
|
from nanobot.cron.session_turns import is_bound_cron_job
|
||||||
from nanobot.cron.types import CronJob
|
from nanobot.cron.types import CronJob
|
||||||
from nanobot.providers.factory import (
|
from nanobot.providers.factory import (
|
||||||
|
ProviderSnapshot,
|
||||||
build_provider_snapshot,
|
build_provider_snapshot,
|
||||||
build_unconfigured_provider_snapshot,
|
build_unconfigured_provider_snapshot,
|
||||||
load_provider_snapshot,
|
load_provider_snapshot,
|
||||||
@ -1823,12 +1846,15 @@ def _run_gateway(
|
|||||||
runtime_events = RuntimeEventBus()
|
runtime_events = RuntimeEventBus()
|
||||||
fallback_model_observer = build_webui_fallback_model_observer(bus)
|
fallback_model_observer = build_webui_fallback_model_observer(bus)
|
||||||
|
|
||||||
def _observe_fallback_models(snapshot):
|
def _observe_fallback_models(snapshot: ProviderSnapshot) -> ProviderSnapshot:
|
||||||
if isinstance(snapshot.provider, FallbackProvider):
|
if isinstance(snapshot.provider, FallbackProvider):
|
||||||
snapshot.provider.set_fallback_model_observer(fallback_model_observer)
|
snapshot.provider.set_fallback_model_observer(fallback_model_observer)
|
||||||
return snapshot
|
return snapshot
|
||||||
|
|
||||||
def _load_gateway_provider_snapshot(*args: Any, **kwargs: Any):
|
def _load_gateway_provider_snapshot(
|
||||||
|
*args: Any,
|
||||||
|
**kwargs: Any,
|
||||||
|
) -> ProviderSnapshot:
|
||||||
try:
|
try:
|
||||||
return _observe_fallback_models(load_provider_snapshot(*args, **kwargs))
|
return _observe_fallback_models(load_provider_snapshot(*args, **kwargs))
|
||||||
except ValueError as exc:
|
except ValueError as exc:
|
||||||
@ -1896,10 +1922,13 @@ def _run_gateway(
|
|||||||
local_trigger_store=trigger_store,
|
local_trigger_store=trigger_store,
|
||||||
hook_factories=[create_file_edit_activity_hook],
|
hook_factories=[create_file_edit_activity_hook],
|
||||||
)
|
)
|
||||||
|
def _schedule_webui_background(awaitable: Awaitable[None]) -> None:
|
||||||
|
agent._schedule_background(cast(Coroutine[Any, Any, None], awaitable))
|
||||||
|
|
||||||
webui_turn_coordinator = WebuiTurnCoordinator(
|
webui_turn_coordinator = WebuiTurnCoordinator(
|
||||||
bus=bus,
|
bus=bus,
|
||||||
sessions=session_manager,
|
sessions=session_manager,
|
||||||
schedule_background=lambda coro: agent._schedule_background(coro),
|
schedule_background=_schedule_webui_background,
|
||||||
)
|
)
|
||||||
webui_turn_coordinator.subscribe(runtime_events)
|
webui_turn_coordinator.subscribe(runtime_events)
|
||||||
from nanobot.bus.events import OutboundMessage
|
from nanobot.bus.events import OutboundMessage
|
||||||
@ -1944,14 +1973,14 @@ def _run_gateway(
|
|||||||
session_manager.save(session)
|
session_manager.save(session)
|
||||||
await bus.publish_outbound(msg)
|
await bus.publish_outbound(msg)
|
||||||
|
|
||||||
message_tool = getattr(agent, "tools", {}).get("message")
|
message_tool = agent.tools.get("message")
|
||||||
if isinstance(message_tool, MessageTool):
|
if isinstance(message_tool, MessageTool):
|
||||||
message_tool.set_send_callback(_deliver_to_channel)
|
message_tool.set_send_callback(_deliver_to_channel)
|
||||||
|
|
||||||
# Set cron callback (needs agent)
|
# Set cron callback (needs agent)
|
||||||
async def on_cron_job(job: CronJob) -> str | None:
|
async def on_cron_job(job: CronJob) -> str | None:
|
||||||
"""Execute a cron job through the agent."""
|
"""Execute a cron job through the agent."""
|
||||||
async def _silent(*_args, **_kwargs):
|
async def _silent(*_args: Any, **_kwargs: Any) -> None:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
# Dream is an internal job — run directly, not through the agent loop.
|
# Dream is an internal job — run directly, not through the agent loop.
|
||||||
@ -1972,10 +2001,7 @@ def _run_gateway(
|
|||||||
return None
|
return None
|
||||||
prompt, last_cursor = result
|
prompt, last_cursor = result
|
||||||
key = dream_session_key()
|
key = dream_session_key()
|
||||||
resolve_dream_runtime = getattr(agent, "dream_runtime", None)
|
dream_runtime = agent.dream_runtime()
|
||||||
dream_runtime = (
|
|
||||||
resolve_dream_runtime() if callable(resolve_dream_runtime) else None
|
|
||||||
)
|
|
||||||
resp = await agent.process_direct(
|
resp = await agent.process_direct(
|
||||||
prompt,
|
prompt,
|
||||||
session_key=key,
|
session_key=key,
|
||||||
@ -2111,11 +2137,7 @@ def _run_gateway(
|
|||||||
cron.on_job = on_cron_job
|
cron.on_job = on_cron_job
|
||||||
|
|
||||||
def _webui_runtime_model_name() -> str | None:
|
def _webui_runtime_model_name() -> str | None:
|
||||||
model = getattr(agent, "model", None)
|
return agent.model.strip() or None
|
||||||
if isinstance(model, str):
|
|
||||||
stripped = model.strip()
|
|
||||||
return stripped or None
|
|
||||||
return None
|
|
||||||
|
|
||||||
# Create channel manager (forwards SessionManager so the WebSocket channel
|
# Create channel manager (forwards SessionManager so the WebSocket channel
|
||||||
# can serve the embedded webui's REST surface).
|
# can serve the embedded webui's REST surface).
|
||||||
@ -2126,12 +2148,8 @@ def _run_gateway(
|
|||||||
cron_service=cron,
|
cron_service=cron,
|
||||||
local_trigger_store=trigger_store,
|
local_trigger_store=trigger_store,
|
||||||
webui_runtime_model_name=_webui_runtime_model_name,
|
webui_runtime_model_name=_webui_runtime_model_name,
|
||||||
webui_cron_pending_job_ids=getattr(agent, "pending_cron_job_ids_for_session", None),
|
webui_cron_pending_job_ids=agent.pending_cron_job_ids_for_session,
|
||||||
webui_local_trigger_pending_ids=getattr(
|
webui_local_trigger_pending_ids=agent.pending_local_trigger_ids_for_session,
|
||||||
agent,
|
|
||||||
"pending_local_trigger_ids_for_session",
|
|
||||||
None,
|
|
||||||
),
|
|
||||||
webui_static_dist=webui_static_dist,
|
webui_static_dist=webui_static_dist,
|
||||||
webui_runtime_surface=webui_runtime_surface,
|
webui_runtime_surface=webui_runtime_surface,
|
||||||
webui_runtime_capabilities=webui_runtime_capabilities,
|
webui_runtime_capabilities=webui_runtime_capabilities,
|
||||||
@ -2158,8 +2176,9 @@ def _run_gateway(
|
|||||||
console.print("[yellow]Warning: No channels enabled[/yellow]")
|
console.print("[yellow]Warning: No channels enabled[/yellow]")
|
||||||
|
|
||||||
cron_status = cron.status()
|
cron_status = cron.status()
|
||||||
if cron_status["jobs"] > 0:
|
cron_job_count = cast(int, cron_status["jobs"])
|
||||||
console.print(f"[green]✓[/green] Cron: {cron_status['jobs']} scheduled jobs")
|
if cron_job_count > 0:
|
||||||
|
console.print(f"[green]✓[/green] Cron: {cron_job_count} scheduled jobs")
|
||||||
|
|
||||||
hb_cfg = config.gateway.heartbeat
|
hb_cfg = config.gateway.heartbeat
|
||||||
if hb_cfg.enabled:
|
if hb_cfg.enabled:
|
||||||
@ -2167,13 +2186,16 @@ def _run_gateway(
|
|||||||
else:
|
else:
|
||||||
console.print("[yellow]✗[/yellow] Heartbeat: disabled")
|
console.print("[yellow]✗[/yellow] Heartbeat: disabled")
|
||||||
|
|
||||||
async def _health_server(host: str, health_port: int):
|
async def _health_server(host: str, health_port: int) -> None:
|
||||||
"""Lightweight HTTP health endpoint on the gateway port."""
|
"""Lightweight HTTP health endpoint on the gateway port."""
|
||||||
import json as _json
|
import json as _json
|
||||||
|
|
||||||
connection_slots = asyncio.Semaphore(_GATEWAY_HEALTH_MAX_CONNECTIONS)
|
connection_slots = asyncio.Semaphore(_GATEWAY_HEALTH_MAX_CONNECTIONS)
|
||||||
|
|
||||||
async def handle(reader, writer):
|
async def handle(
|
||||||
|
reader: asyncio.StreamReader,
|
||||||
|
writer: asyncio.StreamWriter,
|
||||||
|
) -> None:
|
||||||
if connection_slots.locked():
|
if connection_slots.locked():
|
||||||
writer.close()
|
writer.close()
|
||||||
return
|
return
|
||||||
@ -2260,7 +2282,7 @@ def _run_gateway(
|
|||||||
# Channels start asynchronously; a short poll lets us avoid racing the bind.
|
# Channels start asynchronously; a short poll lets us avoid racing the bind.
|
||||||
for _ in range(40): # ~4s max
|
for _ in range(40): # ~4s max
|
||||||
try:
|
try:
|
||||||
reader, writer = await asyncio.open_connection(
|
_reader, writer = await asyncio.open_connection(
|
||||||
target_host,
|
target_host,
|
||||||
target_port,
|
target_port,
|
||||||
)
|
)
|
||||||
@ -2276,10 +2298,10 @@ def _run_gateway(
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
console.print(f"[yellow]Could not open browser ({e}); visit {open_browser_url}[/yellow]")
|
console.print(f"[yellow]Could not open browser ({e}); visit {open_browser_url}[/yellow]")
|
||||||
|
|
||||||
async def run():
|
async def run() -> None:
|
||||||
tasks: list[asyncio.Task] = []
|
tasks: list[asyncio.Task[Any]] = []
|
||||||
shutdown_task: asyncio.Task | None = None
|
shutdown_task: asyncio.Task[Any] | None = None
|
||||||
runtime_tasks: asyncio.Future | None = None
|
runtime_tasks: asyncio.Future[list[Any]] | None = None
|
||||||
runtime_tasks_drained = False
|
runtime_tasks_drained = False
|
||||||
shutdown_event = asyncio.Event()
|
shutdown_event = asyncio.Event()
|
||||||
_ensure_interactive_tty_mode()
|
_ensure_interactive_tty_mode()
|
||||||
@ -2306,7 +2328,7 @@ def _run_gateway(
|
|||||||
asyncio.create_task(
|
asyncio.create_task(
|
||||||
run_local_trigger_queue(
|
run_local_trigger_queue(
|
||||||
store=trigger_store,
|
store=trigger_store,
|
||||||
submit_turn=getattr(agent, "submit_local_trigger_turn", None),
|
submit_turn=agent.submit_local_trigger_turn,
|
||||||
is_channel_enabled=lambda name: channels.get_channel(name) is not None,
|
is_channel_enabled=lambda name: channels.get_channel(name) is not None,
|
||||||
),
|
),
|
||||||
name="nanobot-local-triggers",
|
name="nanobot-local-triggers",
|
||||||
@ -2334,7 +2356,7 @@ def _run_gateway(
|
|||||||
if runtime_tasks in done:
|
if runtime_tasks in done:
|
||||||
runtime_tasks_drained = True
|
runtime_tasks_drained = True
|
||||||
await runtime_tasks
|
await runtime_tasks
|
||||||
elif runtime_tasks is not None:
|
else:
|
||||||
runtime_tasks.cancel()
|
runtime_tasks.cancel()
|
||||||
except KeyboardInterrupt:
|
except KeyboardInterrupt:
|
||||||
console.print("\nShutting down...")
|
console.print("\nShutting down...")
|
||||||
@ -2410,33 +2432,33 @@ def agent(
|
|||||||
from nanobot.providers.factory import make_provider
|
from nanobot.providers.factory import make_provider
|
||||||
from nanobot.providers.image_generation import image_gen_provider_configs
|
from nanobot.providers.image_generation import image_gen_provider_configs
|
||||||
|
|
||||||
config = _load_runtime_config(config, workspace)
|
runtime_config = _load_runtime_config(config, workspace)
|
||||||
try:
|
try:
|
||||||
provider = make_provider(config)
|
provider = make_provider(runtime_config)
|
||||||
except ValueError as exc:
|
except ValueError as exc:
|
||||||
_print_agent_start_error(exc)
|
_print_agent_start_error(exc)
|
||||||
raise typer.Exit(1) from exc
|
raise typer.Exit(1) from exc
|
||||||
|
|
||||||
sync_workspace_templates(config.workspace_path)
|
sync_workspace_templates(runtime_config.workspace_path)
|
||||||
|
|
||||||
bus = MessageBus()
|
bus = MessageBus()
|
||||||
|
|
||||||
# Preserve existing single-workspace installs, but keep custom workspaces clean.
|
# Preserve existing single-workspace installs, but keep custom workspaces clean.
|
||||||
if is_default_workspace(config.workspace_path):
|
if is_default_workspace(runtime_config.workspace_path):
|
||||||
_migrate_cron_store(config)
|
_migrate_cron_store(runtime_config)
|
||||||
|
|
||||||
# Create cron service with workspace-scoped store
|
# Create cron service with workspace-scoped store
|
||||||
cron_store_path = config.workspace_path / "cron" / "jobs.json"
|
cron_store_path = runtime_config.workspace_path / "cron" / "jobs.json"
|
||||||
cron = CronService(cron_store_path)
|
cron = CronService(cron_store_path)
|
||||||
|
|
||||||
_set_nanobot_logs(logs)
|
_set_nanobot_logs(logs)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
agent_loop = AgentLoop.from_config(
|
agent_loop = AgentLoop.from_config(
|
||||||
config, bus,
|
runtime_config, bus,
|
||||||
provider=provider,
|
provider=provider,
|
||||||
cron_service=cron,
|
cron_service=cron,
|
||||||
image_generation_provider_configs=image_gen_provider_configs(config),
|
image_generation_provider_configs=image_gen_provider_configs(runtime_config),
|
||||||
hook_factories=[create_file_edit_activity_hook],
|
hook_factories=[create_file_edit_activity_hook],
|
||||||
)
|
)
|
||||||
except ValueError as exc:
|
except ValueError as exc:
|
||||||
@ -2452,7 +2474,9 @@ def agent(
|
|||||||
# Shared reference for progress callbacks
|
# Shared reference for progress callbacks
|
||||||
_thinking: ThinkingSpinner | None = None
|
_thinking: ThinkingSpinner | None = None
|
||||||
|
|
||||||
def _make_progress(renderer: StreamRenderer | None = None):
|
def _make_progress(
|
||||||
|
renderer: StreamRenderer | None = None,
|
||||||
|
) -> Callable[..., Awaitable[None]]:
|
||||||
reasoning_buffer = _ReasoningBuffer()
|
reasoning_buffer = _ReasoningBuffer()
|
||||||
|
|
||||||
async def _cli_progress(content: str, *, tool_hint: bool = False, reasoning: bool = False, **_kwargs: Any) -> None:
|
async def _cli_progress(content: str, *, tool_hint: bool = False, reasoning: bool = False, **_kwargs: Any) -> None:
|
||||||
@ -2482,11 +2506,11 @@ def agent(
|
|||||||
|
|
||||||
if message:
|
if message:
|
||||||
# Single message mode — direct call, no bus needed
|
# Single message mode — direct call, no bus needed
|
||||||
async def run_once():
|
async def run_once() -> None:
|
||||||
renderer = StreamRenderer(
|
renderer = StreamRenderer(
|
||||||
render_markdown=markdown,
|
render_markdown=markdown,
|
||||||
bot_name=config.agents.defaults.bot_name,
|
bot_name=runtime_config.agents.defaults.bot_name,
|
||||||
bot_icon=config.agents.defaults.bot_icon,
|
bot_icon=runtime_config.agents.defaults.bot_icon,
|
||||||
)
|
)
|
||||||
response = await agent_loop.process_direct(
|
response = await agent_loop.process_direct(
|
||||||
message, session_id,
|
message, session_id,
|
||||||
@ -2512,8 +2536,8 @@ def agent(
|
|||||||
# Interactive mode — route through bus like other channels
|
# Interactive mode — route through bus like other channels
|
||||||
from nanobot.bus.events import InboundMessage
|
from nanobot.bus.events import InboundMessage
|
||||||
_init_prompt_session()
|
_init_prompt_session()
|
||||||
_model, _preset_tag = _model_display(config)
|
_model, _preset_tag = _model_display(runtime_config)
|
||||||
_icon = config.agents.defaults.bot_icon or __logo__
|
_icon = runtime_config.agents.defaults.bot_icon or __logo__
|
||||||
console.print(f"{_icon} Interactive mode [bold blue]({_model})[/bold blue]{_preset_tag} — type [bold]exit[/bold] or [bold]Ctrl+C[/bold] to quit\n")
|
console.print(f"{_icon} Interactive mode [bold blue]({_model})[/bold blue]{_preset_tag} — type [bold]exit[/bold] or [bold]Ctrl+C[/bold] to quit\n")
|
||||||
|
|
||||||
if ":" in session_id:
|
if ":" in session_id:
|
||||||
@ -2521,7 +2545,7 @@ def agent(
|
|||||||
else:
|
else:
|
||||||
cli_channel, cli_chat_id = "cli", session_id
|
cli_channel, cli_chat_id = "cli", session_id
|
||||||
|
|
||||||
def _handle_signal(signum, frame):
|
def _handle_signal(signum: int, _frame: FrameType | None) -> None:
|
||||||
sig_name = signal.Signals(signum).name
|
sig_name = signal.Signals(signum).name
|
||||||
_restore_terminal()
|
_restore_terminal()
|
||||||
console.print(f"\nReceived {sig_name}, goodbye!")
|
console.print(f"\nReceived {sig_name}, goodbye!")
|
||||||
@ -2537,7 +2561,7 @@ def agent(
|
|||||||
if hasattr(signal, 'SIGPIPE'):
|
if hasattr(signal, 'SIGPIPE'):
|
||||||
signal.signal(signal.SIGPIPE, signal.SIG_IGN)
|
signal.signal(signal.SIGPIPE, signal.SIG_IGN)
|
||||||
|
|
||||||
async def run_interactive():
|
async def run_interactive() -> None:
|
||||||
bus_task = asyncio.create_task(agent_loop.run())
|
bus_task = asyncio.create_task(agent_loop.run())
|
||||||
turn_done = asyncio.Event()
|
turn_done = asyncio.Event()
|
||||||
turn_done.set()
|
turn_done.set()
|
||||||
@ -2545,7 +2569,7 @@ def agent(
|
|||||||
renderer: StreamRenderer | None = None
|
renderer: StreamRenderer | None = None
|
||||||
reasoning_buffer = _ReasoningBuffer()
|
reasoning_buffer = _ReasoningBuffer()
|
||||||
|
|
||||||
async def _consume_outbound():
|
async def _consume_outbound() -> None:
|
||||||
while True:
|
while True:
|
||||||
try:
|
try:
|
||||||
msg = await asyncio.wait_for(bus.consume_outbound(), timeout=1.0)
|
msg = await asyncio.wait_for(bus.consume_outbound(), timeout=1.0)
|
||||||
@ -2578,7 +2602,7 @@ def agent(
|
|||||||
|
|
||||||
if await _maybe_print_interactive_progress(
|
if await _maybe_print_interactive_progress(
|
||||||
msg,
|
msg,
|
||||||
renderer,
|
None,
|
||||||
agent_loop.channels_config,
|
agent_loop.channels_config,
|
||||||
renderer,
|
renderer,
|
||||||
reasoning_buffer,
|
reasoning_buffer,
|
||||||
@ -2625,8 +2649,8 @@ def agent(
|
|||||||
reasoning_buffer.clear()
|
reasoning_buffer.clear()
|
||||||
renderer = StreamRenderer(
|
renderer = StreamRenderer(
|
||||||
render_markdown=markdown,
|
render_markdown=markdown,
|
||||||
bot_name=config.agents.defaults.bot_name,
|
bot_name=runtime_config.agents.defaults.bot_name,
|
||||||
bot_icon=config.agents.defaults.bot_icon,
|
bot_icon=runtime_config.agents.defaults.bot_icon,
|
||||||
)
|
)
|
||||||
|
|
||||||
await bus.publish_inbound(InboundMessage(
|
await bus.publish_inbound(InboundMessage(
|
||||||
@ -2701,7 +2725,7 @@ def channels_status(
|
|||||||
if section is None:
|
if section is None:
|
||||||
enabled = False
|
enabled = False
|
||||||
elif isinstance(section, dict):
|
elif isinstance(section, dict):
|
||||||
enabled = section.get("enabled", False)
|
enabled = cast(dict[str, Any], section).get("enabled", False)
|
||||||
else:
|
else:
|
||||||
enabled = getattr(section, "enabled", False)
|
enabled = getattr(section, "enabled", False)
|
||||||
table.add_row(
|
table.add_row(
|
||||||
@ -2719,10 +2743,11 @@ def channels_login(
|
|||||||
config: str | None = typer.Option(None, "--config", "-c", help="Path to config file"),
|
config: str | None = typer.Option(None, "--config", "-c", help="Path to config file"),
|
||||||
):
|
):
|
||||||
"""Authenticate with a channel via QR code or other interactive login."""
|
"""Authenticate with a channel via QR code or other interactive login."""
|
||||||
|
from nanobot.bus.queue import MessageBus
|
||||||
from nanobot.channels.registry import discover_all
|
from nanobot.channels.registry import discover_all
|
||||||
|
|
||||||
_, loaded = _load_inspection_config(config=config)
|
_, loaded = _load_inspection_config(config=config)
|
||||||
channel_cfg = getattr(loaded.channels, channel_name, None) or {}
|
channel_cfg: Any = getattr(loaded.channels, channel_name, None) or {}
|
||||||
|
|
||||||
# Validate channel exists
|
# Validate channel exists
|
||||||
all_channels = discover_all()
|
all_channels = discover_all()
|
||||||
@ -2733,8 +2758,8 @@ def channels_login(
|
|||||||
|
|
||||||
console.print(f"{__logo__} {all_channels[channel_name].display_name} Login\n")
|
console.print(f"{__logo__} {all_channels[channel_name].display_name} Login\n")
|
||||||
|
|
||||||
channel_cls = all_channels[channel_name]
|
channel_factory = all_channels[channel_name]
|
||||||
channel = channel_cls(channel_cfg, bus=None)
|
channel = channel_factory(channel_cfg, bus=MessageBus())
|
||||||
|
|
||||||
success = asyncio.run(channel.login(force=force))
|
success = asyncio.run(channel.login(force=force))
|
||||||
|
|
||||||
@ -2923,24 +2948,28 @@ _OAUTH_PROVIDER_DEFAULT_MODELS: dict[str, str] = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def _register_login(name: str):
|
def _register_login(
|
||||||
|
name: str,
|
||||||
|
) -> Callable[[Callable[[], None]], Callable[[], None]]:
|
||||||
"""Register an OAuth login handler."""
|
"""Register an OAuth login handler."""
|
||||||
def decorator(fn):
|
def decorator(fn: Callable[[], None]) -> Callable[[], None]:
|
||||||
_LOGIN_HANDLERS[name] = fn
|
_LOGIN_HANDLERS[name] = fn
|
||||||
return fn
|
return fn
|
||||||
|
|
||||||
return decorator
|
return decorator
|
||||||
|
|
||||||
|
|
||||||
def _register_logout(name: str):
|
def _register_logout(
|
||||||
|
name: str,
|
||||||
|
) -> Callable[[Callable[[], None]], Callable[[], None]]:
|
||||||
"""Register an OAuth logout handler."""
|
"""Register an OAuth logout handler."""
|
||||||
def decorator(fn):
|
def decorator(fn: Callable[[], None]) -> Callable[[], None]:
|
||||||
_LOGOUT_HANDLERS[name] = fn
|
_LOGOUT_HANDLERS[name] = fn
|
||||||
return fn
|
return fn
|
||||||
return decorator
|
return decorator
|
||||||
|
|
||||||
|
|
||||||
def _resolve_oauth_provider(provider: str):
|
def _resolve_oauth_provider(provider: str) -> "ProviderSpec":
|
||||||
"""Resolve and validate an OAuth provider configuration."""
|
"""Resolve and validate an OAuth provider configuration."""
|
||||||
from nanobot.providers.registry import PROVIDERS
|
from nanobot.providers.registry import PROVIDERS
|
||||||
|
|
||||||
|
|||||||
@ -1,5 +1,7 @@
|
|||||||
"""Typer commands for foreground and background gateway control."""
|
"""Typer commands for foreground and background gateway control."""
|
||||||
|
|
||||||
|
# pyright: reportUnusedFunction=false
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import subprocess
|
import subprocess
|
||||||
|
|||||||
@ -1,19 +1,27 @@
|
|||||||
"""Interactive onboarding questionnaire for nanobot."""
|
"""Interactive onboarding questionnaire for nanobot."""
|
||||||
|
|
||||||
|
# pyright: reportMissingTypeStubs=false, reportUnusedFunction=false
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
import json
|
import json
|
||||||
import types
|
import types
|
||||||
|
from collections.abc import Callable, Iterable, Sized
|
||||||
from contextlib import suppress
|
from contextlib import suppress
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from functools import lru_cache
|
from functools import lru_cache
|
||||||
from typing import Any, Literal, NamedTuple, get_args, get_origin
|
from typing import Any, Literal, NamedTuple, TypeVar, cast, get_args, get_origin
|
||||||
|
|
||||||
try:
|
try:
|
||||||
import questionary
|
import questionary
|
||||||
except ModuleNotFoundError: # pragma: no cover - exercised in environments without wizard deps
|
except ModuleNotFoundError: # pragma: no cover - exercised in environments without wizard deps
|
||||||
questionary = None
|
questionary = None
|
||||||
from loguru import logger
|
from loguru import logger
|
||||||
|
from prompt_toolkit.completion import CompleteEvent, Completer, Completion
|
||||||
|
from prompt_toolkit.document import Document
|
||||||
|
from prompt_toolkit.key_binding import KeyBindings
|
||||||
|
from prompt_toolkit.key_binding.key_processor import KeyPressEvent
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
|
from pydantic.fields import FieldInfo
|
||||||
from rich.console import Console
|
from rich.console import Console
|
||||||
from rich.markup import escape
|
from rich.markup import escape
|
||||||
from rich.panel import Panel
|
from rich.panel import Panel
|
||||||
@ -29,6 +37,8 @@ from nanobot.config.schema import Config, ModelPresetConfig
|
|||||||
|
|
||||||
console = Console()
|
console = Console()
|
||||||
|
|
||||||
|
_ModelT = TypeVar("_ModelT", bound=BaseModel)
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class OnboardResult:
|
class OnboardResult:
|
||||||
@ -119,14 +129,14 @@ _CHANNEL_LOGIN_CHOICE = "Login with QR/link"
|
|||||||
_CHANNEL_ADVANCED_CHOICE = "Edit advanced settings"
|
_CHANNEL_ADVANCED_CHOICE = "Edit advanced settings"
|
||||||
|
|
||||||
|
|
||||||
def _get_questionary():
|
def _get_questionary() -> Any:
|
||||||
"""Return questionary or raise a clear error when wizard deps are unavailable."""
|
"""Return questionary or raise a clear error when wizard deps are unavailable."""
|
||||||
if questionary is None:
|
if questionary is None:
|
||||||
raise RuntimeError(
|
raise RuntimeError(
|
||||||
"Interactive onboarding requires the optional 'questionary' dependency. "
|
"Interactive onboarding requires the optional 'questionary' dependency. "
|
||||||
"Install project dependencies and rerun with --wizard."
|
"Install project dependencies and rerun with --wizard."
|
||||||
)
|
)
|
||||||
return questionary
|
return cast(Any, questionary)
|
||||||
|
|
||||||
|
|
||||||
def _select_with_back(
|
def _select_with_back(
|
||||||
@ -147,7 +157,6 @@ def _select_with_back(
|
|||||||
import shutil
|
import shutil
|
||||||
|
|
||||||
from prompt_toolkit.application import Application
|
from prompt_toolkit.application import Application
|
||||||
from prompt_toolkit.key_binding import KeyBindings
|
|
||||||
from prompt_toolkit.keys import Keys
|
from prompt_toolkit.keys import Keys
|
||||||
from prompt_toolkit.layout import Layout
|
from prompt_toolkit.layout import Layout
|
||||||
from prompt_toolkit.layout.containers import HSplit, Window
|
from prompt_toolkit.layout.containers import HSplit, Window
|
||||||
@ -170,8 +179,8 @@ def _select_with_back(
|
|||||||
visible_count = min(len(choices), max(1, terminal_lines - 3))
|
visible_count = min(len(choices), max(1, terminal_lines - 3))
|
||||||
|
|
||||||
# Build menu items (uses closure over selected_index)
|
# Build menu items (uses closure over selected_index)
|
||||||
def get_menu_text():
|
def get_menu_text() -> list[tuple[str, str]]:
|
||||||
items = []
|
items: list[tuple[str, str]] = []
|
||||||
start, end = _choice_viewport(selected_index, len(choices), visible_count)
|
start, end = _choice_viewport(selected_index, len(choices), visible_count)
|
||||||
for i in range(start, end):
|
for i in range(start, end):
|
||||||
choice = choices[i]
|
choice = choices[i]
|
||||||
@ -182,14 +191,14 @@ def _select_with_back(
|
|||||||
return items
|
return items
|
||||||
|
|
||||||
# Create layout
|
# Create layout
|
||||||
menu_control = FormattedTextControl(get_menu_text, show_cursor=False)
|
menu_control = FormattedTextControl(cast(Any, get_menu_text), show_cursor=False)
|
||||||
menu_window = Window(content=menu_control, height=visible_count, always_hide_cursor=True)
|
menu_window = Window(content=menu_control, height=visible_count, always_hide_cursor=True)
|
||||||
|
|
||||||
def get_prompt_text():
|
def get_prompt_text() -> list[tuple[str, str]]:
|
||||||
suffix = f" ({selected_index + 1}/{len(choices)})" if len(choices) > visible_count else ""
|
suffix = f" ({selected_index + 1}/{len(choices)})" if len(choices) > visible_count else ""
|
||||||
return [("class:question", f"{prompt}{suffix}")]
|
return [("class:question", f"{prompt}{suffix}")]
|
||||||
|
|
||||||
prompt_control = FormattedTextControl(get_prompt_text, show_cursor=False)
|
prompt_control = FormattedTextControl(cast(Any, get_prompt_text), show_cursor=False)
|
||||||
prompt_window = Window(content=prompt_control, height=1, always_hide_cursor=True)
|
prompt_window = Window(content=prompt_control, height=1, always_hide_cursor=True)
|
||||||
|
|
||||||
layout = Layout(HSplit([prompt_window, menu_window]))
|
layout = Layout(HSplit([prompt_window, menu_window]))
|
||||||
@ -198,34 +207,34 @@ def _select_with_back(
|
|||||||
bindings = KeyBindings()
|
bindings = KeyBindings()
|
||||||
|
|
||||||
@bindings.add(Keys.Up)
|
@bindings.add(Keys.Up)
|
||||||
def _up(event):
|
def _up(event: KeyPressEvent) -> None:
|
||||||
nonlocal selected_index
|
nonlocal selected_index
|
||||||
selected_index = (selected_index - 1) % len(choices)
|
selected_index = (selected_index - 1) % len(choices)
|
||||||
event.app.invalidate()
|
event.app.invalidate()
|
||||||
|
|
||||||
@bindings.add(Keys.Down)
|
@bindings.add(Keys.Down)
|
||||||
def _down(event):
|
def _down(event: KeyPressEvent) -> None:
|
||||||
nonlocal selected_index
|
nonlocal selected_index
|
||||||
selected_index = (selected_index + 1) % len(choices)
|
selected_index = (selected_index + 1) % len(choices)
|
||||||
event.app.invalidate()
|
event.app.invalidate()
|
||||||
|
|
||||||
@bindings.add(Keys.Enter)
|
@bindings.add(Keys.Enter)
|
||||||
def _enter(event):
|
def _enter(event: KeyPressEvent) -> None:
|
||||||
state["result"] = choices[selected_index]
|
state["result"] = choices[selected_index]
|
||||||
event.app.exit()
|
event.app.exit()
|
||||||
|
|
||||||
@bindings.add("escape")
|
@bindings.add("escape")
|
||||||
def _escape(event):
|
def _escape(event: KeyPressEvent) -> None:
|
||||||
state["result"] = _BACK_PRESSED
|
state["result"] = _BACK_PRESSED
|
||||||
event.app.exit()
|
event.app.exit()
|
||||||
|
|
||||||
@bindings.add(Keys.Left)
|
@bindings.add(Keys.Left)
|
||||||
def _left(event):
|
def _left(event: KeyPressEvent) -> None:
|
||||||
state["result"] = _BACK_PRESSED
|
state["result"] = _BACK_PRESSED
|
||||||
event.app.exit()
|
event.app.exit()
|
||||||
|
|
||||||
@bindings.add(Keys.ControlC)
|
@bindings.add(Keys.ControlC)
|
||||||
def _ctrl_c(event):
|
def _ctrl_c(event: KeyPressEvent) -> None:
|
||||||
state["result"] = None
|
state["result"] = None
|
||||||
event.app.exit()
|
event.app.exit()
|
||||||
|
|
||||||
@ -235,7 +244,7 @@ def _select_with_back(
|
|||||||
"question": f"fg:{_UI_TEXT}",
|
"question": f"fg:{_UI_TEXT}",
|
||||||
})
|
})
|
||||||
|
|
||||||
app = Application(layout=layout, key_bindings=bindings, style=style)
|
app = Application[object](layout=layout, key_bindings=bindings, style=style)
|
||||||
app.ttimeoutlen = 0.05
|
app.ttimeoutlen = 0.05
|
||||||
app.timeoutlen = 0.05
|
app.timeoutlen = 0.05
|
||||||
try:
|
try:
|
||||||
@ -268,7 +277,7 @@ class FieldTypeInfo(NamedTuple):
|
|||||||
inner_type: Any
|
inner_type: Any
|
||||||
|
|
||||||
|
|
||||||
def _get_field_type_info(field_info) -> FieldTypeInfo:
|
def _get_field_type_info(field_info: FieldInfo) -> FieldTypeInfo:
|
||||||
"""Extract field type info from Pydantic field."""
|
"""Extract field type info from Pydantic field."""
|
||||||
annotation = field_info.annotation
|
annotation = field_info.annotation
|
||||||
if annotation is None:
|
if annotation is None:
|
||||||
@ -285,10 +294,11 @@ def _get_field_type_info(field_info) -> FieldTypeInfo:
|
|||||||
args = get_args(annotation)
|
args = get_args(annotation)
|
||||||
|
|
||||||
_simple_types: dict[type, str] = {bool: "bool", int: "int", float: "float"}
|
_simple_types: dict[type, str] = {bool: "bool", int: "int", float: "float"}
|
||||||
|
origin_name = getattr(origin, "__name__", None)
|
||||||
|
|
||||||
if origin is list or (hasattr(origin, "__name__") and origin.__name__ == "List"):
|
if origin is list or origin_name == "List":
|
||||||
return FieldTypeInfo("list", args[0] if args else str)
|
return FieldTypeInfo("list", args[0] if args else str)
|
||||||
if origin is dict or (hasattr(origin, "__name__") and origin.__name__ == "Dict"):
|
if origin is dict or origin_name == "Dict":
|
||||||
return FieldTypeInfo("dict", None)
|
return FieldTypeInfo("dict", None)
|
||||||
for py_type, name in _simple_types.items():
|
for py_type, name in _simple_types.items():
|
||||||
if annotation is py_type:
|
if annotation is py_type:
|
||||||
@ -300,7 +310,7 @@ def _get_field_type_info(field_info) -> FieldTypeInfo:
|
|||||||
return FieldTypeInfo("str", None)
|
return FieldTypeInfo("str", None)
|
||||||
|
|
||||||
|
|
||||||
def _get_field_display_name(field_key: str, field_info) -> str:
|
def _get_field_display_name(field_key: str, field_info: FieldInfo | None) -> str:
|
||||||
"""Get display name for a field."""
|
"""Get display name for a field."""
|
||||||
if field_info and field_info.description:
|
if field_info and field_info.description:
|
||||||
return field_info.description
|
return field_info.description
|
||||||
@ -349,22 +359,30 @@ def _format_value(value: Any, rich: bool = True, field_name: str = "") -> str:
|
|||||||
masked = _mask_value(value)
|
masked = _mask_value(value)
|
||||||
return f"[dim]{masked}[/dim]" if rich else masked
|
return f"[dim]{masked}[/dim]" if rich else masked
|
||||||
if isinstance(value, BaseModel):
|
if isinstance(value, BaseModel):
|
||||||
parts = []
|
model_parts: list[str] = []
|
||||||
for fname, _finfo in type(value).model_fields.items():
|
for fname, _finfo in type(value).model_fields.items():
|
||||||
fval = getattr(value, fname, None)
|
fval = getattr(value, fname, None)
|
||||||
formatted = _format_value(fval, rich=False, field_name=fname)
|
formatted = _format_value(fval, rich=False, field_name=fname)
|
||||||
if formatted != "[not set]":
|
if formatted != "[not set]":
|
||||||
parts.append(f"{fname}={formatted}")
|
model_parts.append(f"{fname}={formatted}")
|
||||||
return ", ".join(parts) if parts else ("[dim]not set[/dim]" if rich else "[not set]")
|
return (
|
||||||
|
", ".join(model_parts)
|
||||||
|
if model_parts
|
||||||
|
else ("[dim]not set[/dim]" if rich else "[not set]")
|
||||||
|
)
|
||||||
if isinstance(value, list):
|
if isinstance(value, list):
|
||||||
return ", ".join(str(v) for v in value)
|
return ", ".join(str(v) for v in cast(list[Any], value))
|
||||||
if isinstance(value, dict):
|
if isinstance(value, dict):
|
||||||
# Handle dicts containing BaseModel instances
|
# Handle dicts containing BaseModel instances
|
||||||
parts = []
|
mapping_parts: list[str] = []
|
||||||
for k, v in value.items():
|
for k, v in cast(dict[Any, Any], value).items():
|
||||||
formatted = _format_value(v, rich=False, field_name=str(k))
|
formatted = _format_value(v, rich=False, field_name=str(k))
|
||||||
parts.append(f"{k}: {formatted}")
|
mapping_parts.append(f"{k}: {formatted}")
|
||||||
return ", ".join(parts) if parts else ("[dim]not set[/dim]" if rich else "[not set]")
|
return (
|
||||||
|
", ".join(mapping_parts)
|
||||||
|
if mapping_parts
|
||||||
|
else ("[dim]not set[/dim]" if rich else "[not set]")
|
||||||
|
)
|
||||||
return str(value)
|
return str(value)
|
||||||
|
|
||||||
|
|
||||||
@ -373,13 +391,13 @@ def _format_value_for_input(value: Any, field_type: str) -> str:
|
|||||||
if value is None or value == "":
|
if value is None or value == "":
|
||||||
return ""
|
return ""
|
||||||
if field_type == "list" and isinstance(value, list):
|
if field_type == "list" and isinstance(value, list):
|
||||||
return ",".join(str(v) for v in value)
|
return ",".join(str(v) for v in cast(list[Any], value))
|
||||||
if field_type == "dict" and isinstance(value, dict):
|
if field_type == "dict" and isinstance(value, dict):
|
||||||
return json.dumps(value)
|
return json.dumps(value)
|
||||||
return str(value)
|
return str(value)
|
||||||
|
|
||||||
|
|
||||||
def _validate_field_constraint(value: Any, field_info) -> str | None:
|
def _validate_field_constraint(value: Any, field_info: FieldInfo | None) -> str | None:
|
||||||
"""Validate a value against Pydantic Field constraints.
|
"""Validate a value against Pydantic Field constraints.
|
||||||
|
|
||||||
Returns an error message string if validation fails, None if valid.
|
Returns an error message string if validation fails, None if valid.
|
||||||
@ -388,7 +406,8 @@ def _validate_field_constraint(value: Any, field_info) -> str | None:
|
|||||||
if field_info is None or not hasattr(field_info, "metadata"):
|
if field_info is None or not hasattr(field_info, "metadata"):
|
||||||
return None
|
return None
|
||||||
|
|
||||||
for m in field_info.metadata:
|
for metadata in field_info.metadata:
|
||||||
|
m = metadata
|
||||||
if hasattr(m, "ge") and isinstance(value, (int, float)):
|
if hasattr(m, "ge") and isinstance(value, (int, float)):
|
||||||
if value < m.ge:
|
if value < m.ge:
|
||||||
return f"Value must be >= {m.ge}"
|
return f"Value must be >= {m.ge}"
|
||||||
@ -402,16 +421,16 @@ def _validate_field_constraint(value: Any, field_info) -> str | None:
|
|||||||
if value >= m.lt:
|
if value >= m.lt:
|
||||||
return f"Value must be < {m.lt}"
|
return f"Value must be < {m.lt}"
|
||||||
if hasattr(m, "min_length") and hasattr(value, "__len__"):
|
if hasattr(m, "min_length") and hasattr(value, "__len__"):
|
||||||
if len(value) < m.min_length:
|
if len(cast(Sized, value)) < m.min_length:
|
||||||
return f"Length must be >= {m.min_length}"
|
return f"Length must be >= {m.min_length}"
|
||||||
if hasattr(m, "max_length") and hasattr(value, "__len__"):
|
if hasattr(m, "max_length") and hasattr(value, "__len__"):
|
||||||
if len(value) > m.max_length:
|
if len(cast(Sized, value)) > m.max_length:
|
||||||
return f"Length must be <= {m.max_length}"
|
return f"Length must be <= {m.max_length}"
|
||||||
|
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
def _get_constraint_hint(field_info) -> str:
|
def _get_constraint_hint(field_info: FieldInfo | None) -> str:
|
||||||
"""Derive a human-readable constraint hint from field metadata.
|
"""Derive a human-readable constraint hint from field metadata.
|
||||||
|
|
||||||
Returns a string like " - 0-10" or " - >= 0" to append to field display names.
|
Returns a string like " - 0-10" or " - >= 0" to append to field display names.
|
||||||
@ -421,7 +440,8 @@ def _get_constraint_hint(field_info) -> str:
|
|||||||
|
|
||||||
ge_val = None
|
ge_val = None
|
||||||
le_val = None
|
le_val = None
|
||||||
for m in field_info.metadata:
|
for metadata in field_info.metadata:
|
||||||
|
m = metadata
|
||||||
if hasattr(m, "ge"):
|
if hasattr(m, "ge"):
|
||||||
ge_val = m.ge
|
ge_val = m.ge
|
||||||
if hasattr(m, "le"):
|
if hasattr(m, "le"):
|
||||||
@ -439,7 +459,11 @@ def _get_constraint_hint(field_info) -> str:
|
|||||||
# --- Rich UI Components ---
|
# --- Rich UI Components ---
|
||||||
|
|
||||||
|
|
||||||
def _show_config_panel(display_name: str, model: BaseModel, fields: list) -> None:
|
def _show_config_panel(
|
||||||
|
display_name: str,
|
||||||
|
model: BaseModel,
|
||||||
|
fields: list[tuple[str, FieldInfo]],
|
||||||
|
) -> None:
|
||||||
"""Display current configuration as a rich table."""
|
"""Display current configuration as a rich table."""
|
||||||
table = Table(show_header=False, box=None, padding=(0, 2))
|
table = Table(show_header=False, box=None, padding=(0, 2))
|
||||||
table.add_column("Field", style=_UI_ACCENT)
|
table.add_column("Field", style=_UI_ACCENT)
|
||||||
@ -504,20 +528,18 @@ def _input_bool(display_name: str, current: bool | None) -> bool | None:
|
|||||||
).ask()
|
).ask()
|
||||||
|
|
||||||
|
|
||||||
def _input_back_key_bindings():
|
def _input_back_key_bindings() -> KeyBindings:
|
||||||
"""Return key bindings that make Escape behave like a local back action."""
|
"""Return key bindings that make Escape behave like a local back action."""
|
||||||
from prompt_toolkit.key_binding import KeyBindings
|
|
||||||
|
|
||||||
bindings = KeyBindings()
|
bindings = KeyBindings()
|
||||||
|
|
||||||
@bindings.add("escape")
|
@bindings.add("escape")
|
||||||
def _escape(event):
|
def _escape(event: KeyPressEvent) -> None:
|
||||||
event.app.exit(result=_BACK_PRESSED)
|
event.app.exit(result=_BACK_PRESSED)
|
||||||
|
|
||||||
return bindings
|
return bindings
|
||||||
|
|
||||||
|
|
||||||
def _ask_prompt(prompt):
|
def _ask_prompt(prompt: Any) -> Any:
|
||||||
"""Ask a questionary prompt with responsive Escape handling."""
|
"""Ask a questionary prompt with responsive Escape handling."""
|
||||||
app = getattr(prompt, "application", None)
|
app = getattr(prompt, "application", None)
|
||||||
if app is not None:
|
if app is not None:
|
||||||
@ -528,7 +550,12 @@ def _ask_prompt(prompt):
|
|||||||
return prompt.ask()
|
return prompt.ask()
|
||||||
|
|
||||||
|
|
||||||
def _input_text(display_name: str, current: Any, field_type: str, field_info=None) -> Any:
|
def _input_text(
|
||||||
|
display_name: str,
|
||||||
|
current: Any,
|
||||||
|
field_type: str,
|
||||||
|
field_info: FieldInfo | None = None,
|
||||||
|
) -> Any:
|
||||||
"""Get text input and parse based on field type."""
|
"""Get text input and parse based on field type."""
|
||||||
default = _format_value_for_input(current, field_type)
|
default = _format_value_for_input(current, field_type)
|
||||||
|
|
||||||
@ -591,7 +618,10 @@ def _input_secret(display_name: str) -> str | None | object:
|
|||||||
|
|
||||||
|
|
||||||
def _input_with_existing(
|
def _input_with_existing(
|
||||||
display_name: str, current: Any, field_type: str, field_info=None
|
display_name: str,
|
||||||
|
current: Any,
|
||||||
|
field_type: str,
|
||||||
|
field_info: FieldInfo | None = None,
|
||||||
) -> Any:
|
) -> Any:
|
||||||
"""Handle input with 'keep existing' option for non-empty values."""
|
"""Handle input with 'keep existing' option for non-empty values."""
|
||||||
has_existing = current is not None and current != "" and current != {} and current != []
|
has_existing = current is not None and current != "" and current != {} and current != []
|
||||||
@ -624,8 +654,6 @@ def _input_model_with_autocomplete(
|
|||||||
"""Get model input with autocomplete suggestions.
|
"""Get model input with autocomplete suggestions.
|
||||||
|
|
||||||
"""
|
"""
|
||||||
from prompt_toolkit.completion import Completer, Completion
|
|
||||||
|
|
||||||
default = str(current) if current else ""
|
default = str(current) if current else ""
|
||||||
|
|
||||||
class DynamicModelCompleter(Completer):
|
class DynamicModelCompleter(Completer):
|
||||||
@ -634,7 +662,12 @@ def _input_model_with_autocomplete(
|
|||||||
def __init__(self, provider_name: str):
|
def __init__(self, provider_name: str):
|
||||||
self.provider = provider_name
|
self.provider = provider_name
|
||||||
|
|
||||||
def get_completions(self, document, _complete_event):
|
def get_completions(
|
||||||
|
self,
|
||||||
|
document: Document,
|
||||||
|
complete_event: CompleteEvent,
|
||||||
|
) -> Iterable[Completion]:
|
||||||
|
_ = complete_event
|
||||||
text = document.text_before_cursor
|
text = document.text_before_cursor
|
||||||
suggestions = get_model_suggestions(text, provider=self.provider, limit=50)
|
suggestions = get_model_suggestions(text, provider=self.provider, limit=50)
|
||||||
for model in suggestions:
|
for model in suggestions:
|
||||||
@ -735,7 +768,7 @@ def _handle_model_field(
|
|||||||
return
|
return
|
||||||
if new_value is not None and new_value != current_value:
|
if new_value is not None and new_value != current_value:
|
||||||
setattr(working_model, field_name, new_value)
|
setattr(working_model, field_name, new_value)
|
||||||
_try_auto_fill_context_window(working_model, new_value)
|
_try_auto_fill_context_window(working_model, cast(str, new_value))
|
||||||
|
|
||||||
|
|
||||||
def _handle_context_window_field(
|
def _handle_context_window_field(
|
||||||
@ -794,7 +827,11 @@ def _handle_fallback_models_field(
|
|||||||
"""Handle the 'fallback_models' field with preset-aware list management."""
|
"""Handle the 'fallback_models' field with preset-aware list management."""
|
||||||
from nanobot.config.schema import InlineFallbackConfig
|
from nanobot.config.schema import InlineFallbackConfig
|
||||||
|
|
||||||
items: list[Any] = list(current_value) if isinstance(current_value, list) else []
|
items: list[Any] = (
|
||||||
|
list(cast(list[Any], current_value))
|
||||||
|
if isinstance(current_value, list)
|
||||||
|
else []
|
||||||
|
)
|
||||||
preset_names = sorted(_MODEL_PRESET_CACHE)
|
preset_names = sorted(_MODEL_PRESET_CACHE)
|
||||||
|
|
||||||
while True:
|
while True:
|
||||||
@ -888,11 +925,11 @@ def _is_str_or_none(annotation: Any) -> bool:
|
|||||||
|
|
||||||
|
|
||||||
def _configure_pydantic_model(
|
def _configure_pydantic_model(
|
||||||
model: BaseModel,
|
model: _ModelT,
|
||||||
display_name: str,
|
display_name: str,
|
||||||
*,
|
*,
|
||||||
skip_fields: set[str] | None = None,
|
skip_fields: set[str] | None = None,
|
||||||
) -> BaseModel | None:
|
) -> _ModelT | None:
|
||||||
"""Configure a Pydantic model interactively.
|
"""Configure a Pydantic model interactively.
|
||||||
|
|
||||||
Returns the updated model when the user selects "Done" or navigates back.
|
Returns the updated model when the user selects "Done" or navigates back.
|
||||||
@ -901,7 +938,7 @@ def _configure_pydantic_model(
|
|||||||
skip_fields = skip_fields or set()
|
skip_fields = skip_fields or set()
|
||||||
working_model = model.model_copy(deep=True)
|
working_model = model.model_copy(deep=True)
|
||||||
|
|
||||||
fields = [
|
fields: list[tuple[str, FieldInfo]] = [
|
||||||
(name, info)
|
(name, info)
|
||||||
for name, info in type(working_model).model_fields.items()
|
for name, info in type(working_model).model_fields.items()
|
||||||
if name not in skip_fields
|
if name not in skip_fields
|
||||||
@ -911,7 +948,7 @@ def _configure_pydantic_model(
|
|||||||
return working_model
|
return working_model
|
||||||
|
|
||||||
def get_choices() -> list[str]:
|
def get_choices() -> list[str]:
|
||||||
items = []
|
items: list[str] = []
|
||||||
for fname, finfo in fields:
|
for fname, finfo in fields:
|
||||||
value = getattr(working_model, fname, None)
|
value = getattr(working_model, fname, None)
|
||||||
display = _get_field_display_name(fname, finfo)
|
display = _get_field_display_name(fname, finfo)
|
||||||
@ -1057,6 +1094,10 @@ def _sync_preset_cache(config: Config) -> None:
|
|||||||
_MODEL_PRESET_CACHE.update(config.model_presets.keys())
|
_MODEL_PRESET_CACHE.update(config.model_presets.keys())
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_nonempty_name(text: str) -> bool | str:
|
||||||
|
return True if text and text.strip() else "Name cannot be empty"
|
||||||
|
|
||||||
|
|
||||||
def _configure_model_presets(config: Config) -> None:
|
def _configure_model_presets(config: Config) -> None:
|
||||||
"""Configure model presets (CRUD)."""
|
"""Configure model presets (CRUD)."""
|
||||||
_sync_preset_cache(config)
|
_sync_preset_cache(config)
|
||||||
@ -1099,7 +1140,7 @@ def _configure_model_presets(config: Config) -> None:
|
|||||||
if answer == "[+] Add new preset":
|
if answer == "[+] Add new preset":
|
||||||
name_input = _get_questionary().text(
|
name_input = _get_questionary().text(
|
||||||
"Preset name:",
|
"Preset name:",
|
||||||
validate=lambda t: True if t and t.strip() else "Name cannot be empty",
|
validate=_validate_nonempty_name,
|
||||||
).ask()
|
).ask()
|
||||||
if not name_input:
|
if not name_input:
|
||||||
continue
|
continue
|
||||||
@ -1218,7 +1259,7 @@ def _configure_providers(config: Config) -> None:
|
|||||||
|
|
||||||
def get_provider_choices() -> list[str]:
|
def get_provider_choices() -> list[str]:
|
||||||
"""Build provider choices with config status indicators."""
|
"""Build provider choices with config status indicators."""
|
||||||
choices = []
|
choices: list[str] = []
|
||||||
for name, display in _get_provider_names().items():
|
for name, display in _get_provider_names().items():
|
||||||
provider = getattr(config.providers, name, None)
|
provider = getattr(config.providers, name, None)
|
||||||
if provider and provider.api_key:
|
if provider and provider.api_key:
|
||||||
@ -1427,7 +1468,7 @@ _SETTINGS_SECTIONS: dict[str, tuple[str, str, set[str] | None]] = {
|
|||||||
"Tools": ("Tools Settings", "Configure web search, shell exec, and other tools", {"mcp_servers"}),
|
"Tools": ("Tools Settings", "Configure web search, shell exec, and other tools", {"mcp_servers"}),
|
||||||
}
|
}
|
||||||
|
|
||||||
_SETTINGS_GETTER = {
|
_SETTINGS_GETTER: dict[str, Callable[[Config], BaseModel]] = {
|
||||||
"Agent Settings": lambda c: c.agents.defaults,
|
"Agent Settings": lambda c: c.agents.defaults,
|
||||||
"Channel Common": lambda c: c.channels,
|
"Channel Common": lambda c: c.channels,
|
||||||
"API Server": lambda c: c.api,
|
"API Server": lambda c: c.api,
|
||||||
@ -1435,7 +1476,7 @@ _SETTINGS_GETTER = {
|
|||||||
"Tools": lambda c: c.tools,
|
"Tools": lambda c: c.tools,
|
||||||
}
|
}
|
||||||
|
|
||||||
_SETTINGS_SETTER = {
|
_SETTINGS_SETTER: dict[str, Callable[[Config, BaseModel], None]] = {
|
||||||
"Agent Settings": lambda c, v: setattr(c.agents, "defaults", v),
|
"Agent Settings": lambda c, v: setattr(c.agents, "defaults", v),
|
||||||
"Channel Common": lambda c, v: setattr(c, "channels", v),
|
"Channel Common": lambda c, v: setattr(c, "channels", v),
|
||||||
"API Server": lambda c, v: setattr(c, "api", v),
|
"API Server": lambda c, v: setattr(c, "api", v),
|
||||||
@ -1449,7 +1490,7 @@ def _configure_general_settings(config: Config, section: str) -> None:
|
|||||||
meta = _SETTINGS_SECTIONS.get(section)
|
meta = _SETTINGS_SECTIONS.get(section)
|
||||||
if not meta:
|
if not meta:
|
||||||
return
|
return
|
||||||
display_name, subtitle, skip = meta
|
display_name, _subtitle, skip = meta
|
||||||
model = _SETTINGS_GETTER[section](config)
|
model = _SETTINGS_GETTER[section](config)
|
||||||
updated = _configure_pydantic_model(model, display_name, skip_fields=skip)
|
updated = _configure_pydantic_model(model, display_name, skip_fields=skip)
|
||||||
if updated is not None:
|
if updated is not None:
|
||||||
@ -1495,7 +1536,7 @@ def _show_summary(config: Config) -> None:
|
|||||||
console.print()
|
console.print()
|
||||||
|
|
||||||
# Providers
|
# Providers
|
||||||
provider_rows = []
|
provider_rows: list[tuple[str, str]] = []
|
||||||
for name, display in _get_provider_names().items():
|
for name, display in _get_provider_names().items():
|
||||||
provider = getattr(config.providers, name, None)
|
provider = getattr(config.providers, name, None)
|
||||||
status = (
|
status = (
|
||||||
@ -1507,12 +1548,12 @@ def _show_summary(config: Config) -> None:
|
|||||||
_print_summary_panel(provider_rows, "LLM Providers")
|
_print_summary_panel(provider_rows, "LLM Providers")
|
||||||
|
|
||||||
# Channels
|
# Channels
|
||||||
channel_rows = []
|
channel_rows: list[tuple[str, str]] = []
|
||||||
for name, display in _get_channel_names().items():
|
for name, display in _get_channel_names().items():
|
||||||
channel = getattr(config.channels, name, None)
|
channel = getattr(config.channels, name, None)
|
||||||
if channel:
|
if channel:
|
||||||
enabled = (
|
enabled = (
|
||||||
channel.get("enabled", False)
|
cast(dict[str, Any], channel).get("enabled", False)
|
||||||
if isinstance(channel, dict)
|
if isinstance(channel, dict)
|
||||||
else getattr(channel, "enabled", False)
|
else getattr(channel, "enabled", False)
|
||||||
)
|
)
|
||||||
@ -1523,7 +1564,7 @@ def _show_summary(config: Config) -> None:
|
|||||||
_print_summary_panel(channel_rows, "Chat Channels")
|
_print_summary_panel(channel_rows, "Chat Channels")
|
||||||
|
|
||||||
# Model Presets
|
# Model Presets
|
||||||
preset_rows = []
|
preset_rows: list[tuple[str, str]] = []
|
||||||
for name, preset in config.model_presets.items():
|
for name, preset in config.model_presets.items():
|
||||||
preset_rows.append((name, f"{preset.model} - ctx {preset.context_window_tokens}"))
|
preset_rows.append((name, f"{preset.model} - ctx {preset.context_window_tokens}"))
|
||||||
_print_summary_panel(preset_rows, "Model Presets")
|
_print_summary_panel(preset_rows, "Model Presets")
|
||||||
@ -1562,7 +1603,7 @@ def _set_primary_quick_start_preset(config: Config, provider_name: str, model: s
|
|||||||
|
|
||||||
def _show_quick_start_progress(active_step: int) -> None:
|
def _show_quick_start_progress(active_step: int) -> None:
|
||||||
"""Render a compact step tracker for Quick Start."""
|
"""Render a compact step tracker for Quick Start."""
|
||||||
parts = []
|
parts: list[str] = []
|
||||||
for idx, label in enumerate(_QUICK_START_STEPS, 1):
|
for idx, label in enumerate(_QUICK_START_STEPS, 1):
|
||||||
if idx < active_step:
|
if idx < active_step:
|
||||||
parts.append(f"[{_UI_SUCCESS}]{idx}. {label}[/]")
|
parts.append(f"[{_UI_SUCCESS}]{idx}. {label}[/]")
|
||||||
@ -1755,7 +1796,10 @@ def _configure_quick_start_provider(config: Config) -> bool | object:
|
|||||||
continue
|
continue
|
||||||
if api_base_result is None:
|
if api_base_result is None:
|
||||||
return False
|
return False
|
||||||
api_base, base_was_prompted = api_base_result
|
api_base, base_was_prompted = cast(
|
||||||
|
tuple[str, bool],
|
||||||
|
api_base_result,
|
||||||
|
)
|
||||||
|
|
||||||
api_key: str | None = None
|
api_key: str | None = None
|
||||||
if _quick_start_requires_api_key(provider_name, provider_info):
|
if _quick_start_requires_api_key(provider_name, provider_info):
|
||||||
@ -1778,7 +1822,10 @@ def _configure_quick_start_provider(config: Config) -> bool | object:
|
|||||||
continue
|
continue
|
||||||
if api_base_result is None:
|
if api_base_result is None:
|
||||||
return False
|
return False
|
||||||
api_base, base_was_prompted = api_base_result
|
api_base, base_was_prompted = cast(
|
||||||
|
tuple[str, bool],
|
||||||
|
api_base_result,
|
||||||
|
)
|
||||||
|
|
||||||
provider_config = getattr(config.providers, provider_name, None)
|
provider_config = getattr(config.providers, provider_name, None)
|
||||||
if provider_config is None:
|
if provider_config is None:
|
||||||
@ -1792,7 +1839,7 @@ def _configure_quick_start_provider(config: Config) -> bool | object:
|
|||||||
)
|
)
|
||||||
if model is _BACK_PRESSED:
|
if model is _BACK_PRESSED:
|
||||||
continue
|
continue
|
||||||
model = (model or "").strip()
|
model = cast(str, model or "").strip()
|
||||||
if not model:
|
if not model:
|
||||||
console.print("[yellow]! Model ID is required for Quick Start[/yellow]")
|
console.print("[yellow]! Model ID is required for Quick Start[/yellow]")
|
||||||
return False
|
return False
|
||||||
@ -1850,7 +1897,7 @@ def _enable_quick_start_websocket_defaults(config: Config) -> bool:
|
|||||||
console.print("[red]No configuration class found for websocket[/red]")
|
console.print("[red]No configuration class found for websocket[/red]")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
current = getattr(config.channels, "websocket", None) or {}
|
current: Any = getattr(config.channels, "websocket", None) or {}
|
||||||
model = config_cls.model_validate(current)
|
model = config_cls.model_validate(current)
|
||||||
if hasattr(model, "enabled"):
|
if hasattr(model, "enabled"):
|
||||||
setattr(model, "enabled", True)
|
setattr(model, "enabled", True)
|
||||||
@ -1997,7 +2044,7 @@ def _configure_advanced_settings(config: Config) -> None:
|
|||||||
if answer is _BACK_PRESSED or answer is None or answer == "<- Back":
|
if answer is _BACK_PRESSED or answer is None or answer == "<- Back":
|
||||||
break
|
break
|
||||||
|
|
||||||
_advanced_dispatch = {
|
_advanced_dispatch: dict[str, Callable[[], None]] = {
|
||||||
"[P] LLM Provider": lambda: _configure_providers(config),
|
"[P] LLM Provider": lambda: _configure_providers(config),
|
||||||
"[M] Model Presets": lambda: _configure_model_presets(config),
|
"[M] Model Presets": lambda: _configure_model_presets(config),
|
||||||
"[C] Chat Channel": lambda: _configure_channels(config),
|
"[C] Chat Channel": lambda: _configure_channels(config),
|
||||||
@ -2008,9 +2055,9 @@ def _configure_advanced_settings(config: Config) -> None:
|
|||||||
"[T] Tools": lambda: _configure_general_settings(config, "Tools"),
|
"[T] Tools": lambda: _configure_general_settings(config, "Tools"),
|
||||||
"[V] View Configuration Summary": lambda: _show_summary(config),
|
"[V] View Configuration Summary": lambda: _show_summary(config),
|
||||||
}
|
}
|
||||||
action_fn = _advanced_dispatch.get(answer)
|
action_fn = _advanced_dispatch.get(cast(str, answer))
|
||||||
if action_fn:
|
if action_fn:
|
||||||
last_choice = answer
|
last_choice = cast(str, answer)
|
||||||
action_fn()
|
action_fn()
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -11,6 +11,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import sys
|
import sys
|
||||||
from contextlib import contextmanager, nullcontext
|
from contextlib import contextmanager, nullcontext
|
||||||
|
from typing import Literal
|
||||||
|
|
||||||
from rich.console import Console
|
from rich.console import Console
|
||||||
from rich.live import Live
|
from rich.live import Live
|
||||||
@ -51,12 +52,12 @@ class ThinkingSpinner:
|
|||||||
self._spinner = c.status(f"[dim]{bot_name} is thinking...[/dim]", spinner="dots")
|
self._spinner = c.status(f"[dim]{bot_name} is thinking...[/dim]", spinner="dots")
|
||||||
self._active = False
|
self._active = False
|
||||||
|
|
||||||
def __enter__(self):
|
def __enter__(self) -> ThinkingSpinner:
|
||||||
self._spinner.start()
|
self._spinner.start()
|
||||||
self._active = True
|
self._active = True
|
||||||
return self
|
return self
|
||||||
|
|
||||||
def __exit__(self, *exc):
|
def __exit__(self, *exc: object) -> Literal[False]:
|
||||||
self._active = False
|
self._active = False
|
||||||
self._spinner.stop()
|
self._spinner.stop()
|
||||||
_clear_current_line(self._console)
|
_clear_current_line(self._console)
|
||||||
@ -110,7 +111,7 @@ class StreamRenderer:
|
|||||||
self._header_printed = False
|
self._header_printed = False
|
||||||
self._start_spinner()
|
self._start_spinner()
|
||||||
|
|
||||||
def _renderable(self):
|
def _renderable(self) -> Markdown | Text:
|
||||||
"""Create a renderable from the current buffer."""
|
"""Create a renderable from the current buffer."""
|
||||||
if self._md and self._buf:
|
if self._md and self._buf:
|
||||||
return Markdown(self._buf)
|
return Markdown(self._buf)
|
||||||
|
|||||||
@ -9,16 +9,20 @@ import sys
|
|||||||
import time
|
import time
|
||||||
from contextlib import suppress
|
from contextlib import suppress
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from typing import Literal
|
from typing import TYPE_CHECKING, Any, Literal, cast
|
||||||
|
|
||||||
from nanobot import __version__
|
from nanobot import __version__
|
||||||
from nanobot.agent.goal_permission import goal_mutation_permission
|
|
||||||
from nanobot.bus.events import OutboundMessage
|
from nanobot.bus.events import OutboundMessage
|
||||||
from nanobot.command.router import CommandContext, CommandRouter, normalize_command_text
|
from nanobot.command.router import CommandContext, CommandRouter, normalize_command_text
|
||||||
from nanobot.utils.helpers import build_status_content
|
from nanobot.utils.helpers import build_status_content
|
||||||
from nanobot.utils.restart import set_restart_notice_to_env
|
from nanobot.utils.restart import set_restart_notice_to_env
|
||||||
from nanobot.utils.workspace_prompts import initialize_workspace_prompt
|
from nanobot.utils.workspace_prompts import initialize_workspace_prompt
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from nanobot.agent.loop import AgentLoop
|
||||||
|
from nanobot.session.manager import Session
|
||||||
|
from nanobot.utils.gitstore import CommitInfo
|
||||||
|
|
||||||
# WebUI protocol contract for how a slash command participates in turn state:
|
# WebUI protocol contract for how a slash command participates in turn state:
|
||||||
# - side_channel: returns control text without starting or ending an agent turn.
|
# - side_channel: returns control text without starting or ending an agent turn.
|
||||||
# - finalize_active_turn: side-channel command that also closes the active UI turn.
|
# - finalize_active_turn: side-channel command that also closes the active UI turn.
|
||||||
@ -199,9 +203,9 @@ async def cmd_stop(ctx: CommandContext) -> OutboundMessage:
|
|||||||
"""Cancel all active tasks and subagents for the session."""
|
"""Cancel all active tasks and subagents for the session."""
|
||||||
loop = ctx.loop
|
loop = ctx.loop
|
||||||
msg = ctx.msg
|
msg = ctx.msg
|
||||||
total = await loop._cancel_active_tasks(ctx.key)
|
total = await loop._cancel_active_tasks(ctx.key) # pyright: ignore[reportPrivateUsage]
|
||||||
# Also drain pending queue to prevent mid-turn injection deadlock
|
# Also drain pending queue to prevent mid-turn injection deadlock
|
||||||
pending = loop._pending_queues.pop(ctx.key, None)
|
pending = loop._pending_queues.pop(ctx.key, None) # pyright: ignore[reportPrivateUsage]
|
||||||
if pending is not None:
|
if pending is not None:
|
||||||
while not pending.empty():
|
while not pending.empty():
|
||||||
try:
|
try:
|
||||||
@ -228,14 +232,14 @@ async def cmd_restart(ctx: CommandContext) -> OutboundMessage:
|
|||||||
async def _do_restart():
|
async def _do_restart():
|
||||||
await asyncio.sleep(1)
|
await asyncio.sleep(1)
|
||||||
argv = [sys.executable, "-m", "nanobot"] + sys.argv[1:]
|
argv = [sys.executable, "-m", "nanobot"] + sys.argv[1:]
|
||||||
mode = getattr(ctx.loop, "restart_mode", "auto") or "auto"
|
mode = ctx.loop.restart_mode or "auto"
|
||||||
if mode == "auto":
|
if mode == "auto":
|
||||||
mode = "spawn" if sys.platform == "win32" else "exec"
|
mode = "spawn" if sys.platform == "win32" else "exec"
|
||||||
if mode == "exec":
|
if mode == "exec":
|
||||||
os.execv(sys.executable, argv)
|
os.execv(sys.executable, argv)
|
||||||
return
|
return
|
||||||
if mode == "spawn":
|
if mode == "spawn":
|
||||||
kwargs = {}
|
kwargs: dict[str, Any] = {}
|
||||||
if sys.platform == "win32":
|
if sys.platform == "win32":
|
||||||
kwargs["creationflags"] = subprocess.CREATE_NEW_PROCESS_GROUP
|
kwargs["creationflags"] = subprocess.CREATE_NEW_PROCESS_GROUP
|
||||||
subprocess.Popen(argv, **kwargs)
|
subprocess.Popen(argv, **kwargs)
|
||||||
@ -260,21 +264,20 @@ async def cmd_status(ctx: CommandContext) -> OutboundMessage:
|
|||||||
runtime=runtime,
|
runtime=runtime,
|
||||||
)
|
)
|
||||||
if ctx_est <= 0:
|
if ctx_est <= 0:
|
||||||
ctx_est = loop._last_usage.get("prompt_tokens", 0)
|
ctx_est = loop._last_usage.get("prompt_tokens", 0) # pyright: ignore[reportPrivateUsage]
|
||||||
|
|
||||||
# Fetch web search provider usage (best-effort, never blocks the response)
|
# Fetch web search provider usage (best-effort, never blocks the response)
|
||||||
search_usage_text: str | None = None
|
search_usage_text: str | None = None
|
||||||
# Never let usage fetch break /status
|
# Never let usage fetch break /status
|
||||||
with suppress(Exception):
|
with suppress(Exception):
|
||||||
from nanobot.utils.searchusage import fetch_search_usage
|
from nanobot.utils.searchusage import fetch_search_usage
|
||||||
web_cfg = getattr(loop, "web_config", None)
|
search_cfg = loop.web_config.search
|
||||||
search_cfg = getattr(web_cfg, "search", None) if web_cfg else None
|
usage = await fetch_search_usage(
|
||||||
if search_cfg is not None:
|
provider=search_cfg.provider,
|
||||||
provider = getattr(search_cfg, "provider", "duckduckgo")
|
api_key=search_cfg.api_key or None,
|
||||||
api_key = getattr(search_cfg, "api_key", "") or None
|
)
|
||||||
usage = await fetch_search_usage(provider=provider, api_key=api_key)
|
search_usage_text = usage.format()
|
||||||
search_usage_text = usage.format()
|
active_tasks = loop._active_tasks.get(ctx.key, []) # pyright: ignore[reportPrivateUsage]
|
||||||
active_tasks = loop._active_tasks.get(ctx.key, [])
|
|
||||||
task_count = sum(1 for t in active_tasks if not t.done())
|
task_count = sum(1 for t in active_tasks if not t.done())
|
||||||
with suppress(Exception):
|
with suppress(Exception):
|
||||||
task_count += loop.subagents.get_running_count_by_session(ctx.key)
|
task_count += loop.subagents.get_running_count_by_session(ctx.key)
|
||||||
@ -283,7 +286,7 @@ async def cmd_status(ctx: CommandContext) -> OutboundMessage:
|
|||||||
chat_id=ctx.msg.chat_id,
|
chat_id=ctx.msg.chat_id,
|
||||||
content=build_status_content(
|
content=build_status_content(
|
||||||
version=__version__, model=runtime.model,
|
version=__version__, model=runtime.model,
|
||||||
start_time=loop._start_time, last_usage=loop._last_usage,
|
start_time=loop._start_time, last_usage=loop._last_usage, # pyright: ignore[reportPrivateUsage]
|
||||||
context_window_tokens=runtime.context_window_tokens,
|
context_window_tokens=runtime.context_window_tokens,
|
||||||
session_msg_count=len(session.get_history(max_messages=0)),
|
session_msg_count=len(session.get_history(max_messages=0)),
|
||||||
context_tokens_estimate=ctx_est,
|
context_tokens_estimate=ctx_est,
|
||||||
@ -298,17 +301,18 @@ async def cmd_status(ctx: CommandContext) -> OutboundMessage:
|
|||||||
async def cmd_new(ctx: CommandContext) -> OutboundMessage:
|
async def cmd_new(ctx: CommandContext) -> OutboundMessage:
|
||||||
"""Stop active task and start a fresh session."""
|
"""Stop active task and start a fresh session."""
|
||||||
loop = ctx.loop
|
loop = ctx.loop
|
||||||
await loop._cancel_active_tasks(ctx.key)
|
await loop._cancel_active_tasks(ctx.key) # pyright: ignore[reportPrivateUsage]
|
||||||
session = ctx.session or loop.sessions.get_or_create(ctx.key)
|
session = ctx.session or loop.sessions.get_or_create(ctx.key)
|
||||||
snapshot = session.messages[session.last_consolidated:]
|
snapshot = session.messages[session.last_consolidated:]
|
||||||
|
runtime = None
|
||||||
if snapshot:
|
if snapshot:
|
||||||
runtime = ctx.runtime or loop.runtime_for_session(session)
|
runtime = ctx.runtime or loop.runtime_for_session(session)
|
||||||
session.clear()
|
session.clear()
|
||||||
loop.sessions.save(session)
|
loop.sessions.save(session)
|
||||||
loop.sessions.invalidate(session.key)
|
loop.sessions.invalidate(session.key)
|
||||||
if snapshot:
|
if snapshot and runtime is not None:
|
||||||
loop._schedule_background(
|
loop._schedule_background( # pyright: ignore[reportPrivateUsage]
|
||||||
loop.consolidator.archive(
|
loop.consolidator.archive( # pyright: ignore[reportUnknownMemberType]
|
||||||
snapshot,
|
snapshot,
|
||||||
runtime=runtime,
|
runtime=runtime,
|
||||||
session_key=ctx.key,
|
session_key=ctx.key,
|
||||||
@ -325,7 +329,7 @@ def _format_preset_names(names: list[str]) -> str:
|
|||||||
return ", ".join(f"`{name}`" for name in names) if names else "(none configured)"
|
return ", ".join(f"`{name}`" for name in names) if names else "(none configured)"
|
||||||
|
|
||||||
|
|
||||||
def _model_preset_names(loop) -> list[str]:
|
def _model_preset_names(loop: AgentLoop) -> list[str]:
|
||||||
names = set(loop.model_presets)
|
names = set(loop.model_presets)
|
||||||
names.add("default")
|
names.add("default")
|
||||||
return ["default", *sorted(name for name in names if name != "default")]
|
return ["default", *sorted(name for name in names if name != "default")]
|
||||||
@ -335,7 +339,7 @@ def _command_error_message(exc: Exception) -> str:
|
|||||||
return str(exc.args[0]) if isinstance(exc, KeyError) and exc.args else str(exc)
|
return str(exc.args[0]) if isinstance(exc, KeyError) and exc.args else str(exc)
|
||||||
|
|
||||||
|
|
||||||
def _model_command_status(loop, session) -> str:
|
def _model_command_status(loop: AgentLoop, session: Session) -> str:
|
||||||
names = _model_preset_names(loop)
|
names = _model_preset_names(loop)
|
||||||
try:
|
try:
|
||||||
runtime = loop.runtime_for_session(session, recover_removed=False)
|
runtime = loop.runtime_for_session(session, recover_removed=False)
|
||||||
@ -401,8 +405,7 @@ async def cmd_model(ctx: CommandContext) -> OutboundMessage:
|
|||||||
f"- Model: `{runtime.model}`",
|
f"- Model: `{runtime.model}`",
|
||||||
f"- Context window: {runtime.context_window_tokens}",
|
f"- Context window: {runtime.context_window_tokens}",
|
||||||
]
|
]
|
||||||
if max_tokens is not None:
|
lines.append(f"- Max output tokens: {max_tokens}")
|
||||||
lines.append(f"- Max output tokens: {max_tokens}")
|
|
||||||
return OutboundMessage(
|
return OutboundMessage(
|
||||||
channel=ctx.msg.channel,
|
channel=ctx.msg.channel,
|
||||||
chat_id=ctx.msg.chat_id,
|
chat_id=ctx.msg.chat_id,
|
||||||
@ -442,8 +445,7 @@ async def cmd_dream(ctx: CommandContext) -> OutboundMessage:
|
|||||||
return
|
return
|
||||||
prompt, last_cursor = result
|
prompt, last_cursor = result
|
||||||
key = dream_session_key()
|
key = dream_session_key()
|
||||||
resolve_dream_runtime = getattr(loop, "dream_runtime", None)
|
dream_runtime = loop.dream_runtime()
|
||||||
dream_runtime = resolve_dream_runtime() if callable(resolve_dream_runtime) else None
|
|
||||||
resp = await loop.process_direct(
|
resp = await loop.process_direct(
|
||||||
prompt,
|
prompt,
|
||||||
session_key=key,
|
session_key=key,
|
||||||
@ -640,7 +642,12 @@ def _format_changed_files(diff: str) -> str:
|
|||||||
_DREAM_COMMIT_PREFIX = "dream:"
|
_DREAM_COMMIT_PREFIX = "dream:"
|
||||||
|
|
||||||
|
|
||||||
def _format_dream_log_content(commit, diff: str, *, requested_sha: str | None = None) -> str:
|
def _format_dream_log_content(
|
||||||
|
commit: CommitInfo,
|
||||||
|
diff: str,
|
||||||
|
*,
|
||||||
|
requested_sha: str | None = None,
|
||||||
|
) -> str:
|
||||||
files_line = _format_changed_files(diff)
|
files_line = _format_changed_files(diff)
|
||||||
lines = [
|
lines = [
|
||||||
"## Dream Update",
|
"## Dream Update",
|
||||||
@ -668,7 +675,7 @@ def _format_dream_log_content(commit, diff: str, *, requested_sha: str | None =
|
|||||||
return "\n".join(lines)
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
def _format_dream_restore_list(commits: list) -> str:
|
def _format_dream_restore_list(commits: list[CommitInfo]) -> str:
|
||||||
lines = [
|
lines = [
|
||||||
"## Dream Restore",
|
"## Dream Restore",
|
||||||
"",
|
"",
|
||||||
@ -806,14 +813,20 @@ _HISTORY_MAX_COUNT = 50
|
|||||||
_HISTORY_MAX_CONTENT_CHARS = 200
|
_HISTORY_MAX_CONTENT_CHARS = 200
|
||||||
|
|
||||||
|
|
||||||
def _format_history_message(msg: dict) -> str | None:
|
def _format_history_message(msg: dict[str, Any]) -> str | None:
|
||||||
"""Format a single history message for display. Returns None to skip."""
|
"""Format a single history message for display. Returns None to skip."""
|
||||||
role = msg.get("role")
|
role = msg.get("role")
|
||||||
if role not in ("user", "assistant"):
|
if role not in ("user", "assistant"):
|
||||||
return None
|
return None
|
||||||
content = msg.get("content") or ""
|
content = msg.get("content") or ""
|
||||||
if isinstance(content, list):
|
if isinstance(content, list):
|
||||||
parts = [b.get("text", "") for b in content if isinstance(b, dict) and b.get("type") == "text"]
|
parts = [
|
||||||
|
text
|
||||||
|
for block in cast(list[object], content)
|
||||||
|
if (item := cast(dict[str, Any], block) if isinstance(block, dict) else None)
|
||||||
|
and item.get("type") == "text"
|
||||||
|
and isinstance(text := item.get("text"), str)
|
||||||
|
]
|
||||||
content = " ".join(parts)
|
content = " ".join(parts)
|
||||||
content = str(content).strip()
|
content = str(content).strip()
|
||||||
if not content:
|
if not content:
|
||||||
@ -863,6 +876,8 @@ async def cmd_history(ctx: CommandContext) -> OutboundMessage:
|
|||||||
|
|
||||||
async def cmd_goal(ctx: CommandContext) -> OutboundMessage | None:
|
async def cmd_goal(ctx: CommandContext) -> OutboundMessage | None:
|
||||||
"""Mark this turn as an explicit sustained-goal request."""
|
"""Mark this turn as an explicit sustained-goal request."""
|
||||||
|
from nanobot.agent.goal_permission import goal_mutation_permission
|
||||||
|
|
||||||
goal = ctx.args.strip()
|
goal = ctx.args.strip()
|
||||||
if not goal:
|
if not goal:
|
||||||
return OutboundMessage(
|
return OutboundMessage(
|
||||||
@ -923,7 +938,7 @@ async def cmd_skill(ctx: CommandContext) -> OutboundMessage:
|
|||||||
else:
|
else:
|
||||||
lines = [f"Available skills ({len(skills)}):", ""]
|
lines = [f"Available skills ({len(skills)}):", ""]
|
||||||
for entry in skills:
|
for entry in skills:
|
||||||
desc = loop.context.skills._get_skill_description(entry["name"])
|
desc = loop.context.skills.get_skill_description(entry["name"])
|
||||||
lines.append(f"- **{entry['name']}** — {desc}")
|
lines.append(f"- **{entry['name']}** — {desc}")
|
||||||
content = "\n".join(lines)
|
content = "\n".join(lines)
|
||||||
return OutboundMessage(
|
return OutboundMessage(
|
||||||
@ -951,15 +966,9 @@ async def cmd_trigger(ctx: CommandContext) -> OutboundMessage:
|
|||||||
from nanobot.triggers.local_store import LocalTriggerStore
|
from nanobot.triggers.local_store import LocalTriggerStore
|
||||||
|
|
||||||
loop = ctx.loop
|
loop = ctx.loop
|
||||||
workspace = getattr(loop, "workspace", None)
|
store = loop.local_trigger_store
|
||||||
if workspace is None:
|
|
||||||
workspace = getattr(getattr(loop, "context", None), "workspace", None)
|
|
||||||
if workspace is None:
|
|
||||||
raise RuntimeError("workspace unavailable for trigger creation")
|
|
||||||
|
|
||||||
store = getattr(loop, "local_trigger_store", None)
|
|
||||||
if store is None:
|
if store is None:
|
||||||
store = LocalTriggerStore(workspace)
|
store = LocalTriggerStore(loop.workspace)
|
||||||
|
|
||||||
from nanobot.session.keys import UNIFIED_SESSION_KEY
|
from nanobot.session.keys import UNIFIED_SESSION_KEY
|
||||||
|
|
||||||
|
|||||||
@ -8,6 +8,7 @@ from dataclasses import dataclass, field
|
|||||||
from typing import TYPE_CHECKING, Any, Awaitable, Callable
|
from typing import TYPE_CHECKING, Any, Awaitable, Callable
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
|
from nanobot.agent.loop import AgentLoop
|
||||||
from nanobot.bus.events import InboundMessage, OutboundMessage
|
from nanobot.bus.events import InboundMessage, OutboundMessage
|
||||||
from nanobot.session.manager import Session
|
from nanobot.session.manager import Session
|
||||||
from nanobot.utils.llm_runtime import LLMRuntime
|
from nanobot.utils.llm_runtime import LLMRuntime
|
||||||
@ -44,7 +45,7 @@ class CommandContext:
|
|||||||
key: str
|
key: str
|
||||||
raw: str
|
raw: str
|
||||||
args: str = ""
|
args: str = ""
|
||||||
loop: Any = None
|
loop: AgentLoop = field(kw_only=True)
|
||||||
runtime: LLMRuntime | None = None
|
runtime: LLMRuntime | None = None
|
||||||
is_user_turn: bool = False
|
is_user_turn: bool = False
|
||||||
turn_scopes: list[AbstractContextManager[Any]] = field(default_factory=list)
|
turn_scopes: list[AbstractContextManager[Any]] = field(default_factory=list)
|
||||||
|
|||||||
@ -4,20 +4,28 @@ import json
|
|||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any
|
from typing import Any, cast, overload
|
||||||
|
|
||||||
from pydantic import BaseModel, ValidationError
|
from pydantic import BaseModel, ValidationError
|
||||||
from pydantic_settings import SettingsError
|
from pydantic_settings import SettingsError
|
||||||
|
|
||||||
from nanobot.config.errors import ConfigIssue, ConfigLoadError, validation_issues
|
from nanobot.config.errors import ConfigIssue, ConfigLoadError, validation_issues
|
||||||
from nanobot.config.schema import Config, _resolve_tool_config_refs
|
from nanobot.config.schema import (
|
||||||
from nanobot.utils.helpers import _write_text_atomic
|
Config,
|
||||||
|
_resolve_tool_config_refs, # pyright: ignore[reportPrivateUsage]
|
||||||
|
)
|
||||||
|
from nanobot.utils.helpers import _write_text_atomic # pyright: ignore[reportPrivateUsage]
|
||||||
|
|
||||||
# Global variable to store current config path (for multi-instance support)
|
# Global variable to store current config path (for multi-instance support)
|
||||||
_current_config_path: Path | None = None
|
_current_config_path: Path | None = None
|
||||||
_schema_refs_ready = False
|
_schema_refs_ready = False
|
||||||
|
|
||||||
|
|
||||||
|
def _as_config_object(value: object) -> dict[str, Any] | None:
|
||||||
|
"""Narrow an untrusted JSON configuration value to an object."""
|
||||||
|
return cast(dict[str, Any], value) if isinstance(value, dict) else None
|
||||||
|
|
||||||
|
|
||||||
def set_config_path(path: Path) -> None:
|
def set_config_path(path: Path) -> None:
|
||||||
"""Set the current config path (used to derive data directory)."""
|
"""Set the current config path (used to derive data directory)."""
|
||||||
global _current_config_path
|
global _current_config_path
|
||||||
@ -110,7 +118,7 @@ def load_config(config_path: Path | None = None) -> Config:
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
data = _migrate_config(data)
|
data = _migrate_config(cast(dict[str, Any], data))
|
||||||
try:
|
try:
|
||||||
config = Config.model_validate(data)
|
config = Config.model_validate(data)
|
||||||
except ValidationError as exc:
|
except ValidationError as exc:
|
||||||
@ -164,13 +172,15 @@ def save_config(config: Config, config_path: Path | None = None) -> None:
|
|||||||
_write_text_atomic(path, json.dumps(data, indent=2, ensure_ascii=False))
|
_write_text_atomic(path, json.dumps(data, indent=2, ensure_ascii=False))
|
||||||
|
|
||||||
|
|
||||||
def merge_missing_defaults(existing: Any, defaults: Any) -> Any:
|
def merge_missing_defaults(existing: object, defaults: object) -> object:
|
||||||
"""Recursively add missing defaults without replacing configured values."""
|
"""Recursively add missing defaults without replacing configured values."""
|
||||||
if not isinstance(existing, dict) or not isinstance(defaults, dict):
|
if not isinstance(existing, dict) or not isinstance(defaults, dict):
|
||||||
return existing
|
return cast(object, existing)
|
||||||
|
|
||||||
merged = dict(existing)
|
existing_dict = cast(dict[str, object], existing)
|
||||||
for key, value in defaults.items():
|
defaults_dict = cast(dict[str, object], defaults)
|
||||||
|
merged = dict(existing_dict)
|
||||||
|
for key, value in defaults_dict.items():
|
||||||
if key not in merged:
|
if key not in merged:
|
||||||
merged[key] = value
|
merged[key] = value
|
||||||
else:
|
else:
|
||||||
@ -203,7 +213,15 @@ def resolve_config_env_vars(
|
|||||||
return _resolve_in_place(config)
|
return _resolve_in_place(config)
|
||||||
|
|
||||||
|
|
||||||
def resolve_env_refs(value: str) -> str:
|
@overload
|
||||||
|
def resolve_env_refs(value: str) -> str: ...
|
||||||
|
|
||||||
|
|
||||||
|
@overload
|
||||||
|
def resolve_env_refs(value: object) -> object: ...
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_env_refs(value: object) -> object:
|
||||||
"""Resolve ``${VAR}`` references in a single string, leniently.
|
"""Resolve ``${VAR}`` references in a single string, leniently.
|
||||||
|
|
||||||
Unlike :func:`resolve_config_env_vars` (which walks a whole ``Config`` and
|
Unlike :func:`resolve_config_env_vars` (which walks a whole ``Config`` and
|
||||||
@ -245,11 +263,21 @@ def _resolve_in_place(obj: Any) -> Any:
|
|||||||
copy.__pydantic_extra__ = new_extras
|
copy.__pydantic_extra__ = new_extras
|
||||||
return copy
|
return copy
|
||||||
if isinstance(obj, dict):
|
if isinstance(obj, dict):
|
||||||
resolved = {k: _resolve_in_place(v) for k, v in obj.items()}
|
object_dict = cast(dict[str, Any], obj)
|
||||||
return resolved if any(resolved[k] is not obj[k] for k in obj) else obj
|
resolved = {key: _resolve_in_place(value) for key, value in object_dict.items()}
|
||||||
|
return (
|
||||||
|
resolved
|
||||||
|
if any(resolved[key] is not object_dict[key] for key in object_dict)
|
||||||
|
else cast(object, obj)
|
||||||
|
)
|
||||||
if isinstance(obj, list):
|
if isinstance(obj, list):
|
||||||
resolved = [_resolve_in_place(v) for v in obj]
|
object_list = cast(list[Any], obj)
|
||||||
return resolved if any(nv is not ov for nv, ov in zip(resolved, obj)) else obj
|
resolved = [_resolve_in_place(value) for value in object_list]
|
||||||
|
return (
|
||||||
|
resolved
|
||||||
|
if any(new is not old for new, old in zip(resolved, object_list))
|
||||||
|
else cast(object, obj)
|
||||||
|
)
|
||||||
return obj
|
return obj
|
||||||
|
|
||||||
|
|
||||||
@ -270,20 +298,21 @@ def _missing_env_issues(
|
|||||||
issues: list[ConfigIssue] = []
|
issues: list[ConfigIssue] = []
|
||||||
for name, field in type(obj).model_fields.items():
|
for name, field in type(obj).model_fields.items():
|
||||||
alias = field.serialization_alias or field.alias or name
|
alias = field.serialization_alias or field.alias or name
|
||||||
part = alias if isinstance(alias, str) else name
|
part = alias
|
||||||
issues.extend(_missing_env_issues(getattr(obj, name), (*path, part)))
|
issues.extend(_missing_env_issues(getattr(obj, name), (*path, part)))
|
||||||
for name, value in (obj.__pydantic_extra__ or {}).items():
|
for name, value in (obj.__pydantic_extra__ or {}).items():
|
||||||
issues.extend(_missing_env_issues(value, (*path, name)))
|
issues.extend(_missing_env_issues(value, (*path, name)))
|
||||||
return issues
|
return issues
|
||||||
if isinstance(obj, dict):
|
if isinstance(obj, dict):
|
||||||
|
object_dict = cast(dict[str | int, Any], obj)
|
||||||
issues = []
|
issues = []
|
||||||
for name, value in obj.items():
|
for name, value in object_dict.items():
|
||||||
part = name if isinstance(name, (str, int)) else str(name)
|
part = name
|
||||||
issues.extend(_missing_env_issues(value, (*path, part)))
|
issues.extend(_missing_env_issues(value, (*path, part)))
|
||||||
return issues
|
return issues
|
||||||
if isinstance(obj, list):
|
if isinstance(obj, list):
|
||||||
issues = []
|
issues = []
|
||||||
for index, value in enumerate(obj):
|
for index, value in enumerate(cast(list[Any], obj)):
|
||||||
issues.extend(_missing_env_issues(value, (*path, index)))
|
issues.extend(_missing_env_issues(value, (*path, index)))
|
||||||
return issues
|
return issues
|
||||||
return []
|
return []
|
||||||
@ -294,9 +323,12 @@ def _resolve_env_vars(obj: object) -> object:
|
|||||||
if isinstance(obj, str):
|
if isinstance(obj, str):
|
||||||
return _ENV_REF_PATTERN.sub(_env_replace, obj)
|
return _ENV_REF_PATTERN.sub(_env_replace, obj)
|
||||||
if isinstance(obj, dict):
|
if isinstance(obj, dict):
|
||||||
return {k: _resolve_env_vars(v) for k, v in obj.items()}
|
return {
|
||||||
|
key: _resolve_env_vars(value)
|
||||||
|
for key, value in cast(dict[str, object], obj).items()
|
||||||
|
}
|
||||||
if isinstance(obj, list):
|
if isinstance(obj, list):
|
||||||
return [_resolve_env_vars(v) for v in obj]
|
return [_resolve_env_vars(value) for value in cast(list[object], obj)]
|
||||||
return obj
|
return obj
|
||||||
|
|
||||||
|
|
||||||
@ -310,15 +342,16 @@ def _env_replace(match: re.Match[str]) -> str:
|
|||||||
return value
|
return value
|
||||||
|
|
||||||
|
|
||||||
def _migrate_config(data: dict) -> dict:
|
def _migrate_config(data: dict[str, Any]) -> dict[str, Any]:
|
||||||
"""Migrate old config formats to current."""
|
"""Migrate old config formats to current."""
|
||||||
# Move tools.exec.restrictToWorkspace → tools.restrictToWorkspace
|
# Move tools.exec.restrictToWorkspace → tools.restrictToWorkspace
|
||||||
tools = data.get("tools", {})
|
tools_value = data.get("tools", {})
|
||||||
if not isinstance(tools, dict):
|
if not isinstance(tools_value, dict):
|
||||||
return data
|
return data
|
||||||
exec_cfg = tools.get("exec", {})
|
tools = cast(dict[str, Any], tools_value)
|
||||||
|
exec_cfg = _as_config_object(tools.get("exec", {}))
|
||||||
if (
|
if (
|
||||||
isinstance(exec_cfg, dict)
|
exec_cfg is not None
|
||||||
and "restrictToWorkspace" in exec_cfg
|
and "restrictToWorkspace" in exec_cfg
|
||||||
and "restrictToWorkspace" not in tools
|
and "restrictToWorkspace" not in tools
|
||||||
):
|
):
|
||||||
@ -334,6 +367,7 @@ def _migrate_config(data: dict) -> dict:
|
|||||||
tools["my"] = my_cfg
|
tools["my"] = my_cfg
|
||||||
if not isinstance(my_cfg, dict):
|
if not isinstance(my_cfg, dict):
|
||||||
return data
|
return data
|
||||||
|
my_cfg = cast(dict[str, Any], my_cfg)
|
||||||
if "myEnabled" in tools and "enable" not in my_cfg:
|
if "myEnabled" in tools and "enable" not in my_cfg:
|
||||||
my_cfg["enable"] = tools.pop("myEnabled")
|
my_cfg["enable"] = tools.pop("myEnabled")
|
||||||
else:
|
else:
|
||||||
|
|||||||
@ -48,7 +48,7 @@ def get_webui_dir() -> Path:
|
|||||||
return get_runtime_subdir("webui")
|
return get_runtime_subdir("webui")
|
||||||
|
|
||||||
|
|
||||||
def get_workspace_path(workspace: str | None = None) -> Path:
|
def get_workspace_path(workspace: str | Path | None = None) -> Path:
|
||||||
"""Resolve and ensure the agent workspace path."""
|
"""Resolve and ensure the agent workspace path."""
|
||||||
path = Path(workspace).expanduser() if workspace else Path.home() / ".nanobot" / "workspace"
|
path = Path(workspace).expanduser() if workspace else Path.home() / ".nanobot" / "workspace"
|
||||||
return ensure_dir(path)
|
return ensure_dir(path)
|
||||||
|
|||||||
@ -5,7 +5,7 @@ from pathlib import Path
|
|||||||
from typing import TYPE_CHECKING, Any, ClassVar, Literal
|
from typing import TYPE_CHECKING, Any, ClassVar, Literal
|
||||||
|
|
||||||
from pydantic import AliasChoices, ConfigDict, Field, field_validator, model_validator
|
from pydantic import AliasChoices, ConfigDict, Field, field_validator, model_validator
|
||||||
from pydantic_settings import BaseSettings
|
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||||
|
|
||||||
from nanobot.config_base import Base
|
from nanobot.config_base import Base
|
||||||
from nanobot.cron.types import CronSchedule
|
from nanobot.cron.types import CronSchedule
|
||||||
@ -618,7 +618,10 @@ class Config(BaseSettings):
|
|||||||
return spec.default_api_base
|
return spec.default_api_base
|
||||||
return None
|
return None
|
||||||
|
|
||||||
model_config = ConfigDict(env_prefix="NANOBOT_", env_nested_delimiter="__")
|
model_config = SettingsConfigDict(
|
||||||
|
env_prefix="NANOBOT_",
|
||||||
|
env_nested_delimiter="__",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _resolve_tool_config_refs() -> None:
|
def _resolve_tool_config_refs() -> None:
|
||||||
|
|||||||
@ -5,7 +5,7 @@ from __future__ import annotations
|
|||||||
from collections.abc import Callable
|
from collections.abc import Callable
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from watchfiles import Change, awatch
|
from watchfiles import Change, awatch # pyright: ignore[reportUnknownVariableType]
|
||||||
|
|
||||||
|
|
||||||
async def watch_config_file(config_path: Path, on_change: Callable[[], None]) -> None:
|
async def watch_config_file(config_path: Path, on_change: Callable[[], None]) -> None:
|
||||||
|
|||||||
@ -1,13 +1,18 @@
|
|||||||
"""Cron service for scheduled agent tasks."""
|
"""Cron service for scheduled agent tasks."""
|
||||||
|
|
||||||
|
from typing import TYPE_CHECKING, Any
|
||||||
|
|
||||||
from nanobot.cron.types import CronJob, CronSchedule
|
from nanobot.cron.types import CronJob, CronSchedule
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from nanobot.cron.service import CronService
|
||||||
|
|
||||||
__all__ = ["CronService", "CronJob", "CronSchedule"]
|
__all__ = ["CronService", "CronJob", "CronSchedule"]
|
||||||
|
|
||||||
_LAZY = {"CronService": ".service"}
|
_LAZY = {"CronService": ".service"}
|
||||||
|
|
||||||
|
|
||||||
def __getattr__(name: str):
|
def __getattr__(name: str) -> Any:
|
||||||
module_path = _LAZY.get(name)
|
module_path = _LAZY.get(name)
|
||||||
if module_path is None:
|
if module_path is None:
|
||||||
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
||||||
|
|||||||
@ -6,7 +6,7 @@ import asyncio
|
|||||||
import hashlib
|
import hashlib
|
||||||
import time
|
import time
|
||||||
import uuid
|
import uuid
|
||||||
from typing import Any, Protocol
|
from typing import TYPE_CHECKING, Any, Protocol
|
||||||
|
|
||||||
from nanobot.agent.tools.cron import CronTool
|
from nanobot.agent.tools.cron import CronTool
|
||||||
from nanobot.bus.events import InboundMessage, OutboundMessage
|
from nanobot.bus.events import InboundMessage, OutboundMessage
|
||||||
@ -16,9 +16,12 @@ from nanobot.cron.types import CronJob
|
|||||||
from nanobot.cron.webui_metadata import cron_proactive_delivery_metadata
|
from nanobot.cron.webui_metadata import cron_proactive_delivery_metadata
|
||||||
from nanobot.utils.prompt_templates import render_template
|
from nanobot.utils.prompt_templates import render_template
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from nanobot.agent.tools.registry import ToolRegistry
|
||||||
|
|
||||||
|
|
||||||
class BoundCronAgent(Protocol):
|
class BoundCronAgent(Protocol):
|
||||||
tools: Any
|
tools: ToolRegistry
|
||||||
|
|
||||||
async def submit_cron_turn(self, msg: InboundMessage) -> OutboundMessage | None:
|
async def submit_cron_turn(self, msg: InboundMessage) -> OutboundMessage | None:
|
||||||
...
|
...
|
||||||
|
|||||||
@ -10,6 +10,7 @@ from contextlib import suppress
|
|||||||
from dataclasses import asdict
|
from dataclasses import asdict
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
from types import EllipsisType
|
||||||
from typing import Any, Callable, Coroutine, Literal
|
from typing import Any, Callable, Coroutine, Literal
|
||||||
|
|
||||||
from filelock import FileLock
|
from filelock import FileLock
|
||||||
@ -160,7 +161,7 @@ class CronService:
|
|||||||
self._lock = FileLock(str(self._action_path.parent) + ".lock")
|
self._lock = FileLock(str(self._action_path.parent) + ".lock")
|
||||||
self.on_job = on_job
|
self.on_job = on_job
|
||||||
self._store: CronStore | None = None
|
self._store: CronStore | None = None
|
||||||
self._timer_task: asyncio.Task | None = None
|
self._timer_task: asyncio.Task[None] | None = None
|
||||||
self._running = False
|
self._running = False
|
||||||
self._timer_active = False
|
self._timer_active = False
|
||||||
self.max_sleep_ms = max_sleep_ms
|
self.max_sleep_ms = max_sleep_ms
|
||||||
@ -243,19 +244,21 @@ class CronService:
|
|||||||
return None
|
return None
|
||||||
return jobs, version
|
return jobs, version
|
||||||
|
|
||||||
def _merge_action(self):
|
def _merge_action(self) -> None:
|
||||||
if not self._action_path.exists():
|
if not self._action_path.exists():
|
||||||
return
|
return
|
||||||
|
|
||||||
jobs_map = {j.id: j for j in self._store.jobs}
|
jobs_map = {job.id: job for job in self._store.jobs} # pyright: ignore[reportOptionalMemberAccess]
|
||||||
def _update(params: dict):
|
|
||||||
|
def _update(params: dict[str, Any]) -> None:
|
||||||
j = CronJob.from_dict(params)
|
j = CronJob.from_dict(params)
|
||||||
_normalize_agent_turn_job(j)
|
_normalize_agent_turn_job(j)
|
||||||
jobs_map[j.id] = j
|
jobs_map[j.id] = j
|
||||||
|
|
||||||
def _del(params: dict):
|
def _del(params: dict[str, Any]) -> None:
|
||||||
if job_id := params.get("job_id"):
|
job_id = params.get("job_id")
|
||||||
jobs_map.pop(job_id)
|
if isinstance(job_id, str) and job_id:
|
||||||
|
jobs_map.pop(job_id, None)
|
||||||
|
|
||||||
with self._lock:
|
with self._lock:
|
||||||
with open(self._action_path, "r", encoding="utf-8") as f:
|
with open(self._action_path, "r", encoding="utf-8") as f:
|
||||||
@ -274,7 +277,7 @@ class CronService:
|
|||||||
except Exception:
|
except Exception:
|
||||||
logger.exception("load action line error")
|
logger.exception("load action line error")
|
||||||
continue
|
continue
|
||||||
self._store.jobs = list(jobs_map.values())
|
self._store.jobs = list(jobs_map.values()) # pyright: ignore[reportOptionalMemberAccess]
|
||||||
if self._running and changed:
|
if self._running and changed:
|
||||||
self._action_path.write_text("", encoding="utf-8")
|
self._action_path.write_text("", encoding="utf-8")
|
||||||
self._save_store()
|
self._save_store()
|
||||||
@ -569,7 +572,8 @@ class CronService:
|
|||||||
# Handle one-shot jobs
|
# Handle one-shot jobs
|
||||||
if job.schedule.kind == "at":
|
if job.schedule.kind == "at":
|
||||||
if job.delete_after_run:
|
if job.delete_after_run:
|
||||||
self._store.jobs = [j for j in self._store.jobs if j.id != job.id]
|
store = self._require_store()
|
||||||
|
store.jobs = [item for item in store.jobs if item.id != job.id]
|
||||||
else:
|
else:
|
||||||
job.enabled = False
|
job.enabled = False
|
||||||
job.state.next_run_at_ms = None
|
job.state.next_run_at_ms = None
|
||||||
@ -577,7 +581,11 @@ class CronService:
|
|||||||
# Compute next run
|
# Compute next run
|
||||||
job.state.next_run_at_ms = _compute_next_run(job.schedule, _now_ms())
|
job.state.next_run_at_ms = _compute_next_run(job.schedule, _now_ms())
|
||||||
|
|
||||||
def _append_action(self, action: Literal["add", "del", "update"], params: dict):
|
def _append_action(
|
||||||
|
self,
|
||||||
|
action: Literal["add", "del", "update"],
|
||||||
|
params: dict[str, Any],
|
||||||
|
) -> None:
|
||||||
self.store_path.parent.mkdir(parents=True, exist_ok=True)
|
self.store_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
with self._lock:
|
with self._lock:
|
||||||
with open(self._action_path, "a", encoding="utf-8") as f:
|
with open(self._action_path, "a", encoding="utf-8") as f:
|
||||||
@ -615,11 +623,11 @@ class CronService:
|
|||||||
channel: str | None = None,
|
channel: str | None = None,
|
||||||
to: str | None = None,
|
to: str | None = None,
|
||||||
delete_after_run: bool = False,
|
delete_after_run: bool = False,
|
||||||
channel_meta: dict | None = None,
|
channel_meta: dict[str, Any] | None = None,
|
||||||
session_key: str | None = None,
|
session_key: str | None = None,
|
||||||
origin_channel: str | None = None,
|
origin_channel: str | None = None,
|
||||||
origin_chat_id: str | None = None,
|
origin_chat_id: str | None = None,
|
||||||
origin_metadata: dict | None = None,
|
origin_metadata: dict[str, Any] | None = None,
|
||||||
) -> CronJob:
|
) -> CronJob:
|
||||||
"""Add a new job."""
|
"""Add a new job."""
|
||||||
_validate_schedule_for_add(schedule)
|
_validate_schedule_for_add(schedule)
|
||||||
@ -727,8 +735,8 @@ class CronService:
|
|||||||
schedule: CronSchedule | None = None,
|
schedule: CronSchedule | None = None,
|
||||||
message: str | None = None,
|
message: str | None = None,
|
||||||
deliver: bool | None = None,
|
deliver: bool | None = None,
|
||||||
channel: str | None = ...,
|
channel: str | None | EllipsisType = ...,
|
||||||
to: str | None = ...,
|
to: str | None | EllipsisType = ...,
|
||||||
delete_after_run: bool | None = None,
|
delete_after_run: bool | None = None,
|
||||||
) -> CronJob | Literal["not_found", "protected"]:
|
) -> CronJob | Literal["not_found", "protected"]:
|
||||||
"""Update mutable fields of an existing job. System jobs cannot be updated.
|
"""Update mutable fields of an existing job. System jobs cannot be updated.
|
||||||
@ -804,7 +812,7 @@ class CronService:
|
|||||||
store = self._require_store()
|
store = self._require_store()
|
||||||
return next((j for j in store.jobs if j.id == job_id), None)
|
return next((j for j in store.jobs if j.id == job_id), None)
|
||||||
|
|
||||||
def status(self) -> dict:
|
def status(self) -> dict[str, object]:
|
||||||
"""Get service status."""
|
"""Get service status."""
|
||||||
store = self._require_store()
|
store = self._require_store()
|
||||||
return {
|
return {
|
||||||
|
|||||||
@ -3,11 +3,19 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from typing import Any, Literal
|
from typing import Any, Literal, cast, overload
|
||||||
|
|
||||||
from nanobot.utils.dict_keys import get_camel_snake
|
from nanobot.utils.dict_keys import get_camel_snake
|
||||||
|
|
||||||
|
|
||||||
|
@overload
|
||||||
|
def _store_int(value: Any, default: Literal[None]) -> int | None: ...
|
||||||
|
|
||||||
|
|
||||||
|
@overload
|
||||||
|
def _store_int(value: Any, default: int = 0) -> int: ...
|
||||||
|
|
||||||
|
|
||||||
def _store_int(value: Any, default: int | None = 0) -> int | None:
|
def _store_int(value: Any, default: int | None = 0) -> int | None:
|
||||||
"""Coerce JSON numerics to int; treat null/blank like a missing key."""
|
"""Coerce JSON numerics to int; treat null/blank like a missing key."""
|
||||||
if value is None or value == "":
|
if value is None or value == "":
|
||||||
@ -103,7 +111,10 @@ class CronJobState:
|
|||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def from_store_dict(cls, data: dict[str, Any]) -> CronJobState:
|
def from_store_dict(cls, data: dict[str, Any]) -> CronJobState:
|
||||||
history = get_camel_snake(data, "runHistory", "run_history", []) or []
|
history = cast(
|
||||||
|
list[object],
|
||||||
|
get_camel_snake(data, "runHistory", "run_history", []) or [],
|
||||||
|
)
|
||||||
return cls(
|
return cls(
|
||||||
next_run_at_ms=_store_int(
|
next_run_at_ms=_store_int(
|
||||||
get_camel_snake(data, "nextRunAtMs", "next_run_at_ms"), None
|
get_camel_snake(data, "nextRunAtMs", "next_run_at_ms"), None
|
||||||
@ -116,7 +127,7 @@ class CronJobState:
|
|||||||
run_history=[
|
run_history=[
|
||||||
record
|
record
|
||||||
if isinstance(record, CronRunRecord)
|
if isinstance(record, CronRunRecord)
|
||||||
else CronRunRecord.from_store_dict(record)
|
else CronRunRecord.from_store_dict(cast(dict[str, Any], record))
|
||||||
for record in history
|
for record in history
|
||||||
if isinstance(record, (dict, CronRunRecord))
|
if isinstance(record, (dict, CronRunRecord))
|
||||||
],
|
],
|
||||||
@ -137,16 +148,20 @@ class CronJob:
|
|||||||
delete_after_run: bool = False
|
delete_after_run: bool = False
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def from_dict(cls, kwargs: dict):
|
def from_dict(cls, kwargs: dict[str, Any]) -> CronJob:
|
||||||
state_kwargs = dict(kwargs.get("state", {}))
|
state_kwargs = dict(cast(dict[str, Any], kwargs.get("state", {})))
|
||||||
state_kwargs["run_history"] = [
|
state_kwargs["run_history"] = [
|
||||||
record if isinstance(record, CronRunRecord) else CronRunRecord(**record)
|
record
|
||||||
for record in state_kwargs.get("run_history", [])
|
if isinstance(record, CronRunRecord)
|
||||||
|
else CronRunRecord(**cast(dict[str, Any], record))
|
||||||
|
for record in cast(list[object], state_kwargs.get("run_history", []))
|
||||||
]
|
]
|
||||||
kwargs["schedule"] = CronSchedule(**kwargs.get("schedule", {"kind": "every"}))
|
kwargs["schedule"] = CronSchedule(
|
||||||
kwargs["payload"] = CronPayload(**kwargs.get("payload", {}))
|
**cast(dict[str, Any], kwargs.get("schedule", {"kind": "every"}))
|
||||||
|
)
|
||||||
|
kwargs["payload"] = CronPayload(**cast(dict[str, Any], kwargs.get("payload", {})))
|
||||||
kwargs["state"] = CronJobState(**state_kwargs)
|
kwargs["state"] = CronJobState(**state_kwargs)
|
||||||
return cls(**kwargs)
|
return cls(**cast(Any, kwargs))
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def from_store_dict(cls, data: dict[str, Any]) -> CronJob:
|
def from_store_dict(cls, data: dict[str, Any]) -> CronJob:
|
||||||
|
|||||||
@ -69,7 +69,7 @@ class GatewayRuntimePaths(ProcessRuntimePaths):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
class GatewayRuntime(ManagedProcessRuntime):
|
class GatewayRuntime(ManagedProcessRuntime[ProcessStartOptions]):
|
||||||
"""Manage a background ``nanobot gateway`` process."""
|
"""Manage a background ``nanobot gateway`` process."""
|
||||||
|
|
||||||
service_name = "gateway"
|
service_name = "gateway"
|
||||||
|
|||||||
@ -7,7 +7,7 @@ import sys
|
|||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from importlib.metadata import PackageNotFoundError, distribution
|
from importlib.metadata import PackageNotFoundError, distribution
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any
|
from typing import Any, cast
|
||||||
|
|
||||||
from loguru import logger
|
from loguru import logger
|
||||||
from packaging.requirements import Requirement
|
from packaging.requirements import Requirement
|
||||||
@ -87,8 +87,8 @@ def optional_dependency_groups() -> dict[str, list[str] | None]:
|
|||||||
deps = project.get("optional-dependencies", {})
|
deps = project.get("optional-dependencies", {})
|
||||||
if isinstance(deps, dict) and deps:
|
if isinstance(deps, dict) and deps:
|
||||||
return {
|
return {
|
||||||
name: list(values)
|
name: list(cast(list[str], values))
|
||||||
for name, values in deps.items()
|
for name, values in cast(dict[str, object], deps).items()
|
||||||
if name != "dev" and name not in _HIDDEN_OPTIONAL_FEATURES and isinstance(values, list)
|
if name != "dev" and name not in _HIDDEN_OPTIONAL_FEATURES and isinstance(values, list)
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
@ -153,13 +153,13 @@ def _extra_dependencies_installed(
|
|||||||
normalized = canonicalize_name(requested_extra)
|
normalized = canonicalize_name(requested_extra)
|
||||||
provided = {
|
provided = {
|
||||||
canonicalize_name(value)
|
canonicalize_name(value)
|
||||||
for value in (dist.metadata.get_all("Provides-Extra") or [])
|
for value in cast(list[str], dist.metadata.get_all("Provides-Extra") or [])
|
||||||
}
|
}
|
||||||
if provided and normalized not in provided:
|
if provided and normalized not in provided:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
matched = False
|
matched = False
|
||||||
for raw in dist.requires or []:
|
for raw in cast(list[str], dist.requires or []):
|
||||||
req = Requirement(raw)
|
req = Requirement(raw)
|
||||||
if req.marker and not req.marker.evaluate({"extra": requested_extra}):
|
if req.marker and not req.marker.evaluate({"extra": requested_extra}):
|
||||||
continue
|
continue
|
||||||
@ -259,7 +259,7 @@ def read_config_data(path: Path) -> dict[str, Any]:
|
|||||||
if not path.exists():
|
if not path.exists():
|
||||||
return {}
|
return {}
|
||||||
with open(path, encoding="utf-8") as f:
|
with open(path, encoding="utf-8") as f:
|
||||||
return json.load(f)
|
return cast(dict[str, Any], json.load(f))
|
||||||
|
|
||||||
|
|
||||||
def write_config_data(path: Path, data: dict[str, Any]) -> None:
|
def write_config_data(path: Path, data: dict[str, Any]) -> None:
|
||||||
@ -312,7 +312,7 @@ def channel_enabled(
|
|||||||
if default_enabled is None:
|
if default_enabled is None:
|
||||||
default_enabled = plugin.default_enabled if plugin is not None else channel_default_enabled(name)
|
default_enabled = plugin.default_enabled if plugin is not None else channel_default_enabled(name)
|
||||||
if section is None:
|
if section is None:
|
||||||
return default_enabled
|
return bool(default_enabled)
|
||||||
if plugin is None:
|
if plugin is None:
|
||||||
from nanobot.channels.registry import load_channel_plugin
|
from nanobot.channels.registry import load_channel_plugin
|
||||||
|
|
||||||
@ -421,7 +421,7 @@ def optional_features_payload(
|
|||||||
dependencies = _feature_dependencies(name, channel_plugin, extras)
|
dependencies = _feature_dependencies(name, channel_plugin, extras)
|
||||||
has_dependencies = bool(dependencies)
|
has_dependencies = bool(dependencies)
|
||||||
installed = extra_installed(name, dependencies) if has_dependencies else True
|
installed = extra_installed(name, dependencies) if has_dependencies else True
|
||||||
feature = {
|
feature: dict[str, Any] = {
|
||||||
"name": name,
|
"name": name,
|
||||||
"display_name": (
|
"display_name": (
|
||||||
channel_plugin.display_name
|
channel_plugin.display_name
|
||||||
@ -502,7 +502,7 @@ def optional_features_payload(
|
|||||||
})
|
})
|
||||||
features.append(feature)
|
features.append(feature)
|
||||||
|
|
||||||
payload = {
|
payload: dict[str, Any] = {
|
||||||
"features": features,
|
"features": features,
|
||||||
"enabled_count": sum(1 for feature in features if feature["enabled"]),
|
"enabled_count": sum(1 for feature in features if feature["enabled"]),
|
||||||
}
|
}
|
||||||
@ -520,13 +520,16 @@ def with_channel_runtime_status(
|
|||||||
for status in runtime_status.values():
|
for status in runtime_status.values():
|
||||||
if not isinstance(status, dict):
|
if not isinstance(status, dict):
|
||||||
continue
|
continue
|
||||||
owner = status.get("owner")
|
status_object = cast(dict[str, Any], status)
|
||||||
|
owner = status_object.get("owner")
|
||||||
if isinstance(owner, str):
|
if isinstance(owner, str):
|
||||||
statuses_by_owner.setdefault(owner, []).append(status)
|
statuses_by_owner.setdefault(owner, []).append(status_object)
|
||||||
|
|
||||||
features: list[dict[str, Any]] = []
|
features: list[dict[str, Any]] = []
|
||||||
for original in payload.get("features", []):
|
for raw_feature in cast(list[object], payload.get("features", [])):
|
||||||
feature = dict(original)
|
if not isinstance(raw_feature, dict):
|
||||||
|
continue
|
||||||
|
feature = cast(dict[str, Any], raw_feature).copy()
|
||||||
if feature.get("type") != "channel":
|
if feature.get("type") != "channel":
|
||||||
features.append(feature)
|
features.append(feature)
|
||||||
continue
|
continue
|
||||||
@ -546,9 +549,11 @@ def with_channel_runtime_status(
|
|||||||
str(status.get("instance_id", "default")): status
|
str(status.get("instance_id", "default")): status
|
||||||
for status in owner_statuses
|
for status in owner_statuses
|
||||||
}
|
}
|
||||||
decorated_instances = []
|
decorated_instances: list[dict[str, Any]] = []
|
||||||
for original_instance in instances:
|
for original_instance in cast(list[object], instances):
|
||||||
instance = dict(original_instance)
|
if not isinstance(original_instance, dict):
|
||||||
|
continue
|
||||||
|
instance = cast(dict[str, Any], original_instance).copy()
|
||||||
desired_instance = bool(instance.get("enabled"))
|
desired_instance = bool(instance.get("enabled"))
|
||||||
status = by_instance.get(str(instance.get("id", "default")))
|
status = by_instance.get(str(instance.get("id", "default")))
|
||||||
if desired_instance and status is None:
|
if desired_instance and status is None:
|
||||||
|
|||||||
@ -13,12 +13,12 @@ import string
|
|||||||
import threading
|
import threading
|
||||||
import time
|
import time
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any
|
from typing import Any, cast
|
||||||
|
|
||||||
from loguru import logger
|
from loguru import logger
|
||||||
|
|
||||||
from nanobot.config.paths import get_data_dir
|
from nanobot.config.paths import get_data_dir
|
||||||
from nanobot.utils.helpers import _write_text_atomic
|
from nanobot.utils.helpers import _write_text_atomic # pyright: ignore[reportPrivateUsage]
|
||||||
|
|
||||||
# threading.Lock is used so store functions remain callable from both sync CLI
|
# threading.Lock is used so store functions remain callable from both sync CLI
|
||||||
# and async channel handlers. At private-assistant scale (small JSON file,
|
# and async channel handlers. At private-assistant scale (small JSON file,
|
||||||
@ -47,21 +47,20 @@ def _load() -> dict[str, Any]:
|
|||||||
logger.warning("Corrupted pairing store, resetting")
|
logger.warning("Corrupted pairing store, resetting")
|
||||||
return {"approved": {}, "pending": {}}
|
return {"approved": {}, "pending": {}}
|
||||||
|
|
||||||
# JSON stores may contain null maps after partial edits; treat like {}.
|
# JSON stores may contain null or malformed maps after partial edits; treat like {}.
|
||||||
approved = data.get("approved") or {}
|
data = cast(dict[str, Any], data)
|
||||||
if not isinstance(approved, dict):
|
raw_approved = data.get("approved")
|
||||||
approved = {}
|
approved = cast(dict[str, Any], raw_approved) if isinstance(raw_approved, dict) else {}
|
||||||
data["approved"] = approved
|
data["approved"] = approved
|
||||||
pending = data.get("pending") or {}
|
raw_pending = data.get("pending")
|
||||||
if not isinstance(pending, dict):
|
pending = cast(dict[str, Any], raw_pending) if isinstance(raw_pending, dict) else {}
|
||||||
pending = {}
|
|
||||||
data["pending"] = pending
|
data["pending"] = pending
|
||||||
|
|
||||||
# Convert approved lists to str sets for O(1) lookup.
|
# Convert approved lists to str sets for O(1) lookup.
|
||||||
for channel, users in approved.items():
|
for channel, users in approved.items():
|
||||||
if not isinstance(users, list):
|
if not isinstance(users, list):
|
||||||
users = []
|
users = []
|
||||||
data["approved"][channel] = {str(u) for u in users}
|
data["approved"][channel] = {str(user) for user in cast(list[object], users)}
|
||||||
return data
|
return data
|
||||||
|
|
||||||
|
|
||||||
@ -69,14 +68,12 @@ def _save(data: dict[str, Any]) -> None:
|
|||||||
path = _store_path()
|
path = _store_path()
|
||||||
path.parent.mkdir(parents=True, exist_ok=True)
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
# Convert sets back to lists for JSON serialization
|
# Convert sets back to lists for JSON serialization
|
||||||
approved = data.get("approved") or {}
|
raw_approved = data.get("approved")
|
||||||
pending = data.get("pending") or {}
|
approved = cast(dict[str, Any], raw_approved) if isinstance(raw_approved, dict) else {}
|
||||||
if not isinstance(approved, dict):
|
raw_pending = data.get("pending")
|
||||||
approved = {}
|
pending = cast(dict[str, Any], raw_pending) if isinstance(raw_pending, dict) else {}
|
||||||
if not isinstance(pending, dict):
|
payload: dict[str, Any] = {
|
||||||
pending = {}
|
"approved": {ch: sorted(list(cast(set[str], users))) for ch, users in approved.items()},
|
||||||
payload = {
|
|
||||||
"approved": {ch: sorted(list(users)) for ch, users in approved.items()},
|
|
||||||
"pending": dict(pending),
|
"pending": dict(pending),
|
||||||
}
|
}
|
||||||
_write_text_atomic(path, json.dumps(payload, indent=2, ensure_ascii=False))
|
_write_text_atomic(path, json.dumps(payload, indent=2, ensure_ascii=False))
|
||||||
@ -86,22 +83,22 @@ def _gc_pending(data: dict[str, Any]) -> None:
|
|||||||
"""Remove expired pending entries in-place."""
|
"""Remove expired pending entries in-place."""
|
||||||
now = time.time()
|
now = time.time()
|
||||||
pending: dict[str, Any] = data.get("pending") or {}
|
pending: dict[str, Any] = data.get("pending") or {}
|
||||||
if not isinstance(pending, dict):
|
expired: list[str] = []
|
||||||
data["pending"] = {}
|
for code, info in pending.items():
|
||||||
return
|
if not isinstance(info, dict):
|
||||||
expired = [
|
expired.append(code)
|
||||||
code
|
continue
|
||||||
for code, info in pending.items()
|
entry = cast(dict[str, Any], info)
|
||||||
|
expires_at = entry.get("expires_at")
|
||||||
if (
|
if (
|
||||||
not isinstance(info, dict)
|
not isinstance(entry.get("channel"), str)
|
||||||
or not isinstance(info.get("channel"), str)
|
or not entry["channel"]
|
||||||
or not info.get("channel")
|
or entry.get("sender_id") is None
|
||||||
or info.get("sender_id") is None
|
or isinstance(expires_at, bool)
|
||||||
or isinstance(info.get("expires_at"), bool)
|
or not isinstance(expires_at, (int, float))
|
||||||
or not isinstance(info.get("expires_at"), (int, float))
|
or expires_at < now
|
||||||
or info["expires_at"] < now
|
):
|
||||||
)
|
expired.append(code)
|
||||||
]
|
|
||||||
for code in expired:
|
for code in expired:
|
||||||
del pending[code]
|
del pending[code]
|
||||||
data["pending"] = pending
|
data["pending"] = pending
|
||||||
@ -322,13 +319,13 @@ def handle_pairing_command(channel: str, subcommand_text: str) -> str:
|
|||||||
if len(parts) == 2:
|
if len(parts) == 2:
|
||||||
return (
|
return (
|
||||||
f"Revoked {arg} from {channel}"
|
f"Revoked {arg} from {channel}"
|
||||||
if revoke(channel, arg)
|
if revoke(channel, parts[1])
|
||||||
else f"{arg} was not in the approved list for {channel}"
|
else f"{arg} was not in the approved list for {channel}"
|
||||||
)
|
)
|
||||||
if len(parts) == 3:
|
if len(parts) == 3:
|
||||||
return (
|
return (
|
||||||
f"Revoked {parts[2]} from {arg}"
|
f"Revoked {parts[2]} from {arg}"
|
||||||
if revoke(arg, parts[2])
|
if revoke(parts[1], parts[2])
|
||||||
else f"{parts[2]} was not in the approved list for {arg}"
|
else f"{parts[2]} was not in the approved list for {arg}"
|
||||||
)
|
)
|
||||||
return "Usage: `/pairing revoke <user_id>` or `/pairing revoke <channel> <user_id>`"
|
return "Usage: `/pairing revoke <user_id>` or `/pairing revoke <channel> <user_id>`"
|
||||||
|
|||||||
@ -15,7 +15,7 @@ from contextlib import suppress
|
|||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from datetime import UTC, datetime
|
from datetime import UTC, datetime
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any
|
from typing import Any, Generic, TypeVar, cast
|
||||||
|
|
||||||
from filelock import FileLock
|
from filelock import FileLock
|
||||||
|
|
||||||
@ -63,7 +63,10 @@ class ProcessRuntimePaths:
|
|||||||
log_path: Path
|
log_path: Path
|
||||||
|
|
||||||
|
|
||||||
class ManagedProcessRuntime:
|
_StartOptionsT = TypeVar("_StartOptionsT", bound=ProcessStartOptions)
|
||||||
|
|
||||||
|
|
||||||
|
class ManagedProcessRuntime(Generic[_StartOptionsT]):
|
||||||
"""Manage a detached child process without service-specific policy."""
|
"""Manage a detached child process without service-specific policy."""
|
||||||
|
|
||||||
service_name = "process"
|
service_name = "process"
|
||||||
@ -100,12 +103,12 @@ class ManagedProcessRuntime:
|
|||||||
state["started_at"] = _utc_now()
|
state["started_at"] = _utc_now()
|
||||||
runtime._write_state(state)
|
runtime._write_state(state)
|
||||||
|
|
||||||
def start_background(self, options: ProcessStartOptions) -> ProcessResult:
|
def start_background(self, options: _StartOptionsT) -> ProcessResult:
|
||||||
"""Start the configured command as a detached process."""
|
"""Start the configured command as a detached process."""
|
||||||
with self._lifecycle_lock():
|
with self._lifecycle_lock():
|
||||||
return self._start_background(options)
|
return self._start_background(options)
|
||||||
|
|
||||||
def _start_background(self, options: ProcessStartOptions) -> ProcessResult:
|
def _start_background(self, options: _StartOptionsT) -> ProcessResult:
|
||||||
current = self.status()
|
current = self.status()
|
||||||
if current.running:
|
if current.running:
|
||||||
return ProcessResult(False, self._message("already_running"), current)
|
return ProcessResult(False, self._message("already_running"), current)
|
||||||
@ -174,7 +177,7 @@ class ManagedProcessRuntime:
|
|||||||
self._clear_state()
|
self._clear_state()
|
||||||
return ProcessResult(True, self._message("stopped"), self.status(reason="stopped"))
|
return ProcessResult(True, self._message("stopped"), self.status(reason="stopped"))
|
||||||
|
|
||||||
def restart(self, options: ProcessStartOptions, *, timeout_s: int = 20) -> ProcessResult:
|
def restart(self, options: _StartOptionsT, *, timeout_s: int = 20) -> ProcessResult:
|
||||||
"""Restart the managed process."""
|
"""Restart the managed process."""
|
||||||
with self._lifecycle_lock():
|
with self._lifecycle_lock():
|
||||||
stop_result = self._stop(timeout_s=timeout_s)
|
stop_result = self._stop(timeout_s=timeout_s)
|
||||||
@ -195,6 +198,7 @@ class ManagedProcessRuntime:
|
|||||||
log_path=self.paths.log_path,
|
log_path=self.paths.log_path,
|
||||||
reason=reason or "not_started",
|
reason=reason or "not_started",
|
||||||
)
|
)
|
||||||
|
assert state is not None
|
||||||
|
|
||||||
if not self._is_pid_running(pid) or not self._record_matches_process(state, pid):
|
if not self._is_pid_running(pid) or not self._record_matches_process(state, pid):
|
||||||
self._clear_state()
|
self._clear_state()
|
||||||
@ -214,7 +218,7 @@ class ManagedProcessRuntime:
|
|||||||
log_path=self.paths.log_path,
|
log_path=self.paths.log_path,
|
||||||
started_at=_as_str(state.get("started_at")),
|
started_at=_as_str(state.get("started_at")),
|
||||||
port=_as_int(state.get("port")),
|
port=_as_int(state.get("port")),
|
||||||
command=tuple(command) if isinstance(command, list) else (),
|
command=tuple(cast(list[str], command)) if isinstance(command, list) else (),
|
||||||
reason=reason or "running",
|
reason=reason or "running",
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -253,7 +257,7 @@ class ManagedProcessRuntime:
|
|||||||
lock_path = self.paths.state_path.with_name(f"{self.paths.state_path.name}.lock")
|
lock_path = self.paths.state_path.with_name(f"{self.paths.state_path.name}.lock")
|
||||||
return FileLock(str(lock_path))
|
return FileLock(str(lock_path))
|
||||||
|
|
||||||
def _build_child_command(self, options: ProcessStartOptions) -> list[str]:
|
def _build_child_command(self, options: _StartOptionsT) -> list[str]:
|
||||||
raise NotImplementedError
|
raise NotImplementedError
|
||||||
|
|
||||||
def _popen_platform_kwargs(self) -> dict[str, Any]:
|
def _popen_platform_kwargs(self) -> dict[str, Any]:
|
||||||
@ -365,7 +369,7 @@ class ManagedProcessRuntime:
|
|||||||
payload = json.load(handle)
|
payload = json.load(handle)
|
||||||
except (OSError, json.JSONDecodeError, ValueError):
|
except (OSError, json.JSONDecodeError, ValueError):
|
||||||
return None
|
return None
|
||||||
return payload if isinstance(payload, dict) else None
|
return cast(dict[str, Any], payload) if isinstance(payload, dict) else None
|
||||||
|
|
||||||
def _write_state(self, payload: dict[str, Any]) -> None:
|
def _write_state(self, payload: dict[str, Any]) -> None:
|
||||||
self.paths.run_dir.mkdir(parents=True, exist_ok=True)
|
self.paths.run_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|||||||
@ -9,8 +9,8 @@ import re
|
|||||||
import secrets
|
import secrets
|
||||||
import string
|
import string
|
||||||
from collections import deque
|
from collections import deque
|
||||||
from collections.abc import Awaitable, Callable
|
from collections.abc import Awaitable, Callable, Iterable
|
||||||
from typing import Any
|
from typing import Any, cast
|
||||||
|
|
||||||
from loguru import logger
|
from loguru import logger
|
||||||
|
|
||||||
@ -198,7 +198,13 @@ class AnthropicProvider(LLMProvider):
|
|||||||
content = msg.get("content")
|
content = msg.get("content")
|
||||||
|
|
||||||
if role == "system":
|
if role == "system":
|
||||||
system = content if isinstance(content, (str, list)) else str(content or "")
|
system = (
|
||||||
|
cast(list[dict[str, Any]], content)
|
||||||
|
if isinstance(content, list)
|
||||||
|
else content
|
||||||
|
if isinstance(content, str)
|
||||||
|
else str(content or "")
|
||||||
|
)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
if role == "tool":
|
if role == "tool":
|
||||||
@ -206,7 +212,7 @@ class AnthropicProvider(LLMProvider):
|
|||||||
if raw and raw[-1]["role"] == "user":
|
if raw and raw[-1]["role"] == "user":
|
||||||
prev_c = raw[-1]["content"]
|
prev_c = raw[-1]["content"]
|
||||||
if isinstance(prev_c, list):
|
if isinstance(prev_c, list):
|
||||||
prev_c.append(block)
|
cast(list[Any], prev_c).append(block)
|
||||||
else:
|
else:
|
||||||
raw[-1]["content"] = [
|
raw[-1]["content"] = [
|
||||||
{"type": "text", "text": prev_c or ""}, block,
|
{"type": "text", "text": prev_c or ""}, block,
|
||||||
@ -264,41 +270,49 @@ class AnthropicProvider(LLMProvider):
|
|||||||
blocks: list[dict[str, Any]] = []
|
blocks: list[dict[str, Any]] = []
|
||||||
content = msg.get("content")
|
content = msg.get("content")
|
||||||
|
|
||||||
for tb in msg.get("thinking_blocks") or []:
|
for tb in cast(Iterable[object], msg.get("thinking_blocks") or []):
|
||||||
if isinstance(tb, dict) and tb.get("type") == "thinking":
|
if isinstance(tb, dict):
|
||||||
blocks.append({
|
thinking_block = cast(dict[str, Any], tb)
|
||||||
"type": "thinking",
|
if thinking_block.get("type") == "thinking":
|
||||||
"thinking": tb.get("thinking", ""),
|
blocks.append({
|
||||||
"signature": tb.get("signature", ""),
|
"type": "thinking",
|
||||||
})
|
"thinking": thinking_block.get("thinking", ""),
|
||||||
|
"signature": thinking_block.get("signature", ""),
|
||||||
|
})
|
||||||
|
|
||||||
if isinstance(content, str) and content:
|
if isinstance(content, str) and content:
|
||||||
blocks.append({"type": "text", "text": content})
|
blocks.append({"type": "text", "text": content})
|
||||||
elif isinstance(content, list):
|
elif isinstance(content, list):
|
||||||
for item in content:
|
for item in cast(list[object], content):
|
||||||
if isinstance(item, dict):
|
if isinstance(item, dict):
|
||||||
if not item.get("type"):
|
content_block = cast(dict[str, Any], item)
|
||||||
|
if not content_block.get("type"):
|
||||||
# Anthropic requires every content block to declare a "type".
|
# Anthropic requires every content block to declare a "type".
|
||||||
# A tool that returned a bare dict lands here; coerce it to
|
# A tool that returned a bare dict lands here; coerce it to
|
||||||
# a text block instead of emitting one that the API rejects.
|
# a text block instead of emitting one that the API rejects.
|
||||||
blocks.append({
|
blocks.append({
|
||||||
"type": "text",
|
"type": "text",
|
||||||
"text": AnthropicProvider._stringify_typeless_block(item),
|
"text": AnthropicProvider._stringify_typeless_block(content_block),
|
||||||
})
|
})
|
||||||
else:
|
else:
|
||||||
blocks.append(item)
|
blocks.append(content_block)
|
||||||
else:
|
else:
|
||||||
blocks.append({"type": "text", "text": str(item)})
|
blocks.append({"type": "text", "text": str(item)})
|
||||||
|
|
||||||
for tc in msg.get("tool_calls") or []:
|
for tc in cast(Iterable[object], msg.get("tool_calls") or []):
|
||||||
if not isinstance(tc, dict):
|
if not isinstance(tc, dict):
|
||||||
continue
|
continue
|
||||||
func = tc.get("function", {})
|
tool_call = cast(dict[str, Any], tc)
|
||||||
|
func = cast(dict[str, Any], tool_call.get("function", {}))
|
||||||
args = func.get("arguments", "{}")
|
args = func.get("arguments", "{}")
|
||||||
raw_id = tc.get("id") or _gen_tool_id()
|
raw_id = tool_call.get("id") or _gen_tool_id()
|
||||||
blocks.append({
|
blocks.append({
|
||||||
"type": "tool_use",
|
"type": "tool_use",
|
||||||
"id": map_tool_id(raw_id) if map_tool_id is not None else _sanitize_tool_id(raw_id),
|
"id": (
|
||||||
|
map_tool_id(raw_id)
|
||||||
|
if map_tool_id is not None
|
||||||
|
else _sanitize_tool_id(cast(str, raw_id))
|
||||||
|
),
|
||||||
"name": func.get("name", ""),
|
"name": func.get("name", ""),
|
||||||
"input": tool_arguments_object_for_replay(args),
|
"input": tool_arguments_object_for_replay(args),
|
||||||
})
|
})
|
||||||
@ -314,26 +328,27 @@ class AnthropicProvider(LLMProvider):
|
|||||||
return str(content)
|
return str(content)
|
||||||
|
|
||||||
result: list[dict[str, Any]] = []
|
result: list[dict[str, Any]] = []
|
||||||
for item in content:
|
for item in cast(list[object], content):
|
||||||
if not isinstance(item, dict):
|
if not isinstance(item, dict):
|
||||||
result.append({"type": "text", "text": str(item)})
|
result.append({"type": "text", "text": str(item)})
|
||||||
continue
|
continue
|
||||||
if item.get("type") == "image_url":
|
content_block = cast(dict[str, Any], item)
|
||||||
converted = AnthropicProvider._convert_image_block(item)
|
if content_block.get("type") == "image_url":
|
||||||
|
converted = AnthropicProvider._convert_image_block(content_block)
|
||||||
if converted:
|
if converted:
|
||||||
result.append(converted)
|
result.append(converted)
|
||||||
continue
|
continue
|
||||||
if not item.get("type"):
|
if not content_block.get("type"):
|
||||||
# Anthropic requires every content block to declare a "type".
|
# Anthropic requires every content block to declare a "type".
|
||||||
# A tool that returned a bare dict (or a list of dicts) lands
|
# A tool that returned a bare dict (or a list of dicts) lands
|
||||||
# here; coerce it to a text block instead of emitting a block
|
# here; coerce it to a text block instead of emitting a block
|
||||||
# the API rejects with "content.0.type: Field required".
|
# the API rejects with "content.0.type: Field required".
|
||||||
result.append({
|
result.append({
|
||||||
"type": "text",
|
"type": "text",
|
||||||
"text": AnthropicProvider._stringify_typeless_block(item),
|
"text": AnthropicProvider._stringify_typeless_block(content_block),
|
||||||
})
|
})
|
||||||
continue
|
continue
|
||||||
result.append(item)
|
result.append(content_block)
|
||||||
return result or "(empty)"
|
return result or "(empty)"
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
@ -343,7 +358,8 @@ class AnthropicProvider(LLMProvider):
|
|||||||
@staticmethod
|
@staticmethod
|
||||||
def _convert_image_block(block: dict[str, Any]) -> dict[str, Any] | None:
|
def _convert_image_block(block: dict[str, Any]) -> dict[str, Any] | None:
|
||||||
"""Convert OpenAI image_url block to Anthropic image block."""
|
"""Convert OpenAI image_url block to Anthropic image block."""
|
||||||
url = (block.get("image_url") or {}).get("url", "")
|
image_url = cast(dict[str, Any], block.get("image_url") or {})
|
||||||
|
url = cast(str, image_url.get("url", ""))
|
||||||
if not url:
|
if not url:
|
||||||
return None
|
return None
|
||||||
m = re.match(r"data:(image/\w+);base64,(.+)", url, re.DOTALL)
|
m = re.match(r"data:(image/\w+);base64,(.+)", url, re.DOTALL)
|
||||||
@ -367,10 +383,13 @@ class AnthropicProvider(LLMProvider):
|
|||||||
content = msg.get("content")
|
content = msg.get("content")
|
||||||
if not isinstance(content, list):
|
if not isinstance(content, list):
|
||||||
return False
|
return False
|
||||||
return any(
|
for block in cast(list[object], content):
|
||||||
isinstance(block, dict) and block.get("type") == "tool_use"
|
if (
|
||||||
for block in content
|
isinstance(block, dict)
|
||||||
)
|
and cast(dict[str, Any], block).get("type") == "tool_use"
|
||||||
|
):
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _merge_consecutive(msgs: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
def _merge_consecutive(msgs: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||||
@ -402,7 +421,7 @@ class AnthropicProvider(LLMProvider):
|
|||||||
if isinstance(cur_c, str):
|
if isinstance(cur_c, str):
|
||||||
cur_c = [{"type": "text", "text": cur_c}]
|
cur_c = [{"type": "text", "text": cur_c}]
|
||||||
if isinstance(cur_c, list):
|
if isinstance(cur_c, list):
|
||||||
prev_c.extend(cur_c)
|
cast(list[Any], prev_c).extend(cast(list[Any], cur_c))
|
||||||
merged[-1]["content"] = prev_c
|
merged[-1]["content"] = prev_c
|
||||||
else:
|
else:
|
||||||
merged.append(msg)
|
merged.append(msg)
|
||||||
@ -446,7 +465,7 @@ class AnthropicProvider(LLMProvider):
|
|||||||
def _convert_tools(tools: list[dict[str, Any]] | None) -> list[dict[str, Any]] | None:
|
def _convert_tools(tools: list[dict[str, Any]] | None) -> list[dict[str, Any]] | None:
|
||||||
if not tools:
|
if not tools:
|
||||||
return None
|
return None
|
||||||
result = []
|
result: list[dict[str, Any]] = []
|
||||||
for tool in tools:
|
for tool in tools:
|
||||||
func = tool.get("function", tool)
|
func = tool.get("function", tool)
|
||||||
entry: dict[str, Any] = {
|
entry: dict[str, Any] = {
|
||||||
@ -506,7 +525,7 @@ class AnthropicProvider(LLMProvider):
|
|||||||
if isinstance(c, str):
|
if isinstance(c, str):
|
||||||
new_msgs[-2] = {**m, "content": [{"type": "text", "text": c, "cache_control": marker}]}
|
new_msgs[-2] = {**m, "content": [{"type": "text", "text": c, "cache_control": marker}]}
|
||||||
elif isinstance(c, list) and c:
|
elif isinstance(c, list) and c:
|
||||||
nc = list(c)
|
nc = list(cast(list[dict[str, Any]], c))
|
||||||
nc[-1] = {**nc[-1], "cache_control": marker}
|
nc[-1] = {**nc[-1], "cache_control": marker}
|
||||||
new_msgs[-2] = {**m, "content": nc}
|
new_msgs[-2] = {**m, "content": nc}
|
||||||
|
|
||||||
@ -570,7 +589,7 @@ class AnthropicProvider(LLMProvider):
|
|||||||
kwargs["temperature"] = 1.0
|
kwargs["temperature"] = 1.0
|
||||||
elif thinking_enabled:
|
elif thinking_enabled:
|
||||||
budget_map = {"low": 1024, "medium": 4096, "high": max(8192, max_tokens)}
|
budget_map = {"low": 1024, "medium": 4096, "high": max(8192, max_tokens)}
|
||||||
budget = budget_map.get(reasoning_effort.lower(), 4096)
|
budget = budget_map.get(cast(str, reasoning_effort).lower(), 4096)
|
||||||
kwargs["thinking"] = {"type": "enabled", "budget_tokens": budget}
|
kwargs["thinking"] = {"type": "enabled", "budget_tokens": budget}
|
||||||
kwargs["max_tokens"] = max(max_tokens, budget + 4096)
|
kwargs["max_tokens"] = max(max_tokens, budget + 4096)
|
||||||
if not omit_temperature:
|
if not omit_temperature:
|
||||||
@ -683,7 +702,7 @@ class AnthropicProvider(LLMProvider):
|
|||||||
reasoning_effort, tool_choice,
|
reasoning_effort, tool_choice,
|
||||||
)
|
)
|
||||||
try:
|
try:
|
||||||
response = await self._client.messages.create(**kwargs)
|
response = cast(Any, await self._client.messages.create(**kwargs))
|
||||||
return self._parse_response(response)
|
return self._parse_response(response)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
if self._is_streaming_required_error(e):
|
if self._is_streaming_required_error(e):
|
||||||
|
|||||||
@ -21,7 +21,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import uuid
|
import uuid
|
||||||
from collections.abc import Awaitable, Callable
|
from collections.abc import Awaitable, Callable
|
||||||
from typing import Any
|
from typing import Any, cast
|
||||||
|
|
||||||
from openai import AsyncOpenAI
|
from openai import AsyncOpenAI
|
||||||
|
|
||||||
@ -208,7 +208,7 @@ class AzureOpenAIProvider(LLMProvider):
|
|||||||
reasoning_effort, tool_choice,
|
reasoning_effort, tool_choice,
|
||||||
)
|
)
|
||||||
try:
|
try:
|
||||||
response = await self._client.responses.create(**body)
|
response = cast(Any, await self._client.responses.create(**body))
|
||||||
return parse_response_output(response)
|
return parse_response_output(response)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return self._handle_error(e)
|
return self._handle_error(e)
|
||||||
@ -234,7 +234,7 @@ class AzureOpenAIProvider(LLMProvider):
|
|||||||
body["stream"] = True
|
body["stream"] = True
|
||||||
|
|
||||||
try:
|
try:
|
||||||
stream = await self._client.responses.create(**body)
|
stream = cast(Any, await self._client.responses.create(**body))
|
||||||
content, tool_calls, finish_reason, usage, reasoning_content = (
|
content, tool_calls, finish_reason, usage, reasoning_content = (
|
||||||
await consume_sdk_stream(stream, on_content_delta, on_tool_call_delta)
|
await consume_sdk_stream(stream, on_content_delta, on_tool_call_delta)
|
||||||
)
|
)
|
||||||
|
|||||||
@ -10,7 +10,7 @@ from contextlib import suppress
|
|||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from email.utils import parsedate_to_datetime
|
from email.utils import parsedate_to_datetime
|
||||||
from typing import Any
|
from typing import Any, cast
|
||||||
|
|
||||||
import json_repair
|
import json_repair
|
||||||
from loguru import logger
|
from loguru import logger
|
||||||
@ -67,7 +67,8 @@ class ToolCallRequest:
|
|||||||
``messages.content.N.tool_use.name: Input should be a valid string``),
|
``messages.content.N.tool_use.name: Input should be a valid string``),
|
||||||
which permanently wedges the session.
|
which permanently wedges the session.
|
||||||
"""
|
"""
|
||||||
return isinstance(self.name, str) and bool(self.name)
|
runtime_name = cast(object, self.name)
|
||||||
|
return isinstance(runtime_name, str) and bool(runtime_name)
|
||||||
|
|
||||||
def to_openai_tool_call(self) -> dict[str, Any]:
|
def to_openai_tool_call(self) -> dict[str, Any]:
|
||||||
"""Serialize to an OpenAI-style tool_call payload."""
|
"""Serialize to an OpenAI-style tool_call payload."""
|
||||||
@ -76,7 +77,7 @@ class ToolCallRequest:
|
|||||||
if isinstance(self.arguments, str)
|
if isinstance(self.arguments, str)
|
||||||
else json.dumps(self.arguments, ensure_ascii=False)
|
else json.dumps(self.arguments, ensure_ascii=False)
|
||||||
)
|
)
|
||||||
tool_call = {
|
tool_call: dict[str, Any] = {
|
||||||
"id": self.id,
|
"id": self.id,
|
||||||
"type": "function",
|
"type": "function",
|
||||||
"function": {
|
"function": {
|
||||||
@ -126,7 +127,7 @@ def tool_arguments_object_for_replay(arguments: Any) -> dict[str, Any]:
|
|||||||
if arguments is None:
|
if arguments is None:
|
||||||
return {}
|
return {}
|
||||||
if isinstance(arguments, dict):
|
if isinstance(arguments, dict):
|
||||||
return arguments
|
return cast(dict[str, Any], arguments)
|
||||||
if not isinstance(arguments, str):
|
if not isinstance(arguments, str):
|
||||||
return {}
|
return {}
|
||||||
|
|
||||||
@ -141,7 +142,7 @@ def tool_arguments_object_for_replay(arguments: Any) -> dict[str, Any]:
|
|||||||
parsed = json_repair.loads(stripped)
|
parsed = json_repair.loads(stripped)
|
||||||
except Exception:
|
except Exception:
|
||||||
return {}
|
return {}
|
||||||
return parsed if isinstance(parsed, dict) else {}
|
return cast(dict[str, Any], parsed) if isinstance(parsed, dict) else {}
|
||||||
|
|
||||||
|
|
||||||
def tool_arguments_json_for_replay(arguments: Any) -> str:
|
def tool_arguments_json_for_replay(arguments: Any) -> str:
|
||||||
@ -158,7 +159,7 @@ class LLMResponse:
|
|||||||
usage: dict[str, int] = field(default_factory=dict)
|
usage: dict[str, int] = field(default_factory=dict)
|
||||||
retry_after: float | None = None # Provider supplied retry wait in seconds.
|
retry_after: float | None = None # Provider supplied retry wait in seconds.
|
||||||
reasoning_content: str | None = None # Kimi, DeepSeek-R1, MiMo etc.
|
reasoning_content: str | None = None # Kimi, DeepSeek-R1, MiMo etc.
|
||||||
thinking_blocks: list[dict] | None = None # Anthropic extended thinking
|
thinking_blocks: list[dict[str, Any]] | None = None # Anthropic extended thinking
|
||||||
# Structured error metadata used by retry policy when finish_reason == "error".
|
# Structured error metadata used by retry policy when finish_reason == "error".
|
||||||
error_status_code: int | None = None
|
error_status_code: int | None = None
|
||||||
error_kind: str | None = None # e.g. "timeout", "connection"
|
error_kind: str | None = None # e.g. "timeout", "connection"
|
||||||
@ -298,19 +299,20 @@ class LLMProvider(ABC):
|
|||||||
if isinstance(content, list):
|
if isinstance(content, list):
|
||||||
new_items: list[Any] = []
|
new_items: list[Any] = []
|
||||||
changed = False
|
changed = False
|
||||||
for item in content:
|
for raw_item in cast(list[object], content):
|
||||||
|
item = cast(dict[str, Any], raw_item) if isinstance(raw_item, dict) else None
|
||||||
if (
|
if (
|
||||||
isinstance(item, dict)
|
item is not None
|
||||||
and item.get("type") in ("text", "input_text", "output_text")
|
and item.get("type") in ("text", "input_text", "output_text")
|
||||||
and not item.get("text")
|
and not item.get("text")
|
||||||
):
|
):
|
||||||
changed = True
|
changed = True
|
||||||
continue
|
continue
|
||||||
if isinstance(item, dict) and "_meta" in item:
|
if item is not None and "_meta" in item:
|
||||||
new_items.append({k: v for k, v in item.items() if k != "_meta"})
|
new_items.append({k: v for k, v in item.items() if k != "_meta"})
|
||||||
changed = True
|
changed = True
|
||||||
else:
|
else:
|
||||||
new_items.append(item)
|
new_items.append(raw_item)
|
||||||
if changed:
|
if changed:
|
||||||
clean = dict(msg)
|
clean = dict(msg)
|
||||||
if new_items:
|
if new_items:
|
||||||
@ -332,7 +334,7 @@ class LLMProvider(ABC):
|
|||||||
# Defense-in-depth: scrub lone UTF-16 surrogates from every string leaf.
|
# Defense-in-depth: scrub lone UTF-16 surrogates from every string leaf.
|
||||||
# This is idempotent and no-op when messages are already clean.
|
# This is idempotent and no-op when messages are already clean.
|
||||||
sanitized = sanitize_surrogates_deep(result)
|
sanitized = sanitize_surrogates_deep(result)
|
||||||
return sanitized if isinstance(sanitized, list) else result
|
return cast(list[dict[str, Any]], sanitized) if isinstance(sanitized, list) else result
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _tool_name(tool: dict[str, Any]) -> str:
|
def _tool_name(tool: dict[str, Any]) -> str:
|
||||||
@ -341,8 +343,9 @@ class LLMProvider(ABC):
|
|||||||
if isinstance(name, str):
|
if isinstance(name, str):
|
||||||
return name
|
return name
|
||||||
fn = tool.get("function")
|
fn = tool.get("function")
|
||||||
if isinstance(fn, dict):
|
fn_object = cast(dict[str, Any], fn) if isinstance(fn, dict) else None
|
||||||
fname = fn.get("name")
|
if fn_object is not None:
|
||||||
|
fname = fn_object.get("name")
|
||||||
if isinstance(fname, str):
|
if isinstance(fname, str):
|
||||||
return fname
|
return fname
|
||||||
return ""
|
return ""
|
||||||
@ -372,7 +375,7 @@ class LLMProvider(ABC):
|
|||||||
allowed_keys: frozenset[str],
|
allowed_keys: frozenset[str],
|
||||||
) -> list[dict[str, Any]]:
|
) -> list[dict[str, Any]]:
|
||||||
"""Keep only provider-safe message keys and normalize assistant content."""
|
"""Keep only provider-safe message keys and normalize assistant content."""
|
||||||
sanitized = []
|
sanitized: list[dict[str, Any]] = []
|
||||||
for msg in messages:
|
for msg in messages:
|
||||||
clean = {k: v for k, v in msg.items() if k in allowed_keys}
|
clean = {k: v for k, v in msg.items() if k in allowed_keys}
|
||||||
if clean.get("role") == "assistant" and "content" not in clean:
|
if clean.get("role") == "assistant" and "content" not in clean:
|
||||||
@ -465,7 +468,7 @@ class LLMProvider(ABC):
|
|||||||
def _extract_error_type_code(cls, payload: Any) -> tuple[str | None, str | None]:
|
def _extract_error_type_code(cls, payload: Any) -> tuple[str | None, str | None]:
|
||||||
data: dict[str, Any] | None = None
|
data: dict[str, Any] | None = None
|
||||||
if isinstance(payload, dict):
|
if isinstance(payload, dict):
|
||||||
data = payload
|
data = cast(dict[str, Any], payload)
|
||||||
elif isinstance(payload, str):
|
elif isinstance(payload, str):
|
||||||
text = payload.strip()
|
text = payload.strip()
|
||||||
if text:
|
if text:
|
||||||
@ -474,16 +477,17 @@ class LLMProvider(ABC):
|
|||||||
except Exception:
|
except Exception:
|
||||||
parsed = None
|
parsed = None
|
||||||
if isinstance(parsed, dict):
|
if isinstance(parsed, dict):
|
||||||
data = parsed
|
data = cast(dict[str, Any], parsed)
|
||||||
if not isinstance(data, dict):
|
if data is None:
|
||||||
return None, None
|
return None, None
|
||||||
|
|
||||||
error_obj = data.get("error")
|
error_obj = data.get("error")
|
||||||
type_value = data.get("type")
|
type_value = data.get("type")
|
||||||
code_value = data.get("code")
|
code_value = data.get("code")
|
||||||
if isinstance(error_obj, dict):
|
error_object = cast(dict[str, Any], error_obj) if isinstance(error_obj, dict) else None
|
||||||
type_value = error_obj.get("type") or type_value
|
if error_object is not None:
|
||||||
code_value = error_obj.get("code") or code_value
|
type_value = error_object.get("type") or type_value
|
||||||
|
code_value = error_object.get("code") or code_value
|
||||||
|
|
||||||
return cls._normalize_error_token(type_value), cls._normalize_error_token(code_value)
|
return cls._normalize_error_token(type_value), cls._normalize_error_token(code_value)
|
||||||
|
|
||||||
@ -582,13 +586,14 @@ class LLMProvider(ABC):
|
|||||||
def _strip_image_content(messages: list[dict[str, Any]]) -> list[dict[str, Any]] | None:
|
def _strip_image_content(messages: list[dict[str, Any]]) -> list[dict[str, Any]] | None:
|
||||||
"""Replace image_url blocks with text placeholder. Returns None if no images found."""
|
"""Replace image_url blocks with text placeholder. Returns None if no images found."""
|
||||||
found = False
|
found = False
|
||||||
result = []
|
result: list[dict[str, Any]] = []
|
||||||
for msg in messages:
|
for msg in messages:
|
||||||
content = msg.get("content")
|
content = msg.get("content")
|
||||||
if isinstance(content, list):
|
if isinstance(content, list):
|
||||||
new_content = []
|
new_content: list[Any] = []
|
||||||
for b in content:
|
for raw_block in cast(list[object], content):
|
||||||
if isinstance(b, dict) and b.get("type") == "image_url":
|
block = cast(dict[str, Any], raw_block) if isinstance(raw_block, dict) else None
|
||||||
|
if block is not None and block.get("type") == "image_url":
|
||||||
placeholder = (
|
placeholder = (
|
||||||
"[Image not delivered to model — "
|
"[Image not delivered to model — "
|
||||||
"do not describe or reference it]"
|
"do not describe or reference it]"
|
||||||
@ -596,7 +601,7 @@ class LLMProvider(ABC):
|
|||||||
new_content.append({"type": "text", "text": placeholder})
|
new_content.append({"type": "text", "text": placeholder})
|
||||||
found = True
|
found = True
|
||||||
else:
|
else:
|
||||||
new_content.append(b)
|
new_content.append(raw_block)
|
||||||
result.append({**msg, "content": new_content})
|
result.append({**msg, "content": new_content})
|
||||||
else:
|
else:
|
||||||
result.append(msg)
|
result.append(msg)
|
||||||
@ -614,8 +619,9 @@ class LLMProvider(ABC):
|
|||||||
for msg in messages:
|
for msg in messages:
|
||||||
content = msg.get("content")
|
content = msg.get("content")
|
||||||
if isinstance(content, list):
|
if isinstance(content, list):
|
||||||
for i, b in enumerate(content):
|
for i, raw_block in enumerate(cast(list[object], content)):
|
||||||
if isinstance(b, dict) and b.get("type") == "image_url":
|
block = cast(dict[str, Any], raw_block) if isinstance(raw_block, dict) else None
|
||||||
|
if block is not None and block.get("type") == "image_url":
|
||||||
placeholder = (
|
placeholder = (
|
||||||
"[Image not delivered to model — "
|
"[Image not delivered to model — "
|
||||||
"do not describe or reference it]"
|
"do not describe or reference it]"
|
||||||
@ -815,7 +821,7 @@ class LLMProvider(ABC):
|
|||||||
if value is not None:
|
if value is not None:
|
||||||
return value
|
return value
|
||||||
if isinstance(headers, dict):
|
if isinstance(headers, dict):
|
||||||
for key, value in headers.items():
|
for key, value in cast(dict[object, Any], headers).items():
|
||||||
if isinstance(key, str) and key.lower() == name.lower():
|
if isinstance(key, str) and key.lower() == name.lower():
|
||||||
return value
|
return value
|
||||||
return None
|
return None
|
||||||
@ -986,7 +992,7 @@ class LLMProvider(ABC):
|
|||||||
on_retry_wait=on_retry_wait,
|
on_retry_wait=on_retry_wait,
|
||||||
)
|
)
|
||||||
|
|
||||||
return last_response if last_response is not None else await call(**kw)
|
return last_response if last_response is not None else await call(**kw) # pyright: ignore[reportUnnecessaryComparison]
|
||||||
|
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
def get_default_model(self) -> str:
|
def get_default_model(self) -> str:
|
||||||
|
|||||||
@ -1,3 +1,4 @@
|
|||||||
|
# pyright: reportMissingTypeStubs=false
|
||||||
"""AWS Bedrock Converse provider."""
|
"""AWS Bedrock Converse provider."""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
@ -8,7 +9,7 @@ import json
|
|||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
from collections.abc import Awaitable, Callable, Iterator
|
from collections.abc import Awaitable, Callable, Iterator
|
||||||
from typing import Any
|
from typing import Any, cast
|
||||||
|
|
||||||
from nanobot.providers.base import (
|
from nanobot.providers.base import (
|
||||||
LLMProvider,
|
LLMProvider,
|
||||||
@ -30,7 +31,10 @@ def _deep_merge(base: dict[str, Any], override: dict[str, Any]) -> dict[str, Any
|
|||||||
merged = dict(base)
|
merged = dict(base)
|
||||||
for key, value in override.items():
|
for key, value in override.items():
|
||||||
if key in merged and isinstance(merged[key], dict) and isinstance(value, dict):
|
if key in merged and isinstance(merged[key], dict) and isinstance(value, dict):
|
||||||
merged[key] = _deep_merge(merged[key], value)
|
merged[key] = _deep_merge(
|
||||||
|
cast(dict[str, Any], merged[key]),
|
||||||
|
cast(dict[str, Any], value),
|
||||||
|
)
|
||||||
else:
|
else:
|
||||||
merged[key] = value
|
merged[key] = value
|
||||||
return merged
|
return merged
|
||||||
@ -77,7 +81,8 @@ class BedrockProvider(LLMProvider):
|
|||||||
session_kwargs: dict[str, Any] = {}
|
session_kwargs: dict[str, Any] = {}
|
||||||
if self.profile:
|
if self.profile:
|
||||||
session_kwargs["profile_name"] = self.profile
|
session_kwargs["profile_name"] = self.profile
|
||||||
session = boto3.Session(**session_kwargs)
|
boto3_module = cast(Any, boto3)
|
||||||
|
session = boto3_module.Session(**session_kwargs)
|
||||||
|
|
||||||
client_kwargs: dict[str, Any] = {}
|
client_kwargs: dict[str, Any] = {}
|
||||||
if self.region:
|
if self.region:
|
||||||
@ -107,7 +112,8 @@ class BedrockProvider(LLMProvider):
|
|||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _image_url_block(block: dict[str, Any]) -> dict[str, Any] | None:
|
def _image_url_block(block: dict[str, Any]) -> dict[str, Any] | None:
|
||||||
url = (block.get("image_url") or {}).get("url", "")
|
image_url = cast(dict[str, Any], block.get("image_url") or {})
|
||||||
|
url = image_url.get("url", "")
|
||||||
if not isinstance(url, str) or not url:
|
if not isinstance(url, str) or not url:
|
||||||
return None
|
return None
|
||||||
match = _IMAGE_DATA_URL.match(url)
|
match = _IMAGE_DATA_URL.match(url)
|
||||||
@ -132,10 +138,11 @@ class BedrockProvider(LLMProvider):
|
|||||||
return [{"text": str(content)}]
|
return [{"text": str(content)}]
|
||||||
|
|
||||||
blocks: list[dict[str, Any]] = []
|
blocks: list[dict[str, Any]] = []
|
||||||
for item in content:
|
for raw_item in cast(list[object], content):
|
||||||
if not isinstance(item, dict):
|
if not isinstance(raw_item, dict):
|
||||||
blocks.append({"text": str(item)})
|
blocks.append({"text": str(raw_item)})
|
||||||
continue
|
continue
|
||||||
|
item = cast(dict[str, Any], raw_item)
|
||||||
|
|
||||||
item_type = item.get("type")
|
item_type = item.get("type")
|
||||||
if item_type in _TEXT_BLOCK_TYPES or "text" in item:
|
if item_type in _TEXT_BLOCK_TYPES or "text" in item:
|
||||||
@ -181,6 +188,7 @@ class BedrockProvider(LLMProvider):
|
|||||||
function = tool_call.get("function")
|
function = tool_call.get("function")
|
||||||
if not isinstance(function, dict):
|
if not isinstance(function, dict):
|
||||||
return None
|
return None
|
||||||
|
function = cast(dict[str, Any], function)
|
||||||
args = tool_arguments_object_for_replay(function.get("arguments", {}))
|
args = tool_arguments_object_for_replay(function.get("arguments", {}))
|
||||||
return {
|
return {
|
||||||
"toolUse": {
|
"toolUse": {
|
||||||
@ -216,8 +224,10 @@ class BedrockProvider(LLMProvider):
|
|||||||
def _assistant_blocks(cls, msg: dict[str, Any]) -> list[dict[str, Any]]:
|
def _assistant_blocks(cls, msg: dict[str, Any]) -> list[dict[str, Any]]:
|
||||||
blocks: list[dict[str, Any]] = []
|
blocks: list[dict[str, Any]] = []
|
||||||
|
|
||||||
for thinking in msg.get("thinking_blocks") or []:
|
thinking_values = cast(list[object], msg.get("thinking_blocks") or [])
|
||||||
if isinstance(thinking, dict):
|
for thinking_value in thinking_values:
|
||||||
|
if isinstance(thinking_value, dict):
|
||||||
|
thinking = cast(dict[str, Any], thinking_value)
|
||||||
reasoning = cls._reasoning_block(thinking)
|
reasoning = cls._reasoning_block(thinking)
|
||||||
if reasoning:
|
if reasoning:
|
||||||
blocks.append(reasoning)
|
blocks.append(reasoning)
|
||||||
@ -228,8 +238,10 @@ class BedrockProvider(LLMProvider):
|
|||||||
elif isinstance(content, list):
|
elif isinstance(content, list):
|
||||||
blocks.extend(block for block in cls._content_blocks(content) if "text" in block)
|
blocks.extend(block for block in cls._content_blocks(content) if "text" in block)
|
||||||
|
|
||||||
for tool_call in msg.get("tool_calls") or []:
|
tool_call_values = cast(list[object], msg.get("tool_calls") or [])
|
||||||
if isinstance(tool_call, dict):
|
for tool_call_value in tool_call_values:
|
||||||
|
if isinstance(tool_call_value, dict):
|
||||||
|
tool_call = cast(dict[str, Any], tool_call_value)
|
||||||
block = cls._tool_use_block(tool_call)
|
block = cls._tool_use_block(tool_call)
|
||||||
if block:
|
if block:
|
||||||
blocks.append(block)
|
blocks.append(block)
|
||||||
@ -240,7 +252,8 @@ class BedrockProvider(LLMProvider):
|
|||||||
def _has_tool_use(msg: dict[str, Any]) -> bool:
|
def _has_tool_use(msg: dict[str, Any]) -> bool:
|
||||||
content = msg.get("content")
|
content = msg.get("content")
|
||||||
return isinstance(content, list) and any(
|
return isinstance(content, list) and any(
|
||||||
isinstance(block, dict) and "toolUse" in block for block in content
|
isinstance(block, dict) and "toolUse" in block
|
||||||
|
for block in cast(list[object], content)
|
||||||
)
|
)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
@ -249,12 +262,14 @@ class BedrockProvider(LLMProvider):
|
|||||||
for msg in messages:
|
for msg in messages:
|
||||||
if merged and merged[-1].get("role") == msg.get("role"):
|
if merged and merged[-1].get("role") == msg.get("role"):
|
||||||
prev = merged[-1].setdefault("content", [])
|
prev = merged[-1].setdefault("content", [])
|
||||||
cur = msg.get("content") or []
|
cur: Any = msg.get("content") or []
|
||||||
if not isinstance(prev, list):
|
if not isinstance(prev, list):
|
||||||
prev = [{"text": str(prev)}]
|
prev = [{"text": str(prev)}]
|
||||||
merged[-1]["content"] = prev
|
merged[-1]["content"] = prev
|
||||||
|
else:
|
||||||
|
prev = cast(list[Any], prev)
|
||||||
if isinstance(cur, list):
|
if isinstance(cur, list):
|
||||||
prev.extend(cur)
|
prev.extend(cast(list[Any], cur))
|
||||||
else:
|
else:
|
||||||
prev.append({"text": str(cur)})
|
prev.append({"text": str(cur)})
|
||||||
else:
|
else:
|
||||||
@ -303,9 +318,12 @@ class BedrockProvider(LLMProvider):
|
|||||||
return None
|
return None
|
||||||
result: list[dict[str, Any]] = []
|
result: list[dict[str, Any]] = []
|
||||||
for tool in tools:
|
for tool in tools:
|
||||||
func = tool.get("function") if isinstance(tool.get("function"), dict) else tool
|
function_value = tool.get("function")
|
||||||
if not isinstance(func, dict):
|
func = (
|
||||||
continue
|
cast(dict[str, Any], function_value)
|
||||||
|
if isinstance(function_value, dict)
|
||||||
|
else tool
|
||||||
|
)
|
||||||
name = str(func.get("name") or "")
|
name = str(func.get("name") or "")
|
||||||
if not name:
|
if not name:
|
||||||
continue
|
continue
|
||||||
@ -330,9 +348,11 @@ class BedrockProvider(LLMProvider):
|
|||||||
content = msg.get("content")
|
content = msg.get("content")
|
||||||
if not isinstance(content, list):
|
if not isinstance(content, list):
|
||||||
continue
|
continue
|
||||||
for block in content:
|
for block_value in cast(list[object], content):
|
||||||
if isinstance(block, dict) and ("toolUse" in block or "toolResult" in block):
|
if isinstance(block_value, dict):
|
||||||
return True
|
block = cast(dict[str, Any], block_value)
|
||||||
|
if "toolUse" in block or "toolResult" in block:
|
||||||
|
return True
|
||||||
return False
|
return False
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
@ -356,7 +376,8 @@ class BedrockProvider(LLMProvider):
|
|||||||
if tool_choice == "none":
|
if tool_choice == "none":
|
||||||
return None
|
return None
|
||||||
if isinstance(tool_choice, dict):
|
if isinstance(tool_choice, dict):
|
||||||
name = tool_choice.get("function", {}).get("name")
|
function = cast(dict[str, Any], tool_choice.get("function", {}))
|
||||||
|
name = function.get("name")
|
||||||
if name:
|
if name:
|
||||||
return {"tool": {"name": str(name)}}
|
return {"tool": {"name": str(name)}}
|
||||||
return {"auto": {}}
|
return {"auto": {}}
|
||||||
@ -457,8 +478,10 @@ class BedrockProvider(LLMProvider):
|
|||||||
reasoning = block.get("reasoningContent")
|
reasoning = block.get("reasoningContent")
|
||||||
if not isinstance(reasoning, dict):
|
if not isinstance(reasoning, dict):
|
||||||
return None, None
|
return None, None
|
||||||
|
reasoning = cast(dict[str, Any], reasoning)
|
||||||
text_obj = reasoning.get("reasoningText")
|
text_obj = reasoning.get("reasoningText")
|
||||||
if isinstance(text_obj, dict):
|
if isinstance(text_obj, dict):
|
||||||
|
text_obj = cast(dict[str, Any], text_obj)
|
||||||
text = text_obj.get("text")
|
text = text_obj.get("text")
|
||||||
if isinstance(text, str):
|
if isinstance(text, str):
|
||||||
return text, {
|
return text, {
|
||||||
@ -480,15 +503,19 @@ class BedrockProvider(LLMProvider):
|
|||||||
reasoning_parts: list[str] = []
|
reasoning_parts: list[str] = []
|
||||||
tool_calls: list[ToolCallRequest] = []
|
tool_calls: list[ToolCallRequest] = []
|
||||||
thinking_blocks: list[dict[str, Any]] = []
|
thinking_blocks: list[dict[str, Any]] = []
|
||||||
message = (response.get("output") or {}).get("message") or {}
|
output = cast(dict[str, Any], response.get("output") or {})
|
||||||
|
message = cast(dict[str, Any], output.get("message") or {})
|
||||||
|
|
||||||
for block in message.get("content") or []:
|
content_blocks = cast(list[object], message.get("content") or [])
|
||||||
if not isinstance(block, dict):
|
for block_value in content_blocks:
|
||||||
|
if not isinstance(block_value, dict):
|
||||||
continue
|
continue
|
||||||
|
block = cast(dict[str, Any], block_value)
|
||||||
if isinstance(block.get("text"), str):
|
if isinstance(block.get("text"), str):
|
||||||
content_parts.append(block["text"])
|
content_parts.append(cast(str, block["text"]))
|
||||||
tool_use = block.get("toolUse")
|
tool_use = block.get("toolUse")
|
||||||
if isinstance(tool_use, dict):
|
if isinstance(tool_use, dict):
|
||||||
|
tool_use = cast(dict[str, Any], tool_use)
|
||||||
arguments = tool_use.get("input", {})
|
arguments = tool_use.get("input", {})
|
||||||
tool_calls.append(ToolCallRequest(
|
tool_calls.append(ToolCallRequest(
|
||||||
id=str(tool_use.get("toolUseId") or ""),
|
id=str(tool_use.get("toolUseId") or ""),
|
||||||
@ -504,8 +531,8 @@ class BedrockProvider(LLMProvider):
|
|||||||
return LLMResponse(
|
return LLMResponse(
|
||||||
content="".join(content_parts) or None,
|
content="".join(content_parts) or None,
|
||||||
tool_calls=tool_calls,
|
tool_calls=tool_calls,
|
||||||
finish_reason=cls._finish_reason(response.get("stopReason")),
|
finish_reason=cls._finish_reason(cast(str | None, response.get("stopReason"))),
|
||||||
usage=cls._usage(response.get("usage")),
|
usage=cls._usage(cast(dict[str, Any] | None, response.get("usage"))),
|
||||||
reasoning_content="".join(reasoning_parts) or None,
|
reasoning_content="".join(reasoning_parts) or None,
|
||||||
thinking_blocks=thinking_blocks or None,
|
thinking_blocks=thinking_blocks or None,
|
||||||
)
|
)
|
||||||
@ -522,11 +549,12 @@ class BedrockProvider(LLMProvider):
|
|||||||
state: dict[str, Any],
|
state: dict[str, Any],
|
||||||
) -> str | None:
|
) -> str | None:
|
||||||
if "contentBlockStart" in event:
|
if "contentBlockStart" in event:
|
||||||
data = event["contentBlockStart"]
|
data = cast(dict[str, Any], event["contentBlockStart"])
|
||||||
idx = int(data.get("contentBlockIndex") or 0)
|
idx = int(data.get("contentBlockIndex") or 0)
|
||||||
start = data.get("start") or {}
|
start = cast(dict[str, Any], data.get("start") or {})
|
||||||
tool_use = start.get("toolUse")
|
tool_use = start.get("toolUse")
|
||||||
if isinstance(tool_use, dict):
|
if isinstance(tool_use, dict):
|
||||||
|
tool_use = cast(dict[str, Any], tool_use)
|
||||||
tool_buffers[idx] = {
|
tool_buffers[idx] = {
|
||||||
"id": str(tool_use.get("toolUseId") or ""),
|
"id": str(tool_use.get("toolUseId") or ""),
|
||||||
"name": str(tool_use.get("name") or ""),
|
"name": str(tool_use.get("name") or ""),
|
||||||
@ -535,21 +563,27 @@ class BedrockProvider(LLMProvider):
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
if "contentBlockDelta" in event:
|
if "contentBlockDelta" in event:
|
||||||
data = event["contentBlockDelta"]
|
data = cast(dict[str, Any], event["contentBlockDelta"])
|
||||||
idx = int(data.get("contentBlockIndex") or 0)
|
idx = int(data.get("contentBlockIndex") or 0)
|
||||||
delta = data.get("delta") or {}
|
delta = cast(dict[str, Any], data.get("delta") or {})
|
||||||
text = delta.get("text")
|
text = delta.get("text")
|
||||||
if isinstance(text, str):
|
if isinstance(text, str):
|
||||||
content_parts.append(text)
|
content_parts.append(text)
|
||||||
return text
|
return text
|
||||||
tool_delta = delta.get("toolUse")
|
tool_delta = delta.get("toolUse")
|
||||||
if isinstance(tool_delta, dict):
|
if isinstance(tool_delta, dict):
|
||||||
|
tool_delta = cast(dict[str, Any], tool_delta)
|
||||||
buf = tool_buffers.setdefault(idx, {"id": "", "name": "", "input": ""})
|
buf = tool_buffers.setdefault(idx, {"id": "", "name": "", "input": ""})
|
||||||
if isinstance(tool_delta.get("input"), str):
|
if isinstance(tool_delta.get("input"), str):
|
||||||
buf["input"] += tool_delta["input"]
|
buf["input"] += tool_delta["input"]
|
||||||
reasoning = delta.get("reasoningContent")
|
reasoning = delta.get("reasoningContent")
|
||||||
if isinstance(reasoning, dict):
|
if isinstance(reasoning, dict):
|
||||||
buf = state.setdefault("reasoning_buffers", {}).setdefault(
|
reasoning = cast(dict[str, Any], reasoning)
|
||||||
|
reasoning_buffers = cast(
|
||||||
|
dict[int, dict[str, Any]],
|
||||||
|
state.setdefault("reasoning_buffers", {}),
|
||||||
|
)
|
||||||
|
buf = reasoning_buffers.setdefault(
|
||||||
idx, {"text": "", "signature": "", "redactedContent": None}
|
idx, {"text": "", "signature": "", "redactedContent": None}
|
||||||
)
|
)
|
||||||
if isinstance(reasoning.get("text"), str):
|
if isinstance(reasoning.get("text"), str):
|
||||||
@ -562,8 +596,13 @@ class BedrockProvider(LLMProvider):
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
if "contentBlockStop" in event:
|
if "contentBlockStop" in event:
|
||||||
idx = int((event["contentBlockStop"] or {}).get("contentBlockIndex") or 0)
|
stop = cast(dict[str, Any], event["contentBlockStop"] or {})
|
||||||
reasoning_buf = state.setdefault("reasoning_buffers", {}).pop(idx, None)
|
idx = int(stop.get("contentBlockIndex") or 0)
|
||||||
|
reasoning_buffers = cast(
|
||||||
|
dict[int, dict[str, Any]],
|
||||||
|
state.setdefault("reasoning_buffers", {}),
|
||||||
|
)
|
||||||
|
reasoning_buf = reasoning_buffers.pop(idx, None)
|
||||||
if reasoning_buf:
|
if reasoning_buf:
|
||||||
if reasoning_buf.get("text"):
|
if reasoning_buf.get("text"):
|
||||||
thinking_blocks.append({
|
thinking_blocks.append({
|
||||||
@ -589,11 +628,12 @@ class BedrockProvider(LLMProvider):
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
if "messageStop" in event:
|
if "messageStop" in event:
|
||||||
state["stop_reason"] = (event["messageStop"] or {}).get("stopReason")
|
message_stop = cast(dict[str, Any], event["messageStop"] or {})
|
||||||
|
state["stop_reason"] = message_stop.get("stopReason")
|
||||||
return None
|
return None
|
||||||
|
|
||||||
if "metadata" in event:
|
if "metadata" in event:
|
||||||
metadata = event["metadata"] or {}
|
metadata = cast(dict[str, Any], event["metadata"] or {})
|
||||||
if isinstance(metadata.get("usage"), dict):
|
if isinstance(metadata.get("usage"), dict):
|
||||||
state["usage"] = metadata["usage"]
|
state["usage"] = metadata["usage"]
|
||||||
return None
|
return None
|
||||||
@ -631,14 +671,29 @@ class BedrockProvider(LLMProvider):
|
|||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def _handle_error(cls, e: Exception) -> LLMResponse:
|
def _handle_error(cls, e: Exception) -> LLMResponse:
|
||||||
response = getattr(e, "response", None)
|
response_value = getattr(e, "response", None)
|
||||||
metadata = response.get("ResponseMetadata", {}) if isinstance(response, dict) else {}
|
response = (
|
||||||
headers = metadata.get("HTTPHeaders") if isinstance(metadata, dict) else None
|
cast(dict[str, Any], response_value)
|
||||||
error_obj = response.get("Error", {}) if isinstance(response, dict) else {}
|
if isinstance(response_value, dict)
|
||||||
message = error_obj.get("Message") if isinstance(error_obj, dict) else None
|
else {}
|
||||||
code = error_obj.get("Code") if isinstance(error_obj, dict) else None
|
)
|
||||||
status_code = metadata.get("HTTPStatusCode") if isinstance(metadata, dict) else None
|
metadata_value = response.get("ResponseMetadata", {})
|
||||||
body = message or str(e)
|
metadata = (
|
||||||
|
cast(dict[str, Any], metadata_value)
|
||||||
|
if isinstance(metadata_value, dict)
|
||||||
|
else {}
|
||||||
|
)
|
||||||
|
headers = metadata.get("HTTPHeaders")
|
||||||
|
error_value = response.get("Error", {})
|
||||||
|
error_obj = (
|
||||||
|
cast(dict[str, Any], error_value)
|
||||||
|
if isinstance(error_value, dict)
|
||||||
|
else {}
|
||||||
|
)
|
||||||
|
message = error_obj.get("Message")
|
||||||
|
code = error_obj.get("Code")
|
||||||
|
status_code = metadata.get("HTTPStatusCode")
|
||||||
|
body = cast(str, message or str(e))
|
||||||
retry_after = cls._extract_retry_after_from_headers(headers)
|
retry_after = cls._extract_retry_after_from_headers(headers)
|
||||||
if retry_after is None:
|
if retry_after is None:
|
||||||
retry_after = cls._extract_retry_after(body)
|
retry_after = cls._extract_retry_after(body)
|
||||||
@ -683,7 +738,10 @@ class BedrockProvider(LLMProvider):
|
|||||||
kwargs = self._build_kwargs(
|
kwargs = self._build_kwargs(
|
||||||
messages, tools, model, max_tokens, temperature, reasoning_effort, tool_choice
|
messages, tools, model, max_tokens, temperature, reasoning_effort, tool_choice
|
||||||
)
|
)
|
||||||
response = await asyncio.to_thread(self._client.converse, **kwargs)
|
response = cast(
|
||||||
|
dict[str, Any],
|
||||||
|
await asyncio.to_thread(self._client.converse, **kwargs),
|
||||||
|
)
|
||||||
return self._parse_response(response)
|
return self._parse_response(response)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return self._handle_error(e)
|
return self._handle_error(e)
|
||||||
@ -713,8 +771,11 @@ class BedrockProvider(LLMProvider):
|
|||||||
kwargs = self._build_kwargs(
|
kwargs = self._build_kwargs(
|
||||||
messages, tools, model, max_tokens, temperature, reasoning_effort, tool_choice
|
messages, tools, model, max_tokens, temperature, reasoning_effort, tool_choice
|
||||||
)
|
)
|
||||||
response = await asyncio.to_thread(self._client.converse_stream, **kwargs)
|
response = cast(
|
||||||
stream = iter(response.get("stream") or [])
|
dict[str, Any],
|
||||||
|
await asyncio.to_thread(self._client.converse_stream, **kwargs),
|
||||||
|
)
|
||||||
|
stream = cast(Iterator[dict[str, Any]], iter(response.get("stream") or []))
|
||||||
while True:
|
while True:
|
||||||
event = await asyncio.wait_for(
|
event = await asyncio.wait_for(
|
||||||
asyncio.to_thread(_next_or_none, stream),
|
asyncio.to_thread(_next_or_none, stream),
|
||||||
|
|||||||
@ -160,6 +160,8 @@ def _make_provider_core(
|
|||||||
elif backend == "azure_openai":
|
elif backend == "azure_openai":
|
||||||
from nanobot.providers.azure_openai_provider import AzureOpenAIProvider
|
from nanobot.providers.azure_openai_provider import AzureOpenAIProvider
|
||||||
|
|
||||||
|
if p is None or p.api_base is None:
|
||||||
|
raise RuntimeError("validated Azure provider setup is missing api_base")
|
||||||
provider = AzureOpenAIProvider(
|
provider = AzureOpenAIProvider(
|
||||||
api_key=p.api_key or "",
|
api_key=p.api_key or "",
|
||||||
api_base=p.api_base,
|
api_base=p.api_base,
|
||||||
|
|||||||
@ -1,5 +1,7 @@
|
|||||||
"""Provider wrapper that transparently fails over to fallback models on error."""
|
"""Provider wrapper that transparently fails over to fallback models on error."""
|
||||||
|
|
||||||
|
# pyright: reportIncompatibleMethodOverride=false, reportIncompatibleVariableOverride=false
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import time
|
import time
|
||||||
@ -8,7 +10,7 @@ from typing import Any
|
|||||||
|
|
||||||
from loguru import logger
|
from loguru import logger
|
||||||
|
|
||||||
from nanobot.providers.base import LLMProvider, LLMResponse
|
from nanobot.providers.base import GenerationSettings, LLMProvider, LLMResponse
|
||||||
|
|
||||||
# Circuit breaker tuned to match OpenAICompatProvider's Responses API breaker.
|
# Circuit breaker tuned to match OpenAICompatProvider's Responses API breaker.
|
||||||
_PRIMARY_FAILURE_THRESHOLD = 3
|
_PRIMARY_FAILURE_THRESHOLD = 3
|
||||||
@ -121,11 +123,11 @@ class FallbackProvider(LLMProvider):
|
|||||||
self._primary_tripped_at: float | None = None
|
self._primary_tripped_at: float | None = None
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def generation(self):
|
def generation(self) -> GenerationSettings:
|
||||||
return self._primary.generation
|
return self._primary.generation
|
||||||
|
|
||||||
@generation.setter
|
@generation.setter
|
||||||
def generation(self, value):
|
def generation(self, value: GenerationSettings) -> None:
|
||||||
self._primary.generation = value
|
self._primary.generation = value
|
||||||
|
|
||||||
def get_default_model(self) -> str:
|
def get_default_model(self) -> str:
|
||||||
|
|||||||
@ -1,5 +1,7 @@
|
|||||||
"""GitHub Copilot OAuth-backed provider."""
|
"""GitHub Copilot OAuth-backed provider."""
|
||||||
|
|
||||||
|
# pyright: reportMissingTypeStubs=false
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
@ -8,11 +10,13 @@ import time
|
|||||||
import webbrowser
|
import webbrowser
|
||||||
from collections.abc import Awaitable, Callable
|
from collections.abc import Awaitable, Callable
|
||||||
from contextlib import suppress
|
from contextlib import suppress
|
||||||
|
from typing import Any, cast
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
from oauth_cli_kit.models import OAuthToken
|
from oauth_cli_kit.models import OAuthToken
|
||||||
from oauth_cli_kit.storage import FileTokenStorage
|
from oauth_cli_kit.storage import FileTokenStorage
|
||||||
|
|
||||||
|
from nanobot.providers.base import LLMResponse
|
||||||
from nanobot.providers.openai_compat_provider import OpenAICompatProvider
|
from nanobot.providers.openai_compat_provider import OpenAICompatProvider
|
||||||
|
|
||||||
DEFAULT_GITHUB_DEVICE_CODE_URL = "https://github.com/login/device/code"
|
DEFAULT_GITHUB_DEVICE_CODE_URL = "https://github.com/login/device/code"
|
||||||
@ -232,19 +236,19 @@ class GitHubCopilotProvider(OpenAICompatProvider):
|
|||||||
token = await self._get_copilot_access_token()
|
token = await self._get_copilot_access_token()
|
||||||
client = await self._ensure_client()
|
client = await self._ensure_client()
|
||||||
self.api_key = token
|
self.api_key = token
|
||||||
client.api_key = token
|
cast(Any, client).api_key = token
|
||||||
return token
|
return token
|
||||||
|
|
||||||
async def chat(
|
async def chat(
|
||||||
self,
|
self,
|
||||||
messages: list[dict[str, object]],
|
messages: list[dict[str, Any]],
|
||||||
tools: list[dict[str, object]] | None = None,
|
tools: list[dict[str, Any]] | None = None,
|
||||||
model: str | None = None,
|
model: str | None = None,
|
||||||
max_tokens: int = 4096,
|
max_tokens: int = 4096,
|
||||||
temperature: float = 0.7,
|
temperature: float = 0.7,
|
||||||
reasoning_effort: str | None = None,
|
reasoning_effort: str | None = None,
|
||||||
tool_choice: str | dict[str, object] | None = None,
|
tool_choice: str | dict[str, Any] | None = None,
|
||||||
):
|
) -> LLMResponse:
|
||||||
await self._refresh_client_api_key()
|
await self._refresh_client_api_key()
|
||||||
return await super().chat(
|
return await super().chat(
|
||||||
messages=messages,
|
messages=messages,
|
||||||
@ -258,17 +262,17 @@ class GitHubCopilotProvider(OpenAICompatProvider):
|
|||||||
|
|
||||||
async def chat_stream(
|
async def chat_stream(
|
||||||
self,
|
self,
|
||||||
messages: list[dict[str, object]],
|
messages: list[dict[str, Any]],
|
||||||
tools: list[dict[str, object]] | None = None,
|
tools: list[dict[str, Any]] | None = None,
|
||||||
model: str | None = None,
|
model: str | None = None,
|
||||||
max_tokens: int = 4096,
|
max_tokens: int = 4096,
|
||||||
temperature: float = 0.7,
|
temperature: float = 0.7,
|
||||||
reasoning_effort: str | None = None,
|
reasoning_effort: str | None = None,
|
||||||
tool_choice: str | dict[str, object] | None = None,
|
tool_choice: str | dict[str, Any] | None = None,
|
||||||
on_content_delta: Callable[[str], None] | None = None,
|
on_content_delta: Callable[[str], Awaitable[None]] | None = None,
|
||||||
on_thinking_delta: Callable[[str], Awaitable[None]] | None = None,
|
on_thinking_delta: Callable[[str], Awaitable[None]] | None = None,
|
||||||
on_tool_call_delta: Callable[[dict[str, object]], Awaitable[None]] | None = None,
|
on_tool_call_delta: Callable[[dict[str, Any]], Awaitable[None]] | None = None,
|
||||||
):
|
) -> LLMResponse:
|
||||||
await self._refresh_client_api_key()
|
await self._refresh_client_api_key()
|
||||||
return await super().chat_stream(
|
return await super().chat_stream(
|
||||||
messages=messages,
|
messages=messages,
|
||||||
|
|||||||
@ -9,12 +9,13 @@ import re
|
|||||||
from abc import ABC, abstractmethod
|
from abc import ABC, abstractmethod
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any
|
from typing import Any, cast
|
||||||
from urllib.parse import urljoin
|
from urllib.parse import urljoin
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
from loguru import logger
|
from loguru import logger
|
||||||
|
|
||||||
|
from nanobot.config.schema import Config, ProviderConfig
|
||||||
from nanobot.providers.registry import find_by_name
|
from nanobot.providers.registry import find_by_name
|
||||||
from nanobot.security.network import (
|
from nanobot.security.network import (
|
||||||
PinnedDNSAsyncTransport,
|
PinnedDNSAsyncTransport,
|
||||||
@ -81,6 +82,18 @@ class GeneratedImageResponse:
|
|||||||
raw: dict[str, Any]
|
raw: dict[str, Any]
|
||||||
|
|
||||||
|
|
||||||
|
def _as_json_object(value: object) -> dict[str, Any] | None:
|
||||||
|
"""Narrow an untrusted provider response value to a JSON object."""
|
||||||
|
return cast(dict[str, Any], value) if isinstance(value, dict) else None
|
||||||
|
|
||||||
|
|
||||||
|
def _as_json_objects(value: object) -> list[dict[str, Any]]:
|
||||||
|
"""Return object entries from an untrusted provider response array."""
|
||||||
|
if not isinstance(value, list):
|
||||||
|
return []
|
||||||
|
return [cast(dict[str, Any], item) for item in cast(list[object], value) if isinstance(item, dict)]
|
||||||
|
|
||||||
|
|
||||||
def _read_image_b64(path: str | Path) -> tuple[str, str]:
|
def _read_image_b64(path: str | Path) -> tuple[str, str]:
|
||||||
"""Return ``(mime, base64)`` for the image at ``path``."""
|
"""Return ``(mime, base64)`` for the image at ``path``."""
|
||||||
p = Path(path).expanduser()
|
p = Path(path).expanduser()
|
||||||
@ -249,7 +262,7 @@ def image_gen_provider_names() -> tuple[str, ...]:
|
|||||||
return tuple(_IMAGE_GEN_PROVIDERS)
|
return tuple(_IMAGE_GEN_PROVIDERS)
|
||||||
|
|
||||||
|
|
||||||
def image_gen_provider_configs(config: Any) -> dict[str, Any]:
|
def image_gen_provider_configs(config: Config) -> dict[str, ProviderConfig]:
|
||||||
providers_cfg = config.providers
|
providers_cfg = config.providers
|
||||||
return {
|
return {
|
||||||
name: pc
|
name: pc
|
||||||
@ -315,7 +328,7 @@ class ImageGenerationProvider(ABC):
|
|||||||
def _require_images(self, images: list[str], data: dict[str, Any]) -> None:
|
def _require_images(self, images: list[str], data: dict[str, Any]) -> None:
|
||||||
if images:
|
if images:
|
||||||
return
|
return
|
||||||
provider_error = data.get("error") if isinstance(data, dict) else None
|
provider_error = data.get("error")
|
||||||
label = self.provider_name
|
label = self.provider_name
|
||||||
if provider_error:
|
if provider_error:
|
||||||
raise ImageGenerationError(f"{label} returned no images: {provider_error}")
|
raise ImageGenerationError(f"{label} returned no images: {provider_error}")
|
||||||
@ -410,20 +423,17 @@ class OpenRouterImageGenerationClient(ImageGenerationProvider):
|
|||||||
detail = response.text[:500]
|
detail = response.text[:500]
|
||||||
raise ImageGenerationError(f"OpenRouter image generation failed: {detail}") from exc
|
raise ImageGenerationError(f"OpenRouter image generation failed: {detail}") from exc
|
||||||
|
|
||||||
data = response.json()
|
data = _as_json_object(response.json()) or {}
|
||||||
images: list[str] = []
|
images: list[str] = []
|
||||||
text_parts: list[str] = []
|
text_parts: list[str] = []
|
||||||
for choice in data.get("choices") or []:
|
for choice in _as_json_objects(data.get("choices")):
|
||||||
if not isinstance(choice, dict):
|
message = _as_json_object(choice.get("message")) or {}
|
||||||
continue
|
message_content = message.get("content")
|
||||||
message = choice.get("message") or {}
|
if isinstance(message_content, str):
|
||||||
if isinstance(message.get("content"), str):
|
text_parts.append(message_content)
|
||||||
text_parts.append(message["content"])
|
for image in _as_json_objects(message.get("images")):
|
||||||
for image in message.get("images") or []:
|
image_url = _as_json_object(image.get("image_url") or image.get("imageUrl"))
|
||||||
if not isinstance(image, dict):
|
url_value = image_url.get("url") if image_url is not None else None
|
||||||
continue
|
|
||||||
image_url = image.get("image_url") or image.get("imageUrl") or {}
|
|
||||||
url_value = image_url.get("url") if isinstance(image_url, dict) else None
|
|
||||||
if isinstance(url_value, str) and url_value.startswith("data:image/"):
|
if isinstance(url_value, str) and url_value.startswith("data:image/"):
|
||||||
images.append(url_value)
|
images.append(url_value)
|
||||||
|
|
||||||
@ -527,7 +537,7 @@ class AIHubMixImageGenerationClient(ImageGenerationProvider):
|
|||||||
detail = response.text[:500]
|
detail = response.text[:500]
|
||||||
raise ImageGenerationError(f"AIHubMix image generation failed: {detail}") from exc
|
raise ImageGenerationError(f"AIHubMix image generation failed: {detail}") from exc
|
||||||
|
|
||||||
payload = response.json()
|
payload = _as_json_object(response.json()) or {}
|
||||||
images = await _aihubmix_images_from_payload(payload, proxy=self.proxy)
|
images = await _aihubmix_images_from_payload(payload, proxy=self.proxy)
|
||||||
|
|
||||||
self._require_images(images, payload)
|
self._require_images(images, payload)
|
||||||
@ -538,11 +548,12 @@ class AIHubMixImageGenerationClient(ImageGenerationProvider):
|
|||||||
def _http_error_detail(response: httpx.Response) -> str:
|
def _http_error_detail(response: httpx.Response) -> str:
|
||||||
"""Extract a readable error message from an HTTP error response."""
|
"""Extract a readable error message from an HTTP error response."""
|
||||||
try:
|
try:
|
||||||
data = response.json()
|
data = _as_json_object(response.json())
|
||||||
if isinstance(data, dict):
|
if data is not None:
|
||||||
err = data.get("error")
|
err = _as_json_object(data.get("error"))
|
||||||
if isinstance(err, dict):
|
if err is not None:
|
||||||
return err.get("message") or str(err)
|
message = err.get("message")
|
||||||
|
return message if isinstance(message, str) else str(err)
|
||||||
if err:
|
if err:
|
||||||
return str(err)
|
return str(err)
|
||||||
except Exception:
|
except Exception:
|
||||||
@ -595,11 +606,11 @@ def _ollama_image_data_url(value: str) -> str:
|
|||||||
def _ollama_images_from_payload(payload: dict[str, Any]) -> list[str]:
|
def _ollama_images_from_payload(payload: dict[str, Any]) -> list[str]:
|
||||||
images: list[str] = []
|
images: list[str] = []
|
||||||
|
|
||||||
def collect(value: Any) -> None:
|
def collect(value: object) -> None:
|
||||||
if isinstance(value, str) and value:
|
if isinstance(value, str) and value:
|
||||||
images.append(_ollama_image_data_url(value))
|
images.append(_ollama_image_data_url(value))
|
||||||
elif isinstance(value, list):
|
elif isinstance(value, list):
|
||||||
for item in value:
|
for item in cast(list[object], value):
|
||||||
collect(item)
|
collect(item)
|
||||||
|
|
||||||
collect(payload.get("image"))
|
collect(payload.get("image"))
|
||||||
@ -768,14 +779,12 @@ class GeminiImageGenerationClient(ImageGenerationProvider):
|
|||||||
f"Gemini Imagen generation failed (HTTP {response.status_code}): {detail}"
|
f"Gemini Imagen generation failed (HTTP {response.status_code}): {detail}"
|
||||||
) from exc
|
) from exc
|
||||||
|
|
||||||
data = response.json()
|
data = _as_json_object(response.json()) or {}
|
||||||
images: list[str] = []
|
images: list[str] = []
|
||||||
for prediction in data.get("predictions") or []:
|
for prediction in _as_json_objects(data.get("predictions")):
|
||||||
if not isinstance(prediction, dict):
|
|
||||||
continue
|
|
||||||
b64 = prediction.get("bytesBase64Encoded")
|
b64 = prediction.get("bytesBase64Encoded")
|
||||||
mime = prediction.get("mimeType", "image/png")
|
mime = prediction.get("mimeType", "image/png")
|
||||||
if isinstance(b64, str) and b64:
|
if isinstance(b64, str) and b64 and isinstance(mime, str):
|
||||||
images.append(f"data:{mime};base64,{b64}")
|
images.append(f"data:{mime};base64,{b64}")
|
||||||
|
|
||||||
self._require_images(images, data)
|
self._require_images(images, data)
|
||||||
@ -824,23 +833,21 @@ class GeminiImageGenerationClient(ImageGenerationProvider):
|
|||||||
f"Gemini image generation failed (HTTP {response.status_code}): {detail}"
|
f"Gemini image generation failed (HTTP {response.status_code}): {detail}"
|
||||||
) from exc
|
) from exc
|
||||||
|
|
||||||
data = response.json()
|
data = _as_json_object(response.json()) or {}
|
||||||
images: list[str] = []
|
images: list[str] = []
|
||||||
text_parts: list[str] = []
|
text_parts: list[str] = []
|
||||||
for candidate in data.get("candidates") or []:
|
for candidate in _as_json_objects(data.get("candidates")):
|
||||||
if not isinstance(candidate, dict):
|
content = _as_json_object(candidate.get("content")) or {}
|
||||||
continue
|
for part in _as_json_objects(content.get("parts")):
|
||||||
content = candidate.get("content") or {}
|
|
||||||
for part in content.get("parts") or []:
|
|
||||||
if not isinstance(part, dict):
|
|
||||||
continue
|
|
||||||
if "text" in part:
|
if "text" in part:
|
||||||
text_parts.append(part["text"])
|
text = part["text"]
|
||||||
inline = part.get("inlineData")
|
if isinstance(text, str):
|
||||||
if isinstance(inline, dict):
|
text_parts.append(text)
|
||||||
|
inline = _as_json_object(part.get("inlineData"))
|
||||||
|
if inline is not None:
|
||||||
mime = inline.get("mimeType", "image/png")
|
mime = inline.get("mimeType", "image/png")
|
||||||
b64 = inline.get("data", "")
|
b64 = inline.get("data", "")
|
||||||
if b64:
|
if isinstance(mime, str) and isinstance(b64, str) and b64:
|
||||||
images.append(f"data:{mime};base64,{b64}")
|
images.append(f"data:{mime};base64,{b64}")
|
||||||
|
|
||||||
self._require_images(images, data)
|
self._require_images(images, data)
|
||||||
@ -914,9 +921,9 @@ async def _aihubmix_images_from_payload(
|
|||||||
if "output" in payload:
|
if "output" in payload:
|
||||||
candidates.append(payload["output"])
|
candidates.append(payload["output"])
|
||||||
|
|
||||||
async def collect(value: Any) -> None:
|
async def collect(value: object) -> None:
|
||||||
if isinstance(value, list):
|
if isinstance(value, list):
|
||||||
for item in value:
|
for item in cast(list[object], value):
|
||||||
await collect(item)
|
await collect(item)
|
||||||
return
|
return
|
||||||
if isinstance(value, str):
|
if isinstance(value, str):
|
||||||
@ -925,32 +932,38 @@ async def _aihubmix_images_from_payload(
|
|||||||
elif value.startswith(("http://", "https://")):
|
elif value.startswith(("http://", "https://")):
|
||||||
images.append(await _download_image_data_url(value, proxy=proxy))
|
images.append(await _download_image_data_url(value, proxy=proxy))
|
||||||
return
|
return
|
||||||
if not isinstance(value, dict):
|
value_object = _as_json_object(value)
|
||||||
|
if value_object is None:
|
||||||
return
|
return
|
||||||
|
|
||||||
b64_json = value.get("b64_json")
|
b64_json = value_object.get("b64_json")
|
||||||
if isinstance(b64_json, str) and b64_json:
|
if isinstance(b64_json, str) and b64_json:
|
||||||
images.append(_b64_image_data_url(b64_json))
|
images.append(_b64_image_data_url(b64_json))
|
||||||
elif b64_json is not None:
|
elif b64_json is not None:
|
||||||
await collect(b64_json)
|
await collect(b64_json)
|
||||||
|
|
||||||
bytes_base64 = value.get("bytesBase64") or value.get("bytes_base64") or value.get("base64")
|
bytes_base64 = (
|
||||||
|
value_object.get("bytesBase64")
|
||||||
|
or value_object.get("bytes_base64")
|
||||||
|
or value_object.get("base64")
|
||||||
|
)
|
||||||
if isinstance(bytes_base64, str) and bytes_base64:
|
if isinstance(bytes_base64, str) and bytes_base64:
|
||||||
images.append(_b64_image_data_url(bytes_base64))
|
images.append(_b64_image_data_url(bytes_base64))
|
||||||
|
|
||||||
image_url = value.get("image_url") or value.get("imageUrl")
|
image_url = value_object.get("image_url") or value_object.get("imageUrl")
|
||||||
if isinstance(image_url, dict):
|
image_url_object = _as_json_object(image_url)
|
||||||
await collect(image_url.get("url"))
|
if image_url_object is not None:
|
||||||
|
await collect(image_url_object.get("url"))
|
||||||
elif image_url is not None:
|
elif image_url is not None:
|
||||||
await collect(image_url)
|
await collect(image_url)
|
||||||
|
|
||||||
url_value = value.get("url")
|
url_value = value_object.get("url")
|
||||||
if url_value is not None:
|
if url_value is not None:
|
||||||
await collect(url_value)
|
await collect(url_value)
|
||||||
|
|
||||||
for key in ("images", "image", "output"):
|
for key in ("images", "image", "output"):
|
||||||
if key in value:
|
if key in value_object:
|
||||||
await collect(value[key])
|
await collect(value_object[key])
|
||||||
|
|
||||||
for candidate in candidates:
|
for candidate in candidates:
|
||||||
await collect(candidate)
|
await collect(candidate)
|
||||||
@ -1061,9 +1074,10 @@ def _minimax_images_from_payload(payload: dict[str, Any]) -> list[str]:
|
|||||||
"""
|
"""
|
||||||
images: list[str] = []
|
images: list[str] = []
|
||||||
data = payload.get("data")
|
data = payload.get("data")
|
||||||
if not isinstance(data, dict):
|
data_object = _as_json_object(data)
|
||||||
|
if data_object is None:
|
||||||
return images
|
return images
|
||||||
for b64 in data.get("image_base64") or []:
|
for b64 in cast(list[object], data_object.get("image_base64") or []):
|
||||||
if isinstance(b64, str) and b64:
|
if isinstance(b64, str) and b64:
|
||||||
images.append(_b64_image_data_url(b64))
|
images.append(_b64_image_data_url(b64))
|
||||||
return images
|
return images
|
||||||
@ -1381,11 +1395,14 @@ class CodexImageGenerationClient(ImageGenerationProvider):
|
|||||||
image_size: str | None = None,
|
image_size: str | None = None,
|
||||||
) -> GeneratedImageResponse:
|
) -> GeneratedImageResponse:
|
||||||
try:
|
try:
|
||||||
from oauth_cli_kit import get_token as get_codex_token
|
from oauth_cli_kit import ( # pyright: ignore[reportMissingTypeStubs]
|
||||||
|
get_token as _get_codex_token,
|
||||||
|
)
|
||||||
except ImportError:
|
except ImportError:
|
||||||
raise ImageGenerationError(self.missing_key_message)
|
raise ImageGenerationError(self.missing_key_message)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
get_codex_token = cast(Any, _get_codex_token)
|
||||||
token_kwargs = {"proxy": self.proxy} if self.proxy else {}
|
token_kwargs = {"proxy": self.proxy} if self.proxy else {}
|
||||||
token = await asyncio.to_thread(get_codex_token, **token_kwargs)
|
token = await asyncio.to_thread(get_codex_token, **token_kwargs)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
@ -1405,9 +1422,9 @@ class CodexImageGenerationClient(ImageGenerationProvider):
|
|||||||
len(reference_images),
|
len(reference_images),
|
||||||
)
|
)
|
||||||
|
|
||||||
headers = {
|
headers: dict[str, str] = {
|
||||||
"Authorization": f"Bearer {token.access}",
|
"Authorization": f"Bearer {token.access}",
|
||||||
"chatgpt-account-id": token.account_id,
|
"chatgpt-account-id": str(token.account_id),
|
||||||
"OpenAI-Beta": "responses=experimental",
|
"OpenAI-Beta": "responses=experimental",
|
||||||
"originator": "nanobot",
|
"originator": "nanobot",
|
||||||
"User-Agent": "nanobot (python)",
|
"User-Agent": "nanobot (python)",
|
||||||
@ -1537,9 +1554,7 @@ async def _openai_images_from_payload(
|
|||||||
Handles both ``b64_json`` (preferred) and ``url`` (downloaded) formats.
|
Handles both ``b64_json`` (preferred) and ``url`` (downloaded) formats.
|
||||||
"""
|
"""
|
||||||
images: list[str] = []
|
images: list[str] = []
|
||||||
for item in payload.get("data") or []:
|
for item in _as_json_objects(payload.get("data")):
|
||||||
if not isinstance(item, dict):
|
|
||||||
continue
|
|
||||||
b64 = item.get("b64_json")
|
b64 = item.get("b64_json")
|
||||||
if isinstance(b64, str) and b64:
|
if isinstance(b64, str) and b64:
|
||||||
images.append(_b64_image_data_url(b64))
|
images.append(_b64_image_data_url(b64))
|
||||||
@ -1567,7 +1582,7 @@ async def _parse_codex_sse_images(
|
|||||||
line = line_bytes.strip()
|
line = line_bytes.strip()
|
||||||
if line == "":
|
if line == "":
|
||||||
if buffer:
|
if buffer:
|
||||||
data_lines = []
|
data_lines: list[str] = []
|
||||||
for bl in buffer:
|
for bl in buffer:
|
||||||
if bl.startswith("data:"):
|
if bl.startswith("data:"):
|
||||||
data_lines.append(bl[5:].strip())
|
data_lines.append(bl[5:].strip())
|
||||||
@ -1577,9 +1592,11 @@ async def _parse_codex_sse_images(
|
|||||||
if raw == "[DONE]":
|
if raw == "[DONE]":
|
||||||
break
|
break
|
||||||
try:
|
try:
|
||||||
event = _json.loads(raw)
|
event = _as_json_object(_json.loads(raw))
|
||||||
except Exception:
|
except Exception:
|
||||||
continue
|
continue
|
||||||
|
if event is None:
|
||||||
|
continue
|
||||||
ev_type = event.get("type", "")
|
ev_type = event.get("type", "")
|
||||||
if ev_type in ("error", "response.failed"):
|
if ev_type in ("error", "response.failed"):
|
||||||
logger.error("Codex SSE failure: {}", raw[:2000])
|
logger.error("Codex SSE failure: {}", raw[:2000])
|
||||||
@ -1596,12 +1613,13 @@ async def _parse_codex_sse_images(
|
|||||||
raw = "".join(data_lines)
|
raw = "".join(data_lines)
|
||||||
if raw and raw != "[DONE]":
|
if raw and raw != "[DONE]":
|
||||||
try:
|
try:
|
||||||
event = _json.loads(raw)
|
event = _as_json_object(_json.loads(raw))
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
else:
|
else:
|
||||||
_collect_images_from_sse_event(event, images)
|
if event is not None:
|
||||||
_collect_text_from_sse_event(event, text_parts)
|
_collect_images_from_sse_event(event, images)
|
||||||
|
_collect_text_from_sse_event(event, text_parts)
|
||||||
|
|
||||||
return images, "".join(text_parts).strip()
|
return images, "".join(text_parts).strip()
|
||||||
|
|
||||||
@ -1609,7 +1627,7 @@ async def _parse_codex_sse_images(
|
|||||||
def _collect_images_from_sse_event(event: dict[str, Any], images: list[str]) -> None:
|
def _collect_images_from_sse_event(event: dict[str, Any], images: list[str]) -> None:
|
||||||
if event.get("type") != "response.output_item.done":
|
if event.get("type") != "response.output_item.done":
|
||||||
return
|
return
|
||||||
item = event.get("item") or {}
|
item = _as_json_object(event.get("item")) or {}
|
||||||
if item.get("type") != "image_generation_call":
|
if item.get("type") != "image_generation_call":
|
||||||
return
|
return
|
||||||
result = item.get("result")
|
result = item.get("result")
|
||||||
@ -1618,8 +1636,8 @@ def _collect_images_from_sse_event(event: dict[str, Any], images: list[str]) ->
|
|||||||
images.append(result)
|
images.append(result)
|
||||||
else:
|
else:
|
||||||
images.append(_b64_image_data_url(result))
|
images.append(_b64_image_data_url(result))
|
||||||
elif isinstance(result, dict):
|
elif (result_object := _as_json_object(result)) is not None:
|
||||||
image_url = result.get("image_url") or result.get("image") or ""
|
image_url = result_object.get("image_url") or result_object.get("image") or ""
|
||||||
if isinstance(image_url, str):
|
if isinstance(image_url, str):
|
||||||
if image_url.startswith("data:image/"):
|
if image_url.startswith("data:image/"):
|
||||||
images.append(image_url)
|
images.append(image_url)
|
||||||
@ -1749,9 +1767,7 @@ def _stepfun_images_from_payload(payload: dict[str, Any]) -> list[str]:
|
|||||||
StepFun returns images in ``data[].b64_json`` (base64 strings).
|
StepFun returns images in ``data[].b64_json`` (base64 strings).
|
||||||
"""
|
"""
|
||||||
images: list[str] = []
|
images: list[str] = []
|
||||||
for item in payload.get("data") or []:
|
for item in _as_json_objects(payload.get("data")):
|
||||||
if not isinstance(item, dict):
|
|
||||||
continue
|
|
||||||
b64 = item.get("b64_json")
|
b64 = item.get("b64_json")
|
||||||
if isinstance(b64, str) and b64:
|
if isinstance(b64, str) and b64:
|
||||||
images.append(_b64_image_data_url(b64))
|
images.append(_b64_image_data_url(b64))
|
||||||
@ -1894,9 +1910,7 @@ async def _zhipu_images_from_payload(
|
|||||||
We download and re-encode as base64 data URLs.
|
We download and re-encode as base64 data URLs.
|
||||||
"""
|
"""
|
||||||
images: list[str] = []
|
images: list[str] = []
|
||||||
for item in payload.get("data") or []:
|
for item in _as_json_objects(payload.get("data")):
|
||||||
if not isinstance(item, dict):
|
|
||||||
continue
|
|
||||||
url = item.get("url")
|
url = item.get("url")
|
||||||
if isinstance(url, str) and url:
|
if isinstance(url, str) and url:
|
||||||
images.append(await _download_image_data_url(url, proxy=proxy))
|
images.append(await _download_image_data_url(url, proxy=proxy))
|
||||||
@ -2080,7 +2094,7 @@ class ModelScopeImageGenerationClient(ImageGenerationProvider):
|
|||||||
data: dict[str, Any],
|
data: dict[str, Any],
|
||||||
) -> list[str]:
|
) -> list[str]:
|
||||||
images: list[str] = []
|
images: list[str] = []
|
||||||
for url in data.get("output_images") or []:
|
for url in cast(list[object], data.get("output_images") or []):
|
||||||
if isinstance(url, str) and url:
|
if isinstance(url, str) and url:
|
||||||
if url.startswith("data:image/"):
|
if url.startswith("data:image/"):
|
||||||
images.append(url)
|
images.append(url)
|
||||||
|
|||||||
@ -1,12 +1,14 @@
|
|||||||
"""OpenAI Codex Responses Provider."""
|
"""OpenAI Codex Responses Provider."""
|
||||||
|
|
||||||
|
# pyright: reportMissingTypeStubs=false, reportPrivateUsage=false
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
import hashlib
|
import hashlib
|
||||||
import json
|
import json
|
||||||
from collections.abc import Awaitable, Callable
|
from collections.abc import Awaitable, Callable
|
||||||
from typing import Any
|
from typing import Any, cast
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
from loguru import logger
|
from loguru import logger
|
||||||
@ -83,7 +85,7 @@ class OpenAICodexProvider(LLMProvider):
|
|||||||
stage = "oauth_token"
|
stage = "oauth_token"
|
||||||
try:
|
try:
|
||||||
token = await asyncio.to_thread(get_codex_token, proxy=self.proxy)
|
token = await asyncio.to_thread(get_codex_token, proxy=self.proxy)
|
||||||
headers = _build_headers(token.account_id, token.access)
|
headers = _build_headers(cast(str, token.account_id), token.access)
|
||||||
|
|
||||||
stage = "codex_request"
|
stage = "codex_request"
|
||||||
try:
|
try:
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user