Merge remote-tracking branch 'origin/main' into nightly

This commit is contained in:
chengyongru 2026-05-07 00:46:59 +08:00
commit 22b3010bd0
100 changed files with 5694 additions and 1307 deletions

View File

@ -165,6 +165,10 @@ export class WhatsAppClient {
fallbackContent = '[Video]'; fallbackContent = '[Video]';
const path = await this.downloadMedia(msg, unwrapped.videoMessage.mimetype ?? undefined); const path = await this.downloadMedia(msg, unwrapped.videoMessage.mimetype ?? undefined);
if (path) mediaPaths.push(path); if (path) mediaPaths.push(path);
} else if (unwrapped.audioMessage) {
fallbackContent = '[Voice Message]';
const path = await this.downloadMedia(msg, unwrapped.audioMessage.mimetype ?? undefined);
if (path) mediaPaths.push(path);
} }
const finalContent = content || (mediaPaths.length === 0 ? fallbackContent : '') || ''; const finalContent = content || (mediaPaths.length === 0 ? fallbackContent : '') || '';

View File

@ -55,6 +55,7 @@ from nanobot.utils.progress_events import (
on_progress_accepts_tool_events, on_progress_accepts_tool_events,
) )
from nanobot.utils.runtime import EMPTY_FINAL_RESPONSE_MESSAGE from nanobot.utils.runtime import EMPTY_FINAL_RESPONSE_MESSAGE
from nanobot.utils.webui_titles import mark_webui_session, maybe_generate_webui_title_after_turn
if TYPE_CHECKING: if TYPE_CHECKING:
from nanobot.config.schema import ChannelsConfig, ExecToolConfig, ToolsConfig, WebToolsConfig from nanobot.config.schema import ChannelsConfig, ExecToolConfig, ToolsConfig, WebToolsConfig
@ -112,6 +113,11 @@ class _LoopHook(AgentHook):
async def before_iteration(self, context: AgentHookContext) -> None: async def before_iteration(self, context: AgentHookContext) -> None:
self._loop._current_iteration = context.iteration self._loop._current_iteration = context.iteration
logger.debug(
"Starting agent loop iteration {} for session {}",
context.iteration,
self._session_key,
)
async def before_execute_tools(self, context: AgentHookContext) -> None: async def before_execute_tools(self, context: AgentHookContext) -> None:
if self._on_progress: if self._on_progress:
@ -422,7 +428,7 @@ class AgentLoop:
logger.warning("MCP connection cancelled (will retry next message)") logger.warning("MCP connection cancelled (will retry next message)")
self._mcp_stacks.clear() self._mcp_stacks.clear()
except BaseException as e: except BaseException as e:
logger.error("Failed to connect MCP servers (will retry next message): {}", e) logger.warning("Failed to connect MCP servers (will retry next message): {}", e)
self._mcp_stacks.clear() self._mcp_stacks.clear()
finally: finally:
self._mcp_connecting = False self._mcp_connecting = False
@ -648,6 +654,7 @@ class AgentLoop:
context_block_limit=self.context_block_limit, context_block_limit=self.context_block_limit,
provider_retry_mode=self.provider_retry_mode, provider_retry_mode=self.provider_retry_mode,
progress_callback=on_progress, progress_callback=on_progress,
stream_progress_deltas=on_stream is not None,
retry_wait_callback=on_retry_wait, retry_wait_callback=on_retry_wait,
checkpoint_callback=_checkpoint, checkpoint_callback=_checkpoint,
injection_callback=_drain_pending, injection_callback=_drain_pending,
@ -800,6 +807,33 @@ class AgentLoop:
channel=msg.channel, chat_id=msg.chat_id, channel=msg.channel, chat_id=msg.chat_id,
content="", metadata=msg.metadata or {}, content="", metadata=msg.metadata or {},
)) ))
if msg.channel == "websocket":
# Signal that the turn is fully complete (all tools executed,
# final text streamed). This lets WS clients know when to
# definitively stop the loading indicator.
await self.bus.publish_outbound(OutboundMessage(
channel=msg.channel, chat_id=msg.chat_id,
content="", metadata={**msg.metadata, "_turn_end": True},
))
if msg.metadata.get("webui") is True:
async def _generate_title_and_notify() -> None:
generated = await maybe_generate_webui_title_after_turn(
channel=msg.channel,
metadata=msg.metadata,
sessions=self.sessions,
session_key=session_key,
provider=self.provider,
model=self.model,
)
if generated:
await self.bus.publish_outbound(OutboundMessage(
channel=msg.channel,
chat_id=msg.chat_id,
content="",
metadata={**msg.metadata, "_session_updated": True},
))
self._schedule_background(_generate_title_and_notify())
except asyncio.CancelledError: except asyncio.CancelledError:
logger.info("Task cancelled for session {}", session_key) logger.info("Task cancelled for session {}", session_key)
# Preserve partial context from the interrupted turn so # Preserve partial context from the interrupted turn so
@ -903,6 +937,8 @@ class AgentLoop:
self.sessions.save(session) self.sessions.save(session)
session, pending = self.auto_compact.prepare_session(session, key) session, pending = self.auto_compact.prepare_session(session, key)
if pending:
logger.info("Memory compact triggered for session {}", key)
await self.consolidator.maybe_consolidate_by_tokens( await self.consolidator.maybe_consolidate_by_tokens(
session, session,
@ -915,6 +951,7 @@ class AgentLoop:
# LLM via the merged prompt. See _persist_subagent_followup. # LLM via the merged prompt. See _persist_subagent_followup.
is_subagent = msg.sender_id == "subagent" is_subagent = msg.sender_id == "subagent"
if is_subagent and self._persist_subagent_followup(session, msg): if is_subagent and self._persist_subagent_followup(session, msg):
logger.debug("Subagent result persisted for session {}", key)
self.sessions.save(session) self.sessions.save(session)
self._set_tool_context( self._set_tool_context(
channel, chat_id, msg.metadata.get("message_id"), channel, chat_id, msg.metadata.get("message_id"),
@ -986,6 +1023,7 @@ class AgentLoop:
key = session_key or msg.session_key key = session_key or msg.session_key
session = self.sessions.get_or_create(key) session = self.sessions.get_or_create(key)
mark_webui_session(session, msg.metadata)
if self._restore_runtime_checkpoint(session): if self._restore_runtime_checkpoint(session):
self.sessions.save(session) self.sessions.save(session)
if self._restore_pending_user_turn(session): if self._restore_pending_user_turn(session):
@ -1131,7 +1169,7 @@ class AgentLoop:
ask_user_options_from_messages(all_msgs) if stop_reason == "ask_user" else [], ask_user_options_from_messages(all_msgs) if stop_reason == "ask_user" else [],
msg.channel, msg.channel,
) )
if on_stream is not None and stop_reason not in {"ask_user", "error"}: if on_stream is not None and stop_reason not in {"ask_user", "error", "tool_error"}:
meta["_streamed"] = True meta["_streamed"] = True
return OutboundMessage( return OutboundMessage(
channel=msg.channel, channel=msg.channel,

View File

@ -974,12 +974,10 @@ class Dream:
if event["status"] == "ok": if event["status"] == "ok":
changelog.append(f"{event['name']}: {event['detail']}") changelog.append(f"{event['name']}: {event['detail']}")
# Advance cursor — always, to avoid re-processing Phase 1 # Only advance cursor on successful completion to prevent silent loss
new_cursor = batch[-1]["cursor"]
self.store.set_last_dream_cursor(new_cursor)
self.store.compact_history()
if result and result.stop_reason == "completed": if result and result.stop_reason == "completed":
new_cursor = batch[-1]["cursor"]
self.store.set_last_dream_cursor(new_cursor)
logger.info( logger.info(
"Dream done: {} change(s), cursor advanced to {}", "Dream done: {} change(s), cursor advanced to {}",
len(changelog), new_cursor, len(changelog), new_cursor,
@ -987,10 +985,12 @@ class Dream:
else: else:
reason = result.stop_reason if result else "exception" reason = result.stop_reason if result else "exception"
logger.warning( logger.warning(
"Dream incomplete ({}): cursor advanced to {}", "Dream incomplete ({}): cursor NOT advanced, will retry next cron cycle",
reason, new_cursor, reason,
) )
self.store.compact_history()
# Git auto-commit (only when there are actual changes) # Git auto-commit (only when there are actual changes)
if changelog and self.store.git.is_initialized(): if changelog and self.store.git.is_initialized():
ts = batch[-1]["timestamp"] ts = batch[-1]["timestamp"]

View File

@ -33,6 +33,7 @@ from nanobot.utils.runtime import (
ensure_nonempty_tool_result, ensure_nonempty_tool_result,
is_blank_text, is_blank_text,
repeated_external_lookup_error, repeated_external_lookup_error,
repeated_workspace_violation_error,
) )
_DEFAULT_ERROR_MESSAGE = "Sorry, I encountered an error calling the AI model." _DEFAULT_ERROR_MESSAGE = "Sorry, I encountered an error calling the AI model."
@ -75,6 +76,7 @@ class AgentRunSpec:
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: Any | None = None
stream_progress_deltas: bool = True
retry_wait_callback: Any | None = None retry_wait_callback: Any | None = None
checkpoint_callback: Any | None = None checkpoint_callback: Any | None = None
injection_callback: Any | None = None injection_callback: Any | None = None
@ -239,6 +241,8 @@ class AgentRunner:
stop_reason = "completed" stop_reason = "completed"
tool_events: list[dict[str, str]] = [] tool_events: list[dict[str, str]] = []
external_lookup_counts: dict[str, int] = {} external_lookup_counts: dict[str, int] = {}
# Per-turn throttle for repeated attempts against the same outside target.
workspace_violation_counts: dict[str, int] = {}
empty_content_retries = 0 empty_content_retries = 0
length_recovery_count = 0 length_recovery_count = 0
had_injections = False had_injections = False
@ -258,12 +262,11 @@ class AgentRunner:
# Snipping may have created new orphans; clean them up. # Snipping may have created new orphans; clean them up.
messages_for_model = self._drop_orphan_tool_results(messages_for_model) messages_for_model = self._drop_orphan_tool_results(messages_for_model)
messages_for_model = self._backfill_missing_tool_results(messages_for_model) messages_for_model = self._backfill_missing_tool_results(messages_for_model)
except Exception as exc: except Exception:
logger.warning( logger.exception(
"Context governance failed on turn {} for {}: {}; applying minimal repair", "Context governance failed on turn {} for {}; applying minimal repair",
iteration, iteration,
spec.session_key or "default", spec.session_key or "default",
exc,
) )
try: try:
messages_for_model = self._drop_orphan_tool_results(messages) messages_for_model = self._drop_orphan_tool_results(messages)
@ -314,6 +317,7 @@ class AgentRunner:
spec, spec,
tool_calls, tool_calls,
external_lookup_counts, external_lookup_counts,
workspace_violation_counts,
) )
tool_events.extend(new_events) tool_events.extend(new_events)
context.tool_results = list(results) context.tool_results = list(results)
@ -612,6 +616,7 @@ class AgentRunner:
wants_streaming = hook.wants_streaming() wants_streaming = hook.wants_streaming()
wants_progress_streaming = ( wants_progress_streaming = (
not wants_streaming not wants_streaming
and spec.stream_progress_deltas
and spec.progress_callback is not None and spec.progress_callback is not None
and getattr(self.provider, "supports_progress_deltas", False) is True and getattr(self.provider, "supports_progress_deltas", False) is True
) )
@ -698,20 +703,25 @@ class AgentRunner:
spec: AgentRunSpec, spec: AgentRunSpec,
tool_calls: list[ToolCallRequest], tool_calls: list[ToolCallRequest],
external_lookup_counts: dict[str, int], external_lookup_counts: dict[str, int],
workspace_violation_counts: dict[str, int],
) -> tuple[list[Any], list[dict[str, str]], BaseException | None]: ) -> tuple[list[Any], list[dict[str, str]], BaseException | None]:
batches = self._partition_tool_batches(spec, tool_calls) batches = self._partition_tool_batches(spec, tool_calls)
tool_results: list[tuple[Any, dict[str, str], BaseException | None]] = [] tool_results: list[tuple[Any, dict[str, str], BaseException | None]] = []
for batch in batches: for batch in batches:
if spec.concurrent_tools and len(batch) > 1: if spec.concurrent_tools and len(batch) > 1:
batch_results = await asyncio.gather(*( batch_results = await asyncio.gather(*(
self._run_tool(spec, tool_call, external_lookup_counts) self._run_tool(
spec, tool_call, external_lookup_counts, workspace_violation_counts,
)
for tool_call in batch for tool_call in batch
)) ))
tool_results.extend(batch_results) tool_results.extend(batch_results)
else: else:
batch_results = [] batch_results = []
for tool_call in batch: for tool_call in batch:
result = await self._run_tool(spec, tool_call, external_lookup_counts) result = await self._run_tool(
spec, tool_call, external_lookup_counts, workspace_violation_counts,
)
tool_results.append(result) tool_results.append(result)
batch_results.append(result) batch_results.append(result)
if isinstance(result[2], AskUserInterrupt): if isinstance(result[2], AskUserInterrupt):
@ -734,6 +744,7 @@ class AgentRunner:
spec: AgentRunSpec, spec: AgentRunSpec,
tool_call: ToolCallRequest, tool_call: ToolCallRequest,
external_lookup_counts: dict[str, int], external_lookup_counts: dict[str, int],
workspace_violation_counts: dict[str, int],
) -> tuple[Any, dict[str, str], BaseException | None]: ) -> tuple[Any, dict[str, str], BaseException | None]:
hint = "\n\n[Analyze the error above and try a different approach.]" hint = "\n\n[Analyze the error above and try a different approach.]"
lookup_error = repeated_external_lookup_error( lookup_error = repeated_external_lookup_error(
@ -763,16 +774,18 @@ class AgentRunner:
"status": "error", "status": "error",
"detail": prep_error.split(": ", 1)[-1][:120], "detail": prep_error.split(": ", 1)[-1][:120],
} }
if self._is_workspace_violation(prep_error): handled = self._classify_violation(
logger.warning( raw_text=prep_error,
"Tool {} blocked by workspace/safety guard during preparation; aborting turn: {}", soft_payload=prep_error + hint,
tool_call.name, event=event,
prep_error.replace("\n", " ").strip()[:200], tool_call=tool_call,
) workspace_violation_counts=workspace_violation_counts,
event["detail"] = ("workspace_violation: " )
+ prep_error.replace("\n", " ").strip())[:160] if handled is not None:
return prep_error, event, RuntimeError(prep_error) return handled
return prep_error + hint, event, RuntimeError(prep_error) if spec.fail_on_tool_error else None return prep_error + hint, event, (
RuntimeError(prep_error) if spec.fail_on_tool_error else None
)
try: try:
if tool is not None: if tool is not None:
result = await tool.execute(**params) result = await tool.execute(**params)
@ -789,18 +802,20 @@ class AgentRunner:
if isinstance(exc, AskUserInterrupt): if isinstance(exc, AskUserInterrupt):
event["status"] = "waiting" event["status"] = "waiting"
return "", event, exc return "", event, exc
if self._is_workspace_violation(str(exc)): payload = f"Error: {type(exc).__name__}: {exc}"
logger.warning( handled = self._classify_violation(
"Tool {} blocked by workspace/safety guard; aborting turn: {}", raw_text=str(exc),
tool_call.name, # Preserve legacy exception payloads without the retry hint.
str(exc).replace("\n", " ").strip()[:200], soft_payload=payload,
) event=event,
event["detail"] = ("workspace_violation: " tool_call=tool_call,
+ str(exc).replace("\n", " ").strip())[:160] workspace_violation_counts=workspace_violation_counts,
return f"Error: {type(exc).__name__}: {exc}", event, exc )
if handled is not None:
return handled
if spec.fail_on_tool_error: if spec.fail_on_tool_error:
return f"Error: {type(exc).__name__}: {exc}", event, exc return payload, event, exc
return f"Error: {type(exc).__name__}: {exc}", event, None return payload, event, None
if isinstance(result, str) and result.startswith("Error"): if isinstance(result, str) and result.startswith("Error"):
event = { event = {
@ -808,17 +823,15 @@ class AgentRunner:
"status": "error", "status": "error",
"detail": result.replace("\n", " ").strip()[:120], "detail": result.replace("\n", " ").strip()[:120],
} }
handled = self._classify_violation(
# check the outside workspace error and break loop raw_text=result,
if self._is_workspace_violation(result): soft_payload=result + hint,
logger.warning( event=event,
"Tool {} blocked by workspace/safety guard; aborting turn: {}", tool_call=tool_call,
tool_call.name, workspace_violation_counts=workspace_violation_counts,
result.replace("\n", " ").strip()[:200], )
) if handled is not None:
event["detail"] = ("workspace_violation: " return handled
+ result.replace("\n", " ").strip())[:160]
return result, event, RuntimeError(result)
if spec.fail_on_tool_error: if spec.fail_on_tool_error:
return result + hint, event, RuntimeError(result) return result + hint, event, RuntimeError(result)
return result + hint, event, None return result + hint, event, None
@ -831,23 +844,97 @@ class AgentRunner:
detail = detail[:120] + "..." detail = detail[:120] + "..."
return result, {"name": tool_call.name, "status": "ok", "detail": detail}, None return result, {"name": tool_call.name, "status": "ok", "detail": detail}, None
# Markers identifying tool results that represent a workspace / safety boundary rejection. # SSRF is a hard security block at the tool boundary, but the agent turn
_WORKSPACE_BLOCK_MARKERS: tuple[str, ...] = ( # should recover conversationally instead of aborting the runtime.
_SSRF_MARKERS: tuple[str, ...] = (
"internal/private url detected",
"private/internal address",
"private address",
)
_SSRF_BOUNDARY_NOTE: str = (
"This is a non-bypassable security boundary. Stop trying to access "
"private/internal URLs. Do not retry with curl, wget, encoded IPs, "
"alternate DNS, redirects, proxies, or another tool. Ask the user for "
"local files, logs, screenshots, or an explicit safe public URL instead. "
"If the user explicitly trusts this private URL, ask them to whitelist "
"the exact IP/CIDR via tools.ssrfWhitelist."
)
# Non-SSRF boundary markers returned to the LLM as recoverable tool errors.
_WORKSPACE_VIOLATION_MARKERS: tuple[str, ...] = (
"outside the configured workspace", "outside the configured workspace",
"outside allowed directory", "outside allowed directory",
"working_dir is outside", "working_dir is outside",
"working_dir could not be resolved", "working_dir could not be resolved",
"path traversal detected",
"path outside working dir", "path outside working dir",
"internal/private url detected", "path traversal detected",
) )
@classmethod @classmethod
def _is_workspace_violation(cls, text: str) -> bool: def _is_ssrf_violation(cls, text: str) -> bool:
if not text: if not text:
return False return False
lowered = text.lower() lowered = text.lower()
return any(marker in lowered for marker in cls._WORKSPACE_BLOCK_MARKERS) return any(marker in lowered for marker in cls._SSRF_MARKERS)
@classmethod
def _is_workspace_violation(cls, text: str) -> bool:
"""True when *text* looks like any policy boundary rejection."""
if not text:
return False
lowered = text.lower()
if cls._is_ssrf_violation(lowered):
return True
return any(marker in lowered for marker in cls._WORKSPACE_VIOLATION_MARKERS)
def _classify_violation(
self,
*,
raw_text: str,
soft_payload: str,
event: dict[str, str],
tool_call: ToolCallRequest,
workspace_violation_counts: dict[str, int],
) -> tuple[Any, dict[str, str], BaseException | None] | None:
"""Classify safety-boundary failures, or return ``None`` to pass through."""
if self._is_ssrf_violation(raw_text):
logger.warning(
"Tool {} blocked by SSRF guard; returning non-retryable tool error: {}",
tool_call.name,
raw_text.replace("\n", " ").strip()[:200],
)
event["detail"] = self._event_detail("ssrf_violation: ", raw_text)
return self._ssrf_soft_payload(raw_text), event, None
if self._is_workspace_violation(raw_text):
escalation = repeated_workspace_violation_error(
tool_call.name,
tool_call.arguments,
workspace_violation_counts,
)
event["detail"] = self._event_detail("workspace_violation: ", raw_text)
if escalation is not None:
logger.warning(
"Tool {} hit workspace boundary repeatedly; escalating hint",
tool_call.name,
)
event["detail"] = self._event_detail(
"workspace_violation_escalated: ",
raw_text,
)
return escalation, event, None
return soft_payload, event, None
return None
@classmethod
def _ssrf_soft_payload(cls, raw_text: str) -> str:
text = raw_text.strip() or "Error: request blocked by SSRF guard"
return f"{text}\n\n{cls._SSRF_BOUNDARY_NOTE}"
@staticmethod
def _event_detail(prefix: str, text: str, limit: int = 160) -> str:
return (prefix + text.replace("\n", " ").strip())[:limit]
async def _emit_checkpoint( async def _emit_checkpoint(
self, self,
@ -895,12 +982,11 @@ class AgentRunner:
result, result,
max_chars=spec.max_tool_result_chars, max_chars=spec.max_tool_result_chars,
) )
except Exception as exc: except Exception:
logger.warning( logger.exception(
"Tool result persist failed for {} in {}: {}; using raw result", "Tool result persist failed for {} in {}; using raw result",
tool_call_id, tool_call_id,
spec.session_key or "default", spec.session_key or "default",
exc,
) )
content = result content = result
if isinstance(content, str) and len(content) > spec.max_tool_result_chars: if isinstance(content, str) and len(content) > spec.max_tool_result_chars:

View File

@ -250,7 +250,7 @@ class SubagentManager:
except Exception as e: except Exception as e:
status.phase = "error" status.phase = "error"
status.error = str(e) status.error = str(e)
logger.error("Subagent [{}] failed: {}", task_id, e) logger.exception("Subagent [{}] failed", task_id)
await self._announce_result(task_id, label, task, f"Error: {e}", origin, "error", origin_message_id) await self._announce_result(task_id, label, task, f"Error: {e}", origin, "error", origin_message_id)
async def _announce_result( async def _announce_result(

View File

@ -14,6 +14,13 @@ from nanobot.utils.helpers import build_image_content_blocks, detect_image_mime
from nanobot.config.paths import get_media_dir from nanobot.config.paths import get_media_dir
_FS_WORKSPACE_BOUNDARY_NOTE = (
" (this is a hard policy boundary, not a transient failure; "
"do not retry with shell tricks or alternative tools, and ask "
"the user how to proceed if the resource is genuinely required)"
)
def _resolve_path( def _resolve_path(
path: str, path: str,
workspace: Path | None = None, workspace: Path | None = None,
@ -29,7 +36,10 @@ def _resolve_path(
media_path = get_media_dir().resolve() media_path = get_media_dir().resolve()
all_dirs = [allowed_dir] + [media_path] + (extra_allowed_dirs or []) all_dirs = [allowed_dir] + [media_path] + (extra_allowed_dirs or [])
if not any(_is_under(resolved, d) for d in all_dirs): if not any(_is_under(resolved, d) for d in all_dirs):
raise PermissionError(f"Path {path} is outside allowed directory {allowed_dir}") raise PermissionError(
f"Path {path} is outside allowed directory {allowed_dir}"
+ _FS_WORKSPACE_BOUNDARY_NOTE
)
return resolved return resolved

View File

@ -198,11 +198,10 @@ class MCPToolWrapper(Tool):
await asyncio.sleep(1) # Brief backoff before retry await asyncio.sleep(1) # Brief backoff before retry
continue continue
# Second transient failure — give up with retry-specific message # Second transient failure — give up with retry-specific message
logger.error( logger.exception(
"MCP tool '{}' failed after retry: {}: {}", "MCP tool '{}' failed after retry: {}",
self._name, self._name,
type(exc).__name__, type(exc).__name__,
exc,
) )
return f"(MCP tool call failed after retry: {type(exc).__name__})" return f"(MCP tool call failed after retry: {type(exc).__name__})"
logger.exception( logger.exception(
@ -287,11 +286,10 @@ class MCPResourceWrapper(Tool):
) )
await asyncio.sleep(1) await asyncio.sleep(1)
continue continue
logger.error( logger.exception(
"MCP resource '{}' failed after retry: {}: {}", "MCP resource '{}' failed after retry: {}",
self._name, self._name,
type(exc).__name__, type(exc).__name__,
exc,
) )
return f"(MCP resource read failed after retry: {type(exc).__name__})" return f"(MCP resource read failed after retry: {type(exc).__name__})"
logger.exception( logger.exception(
@ -383,7 +381,7 @@ class MCPPromptWrapper(Tool):
logger.warning("MCP prompt '{}' was cancelled by server/SDK", self._name) logger.warning("MCP prompt '{}' was cancelled by server/SDK", self._name)
return "(MCP prompt call was cancelled)" return "(MCP prompt call was cancelled)"
except McpError as exc: except McpError as exc:
logger.error( logger.exception(
"MCP prompt '{}' failed: code={} message={}", "MCP prompt '{}' failed: code={} message={}",
self._name, self._name,
exc.error.code, exc.error.code,
@ -400,11 +398,10 @@ class MCPPromptWrapper(Tool):
) )
await asyncio.sleep(1) await asyncio.sleep(1)
continue continue
logger.error( logger.exception(
"MCP prompt '{}' failed after retry: {}: {}", "MCP prompt '{}' failed after retry: {}",
self._name, self._name,
type(exc).__name__, type(exc).__name__,
exc,
) )
return f"(MCP prompt call failed after retry: {type(exc).__name__})" return f"(MCP prompt call failed after retry: {type(exc).__name__})"
logger.exception( logger.exception(
@ -439,8 +436,8 @@ async def connect_mcp_servers(
"""Connect to configured MCP servers and register their tools, resources, prompts. """Connect to configured MCP servers and register their tools, resources, prompts.
Returns a dict mapping server name -> its dedicated AsyncExitStack. Returns a dict mapping server name -> its dedicated AsyncExitStack.
Each server gets its own stack and runs in its own task to prevent Each server gets its own stack to prevent cancel scope conflicts
cancel scope conflicts when multiple MCP servers are configured. when multiple MCP servers are configured.
""" """
from mcp import ClientSession, StdioServerParameters from mcp import ClientSession, StdioServerParameters
from mcp.client.sse import sse_client from mcp.client.sse import sse_client
@ -608,26 +605,20 @@ async def connect_mcp_servers(
" Hint: this looks like stdio protocol pollution. Make sure the MCP server writes " " Hint: this looks like stdio protocol pollution. Make sure the MCP server writes "
"only JSON-RPC to stdout and sends logs/debug output to stderr instead." "only JSON-RPC to stdout and sends logs/debug output to stderr instead."
) )
logger.error("MCP server '{}': failed to connect: {}{}", name, e, hint) logger.exception("MCP server '{}': failed to connect: {}", name, hint)
with suppress(Exception): with suppress(Exception):
await server_stack.aclose() await server_stack.aclose()
return name, None return name, None
server_stacks: dict[str, AsyncExitStack] = {} server_stacks: dict[str, AsyncExitStack] = {}
tasks: list[asyncio.Task] = []
for name, cfg in mcp_servers.items(): for name, cfg in mcp_servers.items():
task = asyncio.create_task(connect_single_server(name, cfg)) try:
tasks.append(task) result = await connect_single_server(name, cfg)
except Exception as e:
results = await asyncio.gather(*tasks, return_exceptions=True) logger.error("MCP server '{}' connection failed: {}", name, e)
continue
for i, result in enumerate(results): if result is not None and result[1] is not None:
name = list(mcp_servers.keys())[i]
if isinstance(result, BaseException):
if not isinstance(result, asyncio.CancelledError):
logger.error("MCP server '{}' connection task failed: {}", name, result)
elif result is not None and result[1] is not None:
server_stacks[result[0]] = result[1] server_stacks[result[0]] = result[1]
return server_stacks return server_stacks

View File

@ -19,6 +19,16 @@ from nanobot.config.paths import get_media_dir
_IS_WINDOWS = sys.platform == "win32" _IS_WINDOWS = sys.platform == "win32"
# Policy note appended to recoverable workspace-boundary guard errors.
_WORKSPACE_BOUNDARY_NOTE = (
"\n\nNote: this is a hard policy boundary, not a transient failure. "
"Do NOT retry with shell tricks (symlinks, base64 piping, alternative "
"tools, working_dir overrides). If the user genuinely needs this "
"resource, tell them you cannot reach it under the current "
"restrict_to_workspace policy and ask how to proceed."
)
@tool_parameters( @tool_parameters(
tool_parameters_schema( tool_parameters_schema(
command=StringSchema("The shell command to execute"), command=StringSchema("The shell command to execute"),
@ -83,6 +93,19 @@ class ExecTool(Tool):
_MAX_TIMEOUT = 600 _MAX_TIMEOUT = 600
_MAX_OUTPUT = 10_000 _MAX_OUTPUT = 10_000
# Kernel device files safe as stdio redirect targets (#3599).
_BENIGN_DEVICE_PATHS: frozenset[str] = frozenset({
"/dev/null",
"/dev/zero",
"/dev/full",
"/dev/random",
"/dev/urandom",
"/dev/stdin",
"/dev/stdout",
"/dev/stderr",
"/dev/tty",
})
@property @property
def description(self) -> str: def description(self) -> str:
return ( return (
@ -113,9 +136,15 @@ class ExecTool(Tool):
requested = Path(cwd).expanduser().resolve() requested = Path(cwd).expanduser().resolve()
workspace_root = Path(self.working_dir).expanduser().resolve() workspace_root = Path(self.working_dir).expanduser().resolve()
except Exception: except Exception:
return "Error: working_dir could not be resolved" return (
"Error: working_dir could not be resolved"
+ _WORKSPACE_BOUNDARY_NOTE
)
if requested != workspace_root and workspace_root not in requested.parents: if requested != workspace_root and workspace_root not in requested.parents:
return "Error: working_dir is outside the configured workspace" return (
"Error: working_dir is outside the configured workspace"
+ _WORKSPACE_BOUNDARY_NOTE
)
guard_error = self._guard_command(command, cwd) guard_error = self._guard_command(command, cwd)
if guard_error: if guard_error:
@ -191,9 +220,12 @@ class ExecTool(Tool):
) -> asyncio.subprocess.Process: ) -> asyncio.subprocess.Process:
"""Launch *command* in a platform-appropriate shell.""" """Launch *command* in a platform-appropriate shell."""
if _IS_WINDOWS: if _IS_WINDOWS:
comspec = env.get("COMSPEC", os.environ.get("COMSPEC", "cmd.exe")) # create_subprocess_exec re-quotes args via list2cmdline, which
return await asyncio.create_subprocess_exec( # breaks commands containing paths with spaces (e.g. "D:\Program
comspec, "/c", command, # Files\python.exe" "script.py"). create_subprocess_shell passes
# the raw command string to COMSPEC without re-quoting.
return await asyncio.create_subprocess_shell(
command,
stdout=asyncio.subprocess.PIPE, stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE,
cwd=cwd, cwd=cwd,
@ -289,21 +321,33 @@ class ExecTool(Tool):
from nanobot.security.network import contains_internal_url from nanobot.security.network import contains_internal_url
if contains_internal_url(cmd): if contains_internal_url(cmd):
# The runner turns this marker into a non-retryable security hint.
return "Error: Command blocked by safety guard (internal/private URL detected)" return "Error: Command blocked by safety guard (internal/private URL detected)"
if self.restrict_to_workspace: if self.restrict_to_workspace:
if "..\\" in cmd or "../" in cmd: if "..\\" in cmd or "../" in cmd:
return "Error: Command blocked by safety guard (path traversal detected)" return (
"Error: Command blocked by safety guard (path traversal detected)"
+ _WORKSPACE_BOUNDARY_NOTE
)
cwd_path = Path(cwd).resolve() cwd_path = Path(cwd).resolve()
for raw in self._extract_absolute_paths(cmd): for raw in self._extract_absolute_paths(cmd):
try: try:
expanded = os.path.expandvars(raw.strip()) expanded = os.path.expandvars(raw.strip())
# Match against the un-resolved path first. On Linux,
# /dev/stderr is a symlink to /proc/self/fd/2 and
# ``Path.resolve()`` would mask the device-file intent.
if self._is_benign_device_path(expanded):
continue
p = Path(expanded).expanduser().resolve() p = Path(expanded).expanduser().resolve()
except Exception: except Exception:
continue continue
if self._is_benign_device_path(str(p)):
continue
media_path = get_media_dir().resolve() media_path = get_media_dir().resolve()
if (p.is_absolute() if (p.is_absolute()
and cwd_path not in p.parents and cwd_path not in p.parents
@ -311,15 +355,25 @@ class ExecTool(Tool):
and media_path not in p.parents and media_path not in p.parents
and p != media_path and p != media_path
): ):
return "Error: Command blocked by safety guard (path outside working dir)" return (
"Error: Command blocked by safety guard (path outside working dir)"
+ _WORKSPACE_BOUNDARY_NOTE
)
return None return None
@classmethod
def _is_benign_device_path(cls, path: str) -> bool:
"""Return True for kernel device files that should never be workspace-blocked."""
if path in cls._BENIGN_DEVICE_PATHS:
return True
return path.startswith("/dev/fd/")
@staticmethod @staticmethod
def _extract_absolute_paths(command: str) -> list[str]: def _extract_absolute_paths(command: str) -> list[str]:
# Windows: match drive-root paths like `C:\` as well as `C:\path\to\file` # Windows: match drive-root paths like `C:\` as well as `C:\path\to\file`
# NOTE: `*` is required so `C:\` (nothing after the slash) is still extracted. # NOTE: `*` is required so `C:\` (nothing after the slash) is still extracted.
win_paths = re.findall(r"[A-Za-z]:\\[^\s\"'|><;]*", command) win_paths = re.findall(r"[A-Za-z]:\\[^\s\"'|><;]*", command)
posix_paths = re.findall(r"(?:^|[\s|>'\"])(/[^\s\"'>;|<]+)", command) # POSIX: /absolute only posix_paths = re.findall(r"(?:^|[\s|>'\"])(/[^\s\"'>;|<]+)", command) # POSIX: /absolute only
home_paths = re.findall(r"(?:^|[\s|>'\"])(~[^\s\"'>;|<]*)", command) # POSIX/Windows home shortcut: ~ home_paths = re.findall(r"(?:^|[\s>'\"])(~[^\s\"'>;|<]*)", command) # POSIX/Windows home shortcut: ~
return win_paths + posix_paths + home_paths return win_paths + posix_paths + home_paths

View File

@ -500,10 +500,10 @@ class WebFetchTool(Tool):
"untrusted": True, "text": text, "untrusted": True, "text": text,
}, ensure_ascii=False) }, ensure_ascii=False)
except httpx.ProxyError as e: except httpx.ProxyError as e:
logger.error("WebFetch proxy error for {}: {}", url, e) logger.exception("WebFetch proxy error for {}", url)
return json.dumps({"error": f"Proxy error: {e}", "url": url}, ensure_ascii=False) return json.dumps({"error": f"Proxy error: {e}", "url": url}, ensure_ascii=False)
except Exception as e: except Exception as e:
logger.error("WebFetch error for {}: {}", url, e) logger.exception("WebFetch error for {}", url)
return json.dumps({"error": str(e), "url": url}, ensure_ascii=False) return json.dumps({"error": str(e), "url": url}, ensure_ascii=False)
def _to_markdown(self, html_content: str) -> str: def _to_markdown(self, html_content: str) -> str:

View File

@ -38,6 +38,7 @@ class BaseChannel(ABC):
bus: The message bus for communication. bus: The message bus for communication.
""" """
self.config = config self.config = config
self.logger = logger.bind(channel=self.name)
self.bus = bus self.bus = bus
self._running = False self._running = False
@ -61,8 +62,8 @@ class BaseChannel(ABC):
language=self.transcription_language or None, language=self.transcription_language or None,
) )
return await provider.transcribe(file_path) return await provider.transcribe(file_path)
except Exception as e: except Exception:
logger.warning("{}: audio transcription failed: {}", self.name, e) self.logger.exception("Audio transcription failed")
return "" return ""
async def login(self, force: bool = False) -> bool: async def login(self, force: bool = False) -> bool:
@ -136,7 +137,7 @@ class BaseChannel(ABC):
else: else:
allow_list = getattr(self.config, "allow_from", []) allow_list = getattr(self.config, "allow_from", [])
if not allow_list: if not allow_list:
logger.warning("{}: allow_from is empty — all access denied", self.name) self.logger.warning("allow_from is empty — all access denied")
return False return False
if "*" in allow_list: if "*" in allow_list:
return True return True
@ -165,10 +166,10 @@ class BaseChannel(ABC):
session_key: Optional session key override (e.g. thread-scoped sessions). session_key: Optional session key override (e.g. thread-scoped sessions).
""" """
if not self.is_allowed(sender_id): if not self.is_allowed(sender_id):
logger.warning( self.logger.warning(
"Access denied for sender {} on channel {}. " "Access denied for sender {}. "
"Add them to allowFrom list in config to grant access.", "Add them to allowFrom list in config to grant access.",
sender_id, self.name, sender_id,
) )
return return

View File

@ -12,7 +12,6 @@ from typing import Any
from urllib.parse import unquote, urljoin, urlparse from urllib.parse import unquote, urljoin, urlparse
import httpx import httpx
from loguru import logger
from pydantic import Field from pydantic import Field
from nanobot.bus.events import OutboundMessage from nanobot.bus.events import OutboundMessage
@ -113,7 +112,7 @@ class NanobotDingTalkHandler(CallbackHandler):
content = content + "\n\nReceived files:\n" + file_list content = content + "\n\nReceived files:\n" + file_list
if not content: if not content:
logger.warning( self.channel.logger.warning(
"Received empty or unsupported message type: {}", "Received empty or unsupported message type: {}",
chatbot_msg.message_type, chatbot_msg.message_type,
) )
@ -128,7 +127,7 @@ class NanobotDingTalkHandler(CallbackHandler):
or message.data.get("openConversationId") or message.data.get("openConversationId")
) )
logger.info("Received DingTalk message from {} ({}): {}", sender_name, sender_id, content) self.channel.logger.info("Received message from {} ({}): {}", sender_name, sender_id, content)
# Forward to Nanobot via _on_message (non-blocking). # Forward to Nanobot via _on_message (non-blocking).
# Store reference to prevent GC before task completes. # Store reference to prevent GC before task completes.
@ -146,8 +145,8 @@ class NanobotDingTalkHandler(CallbackHandler):
return AckMessage.STATUS_OK, "OK" return AckMessage.STATUS_OK, "OK"
except Exception as e: except Exception:
logger.error("Error processing DingTalk message: {}", e) self.channel.logger.exception("Error processing message")
# Return OK to avoid retry loop from DingTalk server # Return OK to avoid retry loop from DingTalk server
return AckMessage.STATUS_OK, "Error" return AckMessage.STATUS_OK, "Error"
@ -204,20 +203,20 @@ class DingTalkChannel(BaseChannel):
"""Start the DingTalk bot with Stream Mode.""" """Start the DingTalk bot with Stream Mode."""
try: try:
if not DINGTALK_AVAILABLE: if not DINGTALK_AVAILABLE:
logger.error( self.logger.error(
"DingTalk Stream SDK not installed. Run: pip install dingtalk-stream" "Stream SDK not installed. Run: pip install dingtalk-stream"
) )
return return
if not self.config.client_id or not self.config.client_secret: if not self.config.client_id or not self.config.client_secret:
logger.error("DingTalk client_id and client_secret not configured") self.logger.error("client_id and client_secret not configured")
return return
self._running = True self._running = True
self._http = httpx.AsyncClient() self._http = httpx.AsyncClient()
logger.info( self.logger.info(
"Initializing DingTalk Stream Client with Client ID: {}...", "Initializing Stream Client with Client ID: {}...",
self.config.client_id, self.config.client_id,
) )
credential = Credential(self.config.client_id, self.config.client_secret) credential = Credential(self.config.client_id, self.config.client_secret)
@ -227,20 +226,20 @@ class DingTalkChannel(BaseChannel):
handler = NanobotDingTalkHandler(self) handler = NanobotDingTalkHandler(self)
self._client.register_callback_handler(ChatbotMessage.TOPIC, handler) self._client.register_callback_handler(ChatbotMessage.TOPIC, handler)
logger.info("DingTalk bot started with Stream Mode") self.logger.info("bot started with Stream Mode")
# Reconnect loop: restart stream if SDK exits or crashes # Reconnect loop: restart stream if SDK exits or crashes
while self._running: while self._running:
try: try:
await self._client.start() await self._client.start()
except Exception as e: except Exception as e:
logger.warning("DingTalk stream error: {}", e) self.logger.warning("stream error: {}", e)
if self._running: if self._running:
logger.info("Reconnecting DingTalk stream in 5 seconds...") self.logger.info("Reconnecting stream in 5 seconds...")
await asyncio.sleep(5) await asyncio.sleep(5)
except Exception as e: except Exception:
logger.exception("Failed to start DingTalk channel: {}", e) self.logger.exception("Failed to start channel")
async def stop(self) -> None: async def stop(self) -> None:
"""Stop the DingTalk bot.""" """Stop the DingTalk bot."""
@ -266,7 +265,7 @@ class DingTalkChannel(BaseChannel):
} }
if not self._http: if not self._http:
logger.warning("DingTalk HTTP client not initialized, cannot refresh token") self.logger.warning("HTTP client not initialized, cannot refresh token")
return None return None
try: try:
@ -277,8 +276,8 @@ class DingTalkChannel(BaseChannel):
# Expire 60s early to be safe # Expire 60s early to be safe
self._token_expiry = time.time() + int(res_data.get("expireIn", 7200)) - 60 self._token_expiry = time.time() + int(res_data.get("expireIn", 7200)) - 60
return self._access_token return self._access_token
except Exception as e: except Exception:
logger.error("Failed to get DingTalk access token: {}", e) self.logger.exception("Failed to get access token")
return None return None
@staticmethod @staticmethod
@ -317,8 +316,8 @@ class DingTalkChannel(BaseChannel):
) -> tuple[bytes, str, str | None]: ) -> tuple[bytes, str, str | None]:
ext = Path(filename).suffix.lower() ext = Path(filename).suffix.lower()
if ext in self._ZIP_BEFORE_UPLOAD_EXTS or content_type == "text/html": if ext in self._ZIP_BEFORE_UPLOAD_EXTS or content_type == "text/html":
logger.info( self.logger.info(
"DingTalk does not accept raw HTML attachments, zipping {} before upload", "does not accept raw HTML attachments, zipping {} before upload",
filename, filename,
) )
return self._zip_bytes(filename, data) return self._zip_bytes(filename, data)
@ -327,7 +326,7 @@ class DingTalkChannel(BaseChannel):
def _validate_remote_media_url(self, media_ref: str) -> bool: def _validate_remote_media_url(self, media_ref: str) -> bool:
ok, err = validate_url_target(media_ref) ok, err = validate_url_target(media_ref)
if not ok: if not ok:
logger.warning("DingTalk remote media URL blocked ref={} reason={}", media_ref, err) self.logger.warning("remote media URL blocked ref={} reason={}", media_ref, err)
return False return False
return True return True
@ -343,15 +342,15 @@ class DingTalkChannel(BaseChannel):
def _next_remote_media_url(self, current_url: str, location: str | None) -> str | None: def _next_remote_media_url(self, current_url: str, location: str | None) -> str | None:
if not self.config.allow_remote_media_redirects: if not self.config.allow_remote_media_redirects:
logger.warning("DingTalk media download redirect refused ref={}", current_url) self.logger.warning("media download redirect refused ref={}", current_url)
return None return None
if not location: if not location:
logger.warning("DingTalk media download redirect without Location ref={}", current_url) self.logger.warning("media download redirect without Location ref={}", current_url)
return None return None
next_url = urljoin(current_url, location) next_url = urljoin(current_url, location)
if not self._redirect_host_allowed(current_url, next_url): if not self._redirect_host_allowed(current_url, next_url):
logger.warning( self.logger.warning(
"DingTalk media download cross-host redirect refused ref={} next={}", "media download cross-host redirect refused ref={} next={}",
current_url, current_url,
next_url, next_url,
) )
@ -382,8 +381,8 @@ class DingTalkChannel(BaseChannel):
async with stream("GET", current_url, follow_redirects=False) as resp: async with stream("GET", current_url, follow_redirects=False) as resp:
final_ok, final_err = validate_resolved_url(str(resp.url)) final_ok, final_err = validate_resolved_url(str(resp.url))
if not final_ok: if not final_ok:
logger.warning( self.logger.warning(
"DingTalk remote media redirect blocked ref={} final={} reason={}", "remote media redirect blocked ref={} final={} reason={}",
media_ref, media_ref,
resp.url, resp.url,
final_err, final_err,
@ -398,8 +397,8 @@ class DingTalkChannel(BaseChannel):
current_url = next_url current_url = next_url
continue continue
if resp.status_code >= 400: if resp.status_code >= 400:
logger.warning( self.logger.warning(
"DingTalk media download failed status={} ref={}", "media download failed status={} ref={}",
resp.status_code, resp.status_code,
current_url, current_url,
) )
@ -409,15 +408,15 @@ class DingTalkChannel(BaseChannel):
async for chunk in resp.aiter_bytes(): async for chunk in resp.aiter_bytes():
total += len(chunk) total += len(chunk)
if total > DINGTALK_MAX_REMOTE_MEDIA_BYTES: if total > DINGTALK_MAX_REMOTE_MEDIA_BYTES:
logger.warning( self.logger.warning(
"DingTalk media download too large ref={} bytes>{}", "media download too large ref={} bytes>{}",
current_url, current_url,
DINGTALK_MAX_REMOTE_MEDIA_BYTES, DINGTALK_MAX_REMOTE_MEDIA_BYTES,
) )
return None, None return None, None
chunks.append(chunk) chunks.append(chunk)
return b"".join(chunks), (resp.headers.get("content-type") or "") return b"".join(chunks), (resp.headers.get("content-type") or "")
logger.warning("DingTalk media download exceeded redirect limit ref={}", media_ref) self.logger.warning("media download exceeded redirect limit ref={}", media_ref)
return None, None return None, None
current_url = media_ref current_url = media_ref
@ -425,8 +424,8 @@ class DingTalkChannel(BaseChannel):
resp = await self._http.get(current_url, follow_redirects=False) resp = await self._http.get(current_url, follow_redirects=False)
final_ok, final_err = validate_resolved_url(str(getattr(resp, "url", current_url))) final_ok, final_err = validate_resolved_url(str(getattr(resp, "url", current_url)))
if not final_ok: if not final_ok:
logger.warning( self.logger.warning(
"DingTalk remote media redirect blocked ref={} final={} reason={}", "remote media redirect blocked ref={} final={} reason={}",
media_ref, media_ref,
getattr(resp, "url", current_url), getattr(resp, "url", current_url),
final_err, final_err,
@ -441,27 +440,27 @@ class DingTalkChannel(BaseChannel):
current_url = next_url current_url = next_url
continue continue
if resp.status_code >= 400: if resp.status_code >= 400:
logger.warning( self.logger.warning(
"DingTalk media download failed status={} ref={}", "media download failed status={} ref={}",
resp.status_code, resp.status_code,
current_url, current_url,
) )
return None, None return None, None
if len(resp.content) > DINGTALK_MAX_REMOTE_MEDIA_BYTES: if len(resp.content) > DINGTALK_MAX_REMOTE_MEDIA_BYTES:
logger.warning( self.logger.warning(
"DingTalk media download too large ref={} bytes>{}", "media download too large ref={} bytes>{}",
current_url, current_url,
DINGTALK_MAX_REMOTE_MEDIA_BYTES, DINGTALK_MAX_REMOTE_MEDIA_BYTES,
) )
return None, None return None, None
return resp.content, (resp.headers.get("content-type") or "") return resp.content, (resp.headers.get("content-type") or "")
logger.warning("DingTalk media download exceeded redirect limit ref={}", media_ref) self.logger.warning("media download exceeded redirect limit ref={}", media_ref)
return None, None return None, None
except httpx.TransportError as e: except httpx.TransportError:
logger.error("DingTalk media download network error ref={} err={}", media_ref, e) self.logger.exception("media download network error ref={}", media_ref)
raise raise
except Exception as e: except Exception:
logger.error("DingTalk media download error ref={} err={}", media_ref, e) self.logger.exception("media download error ref={}", media_ref)
return None, None return None, None
async def _read_media_bytes( async def _read_media_bytes(
@ -486,13 +485,13 @@ class DingTalkChannel(BaseChannel):
else: else:
local_path = Path(os.path.expanduser(media_ref)) local_path = Path(os.path.expanduser(media_ref))
if not local_path.is_file(): if not local_path.is_file():
logger.warning("DingTalk media file not found: {}", local_path) self.logger.warning("media file not found: {}", local_path)
return None, None, None return None, None, None
data = await asyncio.to_thread(local_path.read_bytes) data = await asyncio.to_thread(local_path.read_bytes)
content_type = mimetypes.guess_type(local_path.name)[0] content_type = mimetypes.guess_type(local_path.name)[0]
return data, local_path.name, content_type return data, local_path.name, content_type
except Exception as e: except Exception:
logger.error("DingTalk media read error ref={} err={}", media_ref, e) self.logger.exception("media read error ref={}", media_ref)
return None, None, None return None, None, None
async def _upload_media( async def _upload_media(
@ -514,23 +513,23 @@ class DingTalkChannel(BaseChannel):
text = resp.text text = resp.text
result = resp.json() if resp.headers.get("content-type", "").startswith("application/json") else {} result = resp.json() if resp.headers.get("content-type", "").startswith("application/json") else {}
if resp.status_code >= 400: if resp.status_code >= 400:
logger.error("DingTalk 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
errcode = result.get("errcode", 0) errcode = result.get("errcode", 0)
if errcode != 0: if errcode != 0:
logger.error("DingTalk 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 = 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:
logger.error("DingTalk media upload missing media_id body={}", text[:500]) self.logger.error("media upload missing media_id body={}", text[:500])
return None return None
return str(media_id) return str(media_id)
except httpx.TransportError as e: except httpx.TransportError:
logger.error("DingTalk media upload network error type={} err={}", media_type, e) self.logger.exception("media upload network error type={}", media_type)
raise raise
except Exception as e: except Exception:
logger.error("DingTalk media upload error type={} err={}", media_type, e) self.logger.exception("media upload error type={}", media_type)
return None return None
async def _send_batch_message( async def _send_batch_message(
@ -541,7 +540,7 @@ class DingTalkChannel(BaseChannel):
msg_param: dict[str, Any], msg_param: dict[str, Any],
) -> bool: ) -> bool:
if not self._http: if not self._http:
logger.warning("DingTalk HTTP client not initialized, cannot send") self.logger.warning("HTTP client not initialized, cannot send")
return False return False
headers = {"x-acs-dingtalk-access-token": token} headers = {"x-acs-dingtalk-access-token": token}
@ -568,7 +567,7 @@ class DingTalkChannel(BaseChannel):
resp = await self._http.post(url, json=payload, headers=headers) resp = await self._http.post(url, json=payload, headers=headers)
body = resp.text body = resp.text
if resp.status_code != 200: if resp.status_code != 200:
logger.error("DingTalk 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 = resp.json()
@ -576,15 +575,15 @@ class DingTalkChannel(BaseChannel):
result = {} result = {}
errcode = result.get("errcode") errcode = result.get("errcode")
if errcode not in (None, 0): if errcode not in (None, 0):
logger.error("DingTalk send api error msgKey={} errcode={} body={}", msg_key, errcode, body[:500]) self.logger.error("send api error msgKey={} errcode={} body={}", msg_key, errcode, body[:500])
return False return False
logger.debug("DingTalk message sent to {} with msgKey={}", chat_id, msg_key) self.logger.debug("message sent to {} with msgKey={}", chat_id, msg_key)
return True return True
except httpx.TransportError as e: except httpx.TransportError:
logger.error("DingTalk network error sending message msgKey={} err={}", msg_key, e) self.logger.exception("network error sending message msgKey={}", msg_key)
raise raise
except Exception as e: except Exception:
logger.error("Error sending DingTalk message msgKey={} err={}", msg_key, e) self.logger.exception("Error sending message msgKey={}", msg_key)
return False return False
async def _send_markdown_text(self, token: str, chat_id: str, content: str) -> bool: async def _send_markdown_text(self, token: str, chat_id: str, content: str) -> bool:
@ -610,11 +609,11 @@ class DingTalkChannel(BaseChannel):
) )
if ok: if ok:
return True return True
logger.warning("DingTalk image url send failed, trying upload fallback: {}", media_ref) self.logger.warning("image url send failed, trying upload fallback: {}", media_ref)
data, filename, content_type = await self._read_media_bytes(media_ref) data, filename, content_type = await self._read_media_bytes(media_ref)
if not data: if not data:
logger.error("DingTalk media read failed: {}", media_ref) self.logger.error("media read failed: {}", media_ref)
return False return False
filename = filename or self._guess_filename(media_ref, upload_type) filename = filename or self._guess_filename(media_ref, upload_type)
@ -646,7 +645,7 @@ class DingTalkChannel(BaseChannel):
) )
if ok: if ok:
return True return True
logger.warning("DingTalk image media_id send failed, falling back to file: {}", media_ref) self.logger.warning("image media_id send failed, falling back to file: {}", media_ref)
return await self._send_batch_message( return await self._send_batch_message(
token, token,
@ -668,7 +667,7 @@ class DingTalkChannel(BaseChannel):
ok = await self._send_media_ref(token, msg.chat_id, media_ref) ok = await self._send_media_ref(token, msg.chat_id, media_ref)
if ok: if ok:
continue continue
logger.error("DingTalk media send failed for {}", media_ref) self.logger.error("media send failed for {}", media_ref)
# Send visible fallback so failures are observable by the user. # Send visible fallback so failures are observable by the user.
filename = self._guess_filename(media_ref, self._guess_upload_type(media_ref)) filename = self._guess_filename(media_ref, self._guess_upload_type(media_ref))
await self._send_markdown_text( await self._send_markdown_text(
@ -691,7 +690,7 @@ class DingTalkChannel(BaseChannel):
permission checks before publishing to the bus. permission checks before publishing to the bus.
""" """
try: try:
logger.info("DingTalk inbound: {} from {}", content, sender_name) self.logger.info("inbound: {} from {}", content, sender_name)
is_group = conversation_type == "2" and conversation_id is_group = conversation_type == "2" and conversation_id
chat_id = f"group:{conversation_id}" if is_group else sender_id chat_id = f"group:{conversation_id}" if is_group else sender_id
await self._handle_message( await self._handle_message(
@ -704,8 +703,8 @@ class DingTalkChannel(BaseChannel):
"conversation_type": conversation_type, "conversation_type": conversation_type,
}, },
) )
except Exception as e: except Exception:
logger.error("Error publishing DingTalk message: {}", e) self.logger.exception("Error publishing message")
async def _download_dingtalk_file( async def _download_dingtalk_file(
self, self,
@ -719,7 +718,7 @@ class DingTalkChannel(BaseChannel):
try: try:
token = await self._get_access_token() token = await self._get_access_token()
if not token or not self._http: if not token or not self._http:
logger.error("DingTalk file download: no token or http client") self.logger.error("file download: no token or http client")
return None return None
# Step 1: Exchange downloadCode for a temporary download URL # Step 1: Exchange downloadCode for a temporary download URL
@ -728,19 +727,19 @@ class DingTalkChannel(BaseChannel):
payload = {"downloadCode": download_code, "robotCode": self.config.client_id} payload = {"downloadCode": download_code, "robotCode": self.config.client_id}
resp = await self._http.post(api_url, json=payload, headers=headers) resp = await self._http.post(api_url, json=payload, headers=headers)
if resp.status_code != 200: if resp.status_code != 200:
logger.error("DingTalk get download URL failed: status={}, body={}", resp.status_code, resp.text) self.logger.error("get download URL failed: status={}, body={}", resp.status_code, resp.text)
return None return None
result = resp.json() result = resp.json()
download_url = result.get("downloadUrl") download_url = result.get("downloadUrl")
if not download_url: if not download_url:
logger.error("DingTalk download URL not found in response: {}", result) self.logger.error("download URL not found in response: {}", result)
return None return None
# Step 2: Download the file content # Step 2: Download the file content
file_resp = await self._http.get(download_url, follow_redirects=True) file_resp = await self._http.get(download_url, follow_redirects=True)
if file_resp.status_code != 200: if file_resp.status_code != 200:
logger.error("DingTalk file download failed: status={}", file_resp.status_code) self.logger.error("file download failed: status={}", file_resp.status_code)
return None return None
# Save to media directory (accessible under workspace) # Save to media directory (accessible under workspace)
@ -748,8 +747,8 @@ class DingTalkChannel(BaseChannel):
download_dir.mkdir(parents=True, exist_ok=True) download_dir.mkdir(parents=True, exist_ok=True)
file_path = download_dir / filename file_path = download_dir / filename
await asyncio.to_thread(file_path.write_bytes, file_resp.content) await asyncio.to_thread(file_path.write_bytes, file_resp.content)
logger.info("DingTalk file saved: {}", file_path) self.logger.info("file saved: {}", file_path)
return str(file_path) return str(file_path)
except Exception as e: except Exception:
logger.error("DingTalk file download error: {}", e) self.logger.exception("file download error")
return None return None

View File

@ -10,7 +10,6 @@ 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
from loguru import logger
from pydantic import Field from pydantic import Field
from nanobot.bus.events import OutboundMessage from nanobot.bus.events import OutboundMessage
@ -86,12 +85,12 @@ if DISCORD_AVAILABLE:
async def on_ready(self) -> None: async def on_ready(self) -> None:
self._channel._bot_user_id = str(self.user.id) if self.user else None self._channel._bot_user_id = str(self.user.id) if self.user else None
logger.info("Discord bot connected as user {}", self._channel._bot_user_id) self._channel.logger.info("bot connected as user {}", self._channel._bot_user_id)
try: try:
synced = await self.tree.sync() synced = await self.tree.sync()
logger.info("Discord app commands synced: {}", len(synced)) self._channel.logger.info("app commands synced: {}", len(synced))
except Exception as e: except Exception as e:
logger.warning("Discord app command sync failed: {}", e) self._channel.logger.warning("app command sync failed: {}", e)
async def on_message(self, message: discord.Message) -> None: async def on_message(self, message: discord.Message) -> None:
await self._channel._handle_discord_message(message) await self._channel._handle_discord_message(message)
@ -111,7 +110,7 @@ if DISCORD_AVAILABLE:
await interaction.response.send_message(text, ephemeral=True) await interaction.response.send_message(text, ephemeral=True)
return True return True
except Exception as e: except Exception as e:
logger.warning("Discord interaction response failed: {}", e) self._channel.logger.warning("interaction response failed: {}", e)
return False return False
async def _resolve_interaction_channel( async def _resolve_interaction_channel(
@ -126,7 +125,7 @@ if DISCORD_AVAILABLE:
try: try:
channel = await self.fetch_channel(channel_id) channel = await self.fetch_channel(channel_id)
except Exception as e: except Exception as e:
logger.warning("Discord interaction channel {} unavailable: {}", channel_id, e) self._channel.logger.warning("interaction channel {} unavailable: {}", channel_id, e)
return None return None
self._channel._remember_channel(channel) self._channel._remember_channel(channel)
return channel return channel
@ -154,7 +153,7 @@ if DISCORD_AVAILABLE:
channel_id = interaction.channel_id channel_id = interaction.channel_id
if channel_id is None: if channel_id is None:
logger.warning("Discord slash command missing channel_id: {}", command_text) self._channel.logger.warning("slash command missing channel_id: {}", command_text)
return return
if not self._channel.is_allowed(sender_id): if not self._channel.is_allowed(sender_id):
@ -226,8 +225,8 @@ if DISCORD_AVAILABLE:
error: app_commands.AppCommandError, error: app_commands.AppCommandError,
) -> None: ) -> None:
command_name = interaction.command.qualified_name if interaction.command else "?" command_name = interaction.command.qualified_name if interaction.command else "?"
logger.warning( self._channel.logger.warning(
"Discord app command failed user={} channel={} cmd={} error={}", "app command failed user={} channel={} cmd={} error={}",
interaction.user.id, interaction.user.id,
interaction.channel_id, interaction.channel_id,
command_name, command_name,
@ -243,7 +242,7 @@ if DISCORD_AVAILABLE:
try: try:
channel = await self.fetch_channel(channel_id) channel = await self.fetch_channel(channel_id)
except Exception as e: except Exception as e:
logger.warning("Discord channel {} unavailable: {}", msg.chat_id, e) self._channel.logger.warning("channel {} unavailable: {}", msg.chat_id, e)
return return
reference, mention_settings = self._build_reply_context(channel, msg.reply_to) reference, mention_settings = self._build_reply_context(channel, msg.reply_to)
@ -281,11 +280,11 @@ if DISCORD_AVAILABLE:
"""Send a file attachment via discord.py.""" """Send a file attachment via discord.py."""
path = Path(file_path) path = Path(file_path)
if not path.is_file(): if not path.is_file():
logger.warning("Discord file not found, skipping: {}", file_path) self._channel.logger.warning("file not found, skipping: {}", file_path)
return False return False
if path.stat().st_size > MAX_ATTACHMENT_BYTES: if path.stat().st_size > MAX_ATTACHMENT_BYTES:
logger.warning("Discord file too large (>20MB), skipping: {}", path.name) self._channel.logger.warning("file too large (>20MB), skipping: {}", path.name)
return False return False
try: try:
@ -294,10 +293,10 @@ if DISCORD_AVAILABLE:
kwargs["reference"] = reference kwargs["reference"] = reference
kwargs["allowed_mentions"] = mention_settings kwargs["allowed_mentions"] = mention_settings
await channel.send(**kwargs) await channel.send(**kwargs)
logger.info("Discord file sent: {}", path.name) self._channel.logger.info("file sent: {}", path.name)
return True return True
except Exception as e: except Exception:
logger.error("Error sending Discord file {}: {}", path.name, e) self._channel.logger.exception("Error sending file {}", path.name)
return False return False
@staticmethod @staticmethod
@ -321,7 +320,7 @@ if DISCORD_AVAILABLE:
try: try:
message_id = int(reply_to) message_id = int(reply_to)
except ValueError: except ValueError:
logger.warning("Invalid Discord 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 channel.get_partial_message(message_id), mention_settings
@ -385,11 +384,11 @@ class DiscordChannel(BaseChannel):
async def start(self) -> None: async def start(self) -> None:
"""Start the Discord client.""" """Start the Discord client."""
if not DISCORD_AVAILABLE: if not DISCORD_AVAILABLE:
logger.error("discord.py not installed. Run: pip install nanobot-ai[discord]") self.logger.error("discord.py not installed. Run: pip install nanobot-ai[discord]")
return return
if not self.config.token: if not self.config.token:
logger.error("Discord bot token not configured") self.logger.error("bot token not configured")
return return
try: try:
@ -407,8 +406,8 @@ class DiscordChannel(BaseChannel):
password=self.config.proxy_password, password=self.config.proxy_password,
) )
elif has_user != has_pass: elif has_user != has_pass:
logger.warning( self.logger.warning(
"Discord proxy auth incomplete: both proxy_username and " "proxy auth incomplete: both proxy_username and "
"proxy_password must be set; ignoring partial credentials", "proxy_password must be set; ignoring partial credentials",
) )
@ -418,21 +417,21 @@ class DiscordChannel(BaseChannel):
proxy=self.config.proxy, proxy=self.config.proxy,
proxy_auth=proxy_auth, proxy_auth=proxy_auth,
) )
except Exception as e: except Exception:
logger.error("Failed to initialize Discord client: {}", e) self.logger.exception("Failed to initialize client")
self._client = None self._client = None
self._running = False self._running = False
return return
self._running = True self._running = True
logger.info("Starting Discord client via discord.py...") self.logger.info("Starting client via discord.py...")
try: try:
await self._client.start(self.config.token) await self._client.start(self.config.token)
except asyncio.CancelledError: except asyncio.CancelledError:
raise raise
except Exception as e: except Exception:
logger.error("Discord client startup failed: {}", e) self.logger.exception("client startup failed")
finally: finally:
self._running = False self._running = False
await self._reset_runtime_state(close_client=True) await self._reset_runtime_state(close_client=True)
@ -446,15 +445,15 @@ class DiscordChannel(BaseChannel):
"""Send a message through Discord using discord.py.""" """Send a message through Discord using discord.py."""
client = self._client client = self._client
if client is None or not client.is_ready(): if client is None or not client.is_ready():
logger.warning("Discord client not ready; dropping outbound message") self.logger.warning("client not ready; dropping outbound message")
return return
is_progress = bool((msg.metadata or {}).get("_progress")) is_progress = bool((msg.metadata or {}).get("_progress"))
try: try:
await client.send_outbound(msg) await client.send_outbound(msg)
except Exception as e: except Exception:
logger.error("Error sending Discord message: {}", e) self.logger.exception("Error sending message")
raise raise
finally: finally:
if not is_progress: if not is_progress:
@ -467,7 +466,7 @@ class DiscordChannel(BaseChannel):
"""Progressive Discord delivery: send once, then edit until the stream ends.""" """Progressive Discord delivery: send once, then edit until the stream ends."""
client = self._client client = self._client
if client is None or not client.is_ready(): if client is None or not client.is_ready():
logger.warning("Discord client not ready; dropping stream delta") self.logger.warning("client not ready; dropping stream delta")
return return
meta = metadata or {} meta = metadata or {}
@ -497,7 +496,7 @@ class DiscordChannel(BaseChannel):
target = await self._resolve_channel(chat_id) target = await self._resolve_channel(chat_id)
if target is None: if target is None:
logger.warning("Discord stream target {} unavailable", chat_id) self.logger.warning("stream target {} unavailable", chat_id)
return return
now = time.monotonic() now = time.monotonic()
@ -506,7 +505,7 @@ class DiscordChannel(BaseChannel):
buf.message = await target.send(content=buf.text) buf.message = await target.send(content=buf.text)
buf.last_edit = now buf.last_edit = now
except Exception as e: except Exception as e:
logger.warning("Discord stream initial send failed: {}", e) self.logger.warning("stream initial send failed: {}", e)
raise raise
return return
@ -517,7 +516,7 @@ class DiscordChannel(BaseChannel):
await buf.message.edit(content=DiscordBotClient._build_chunks(buf.text, [], False)[0]) await buf.message.edit(content=DiscordBotClient._build_chunks(buf.text, [], False)[0])
buf.last_edit = now buf.last_edit = now
except Exception as e: except Exception as e:
logger.warning("Discord stream edit failed: {}", e) self.logger.warning("stream edit failed: {}", e)
raise raise
async def _handle_discord_message(self, message: discord.Message) -> None: async def _handle_discord_message(self, message: discord.Message) -> None:
@ -560,7 +559,7 @@ class DiscordChannel(BaseChannel):
await message.add_reaction(self.config.read_receipt_emoji) await message.add_reaction(self.config.read_receipt_emoji)
self._pending_reactions[channel_id] = message self._pending_reactions[channel_id] = message
except Exception as e: except Exception as e:
logger.debug("Failed to add read receipt reaction: {}", e) self.logger.debug("Failed to add read receipt reaction: {}", e)
# Delayed working indicator (cosmetic — not tied to subagent lifecycle) # Delayed working indicator (cosmetic — not tied to subagent lifecycle)
async def _delayed_working_emoji() -> None: async def _delayed_working_emoji() -> None:
@ -603,7 +602,7 @@ class DiscordChannel(BaseChannel):
try: try:
return await client.fetch_channel(channel_id) return await client.fetch_channel(channel_id)
except Exception as e: except Exception as e:
logger.warning("Discord 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) -> None:
@ -616,12 +615,12 @@ class DiscordChannel(BaseChannel):
try: try:
await buf.message.edit(content=chunks[0]) await buf.message.edit(content=chunks[0])
except Exception as e: except Exception as e:
logger.warning("Discord 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 = getattr(buf.message, "channel", None) or await self._resolve_channel(chat_id)
if target is None: if target is None:
logger.warning("Discord stream follow-up target {} unavailable", chat_id) self.logger.warning("stream follow-up target {} unavailable", chat_id)
self._stream_bufs.pop(chat_id, None) self._stream_bufs.pop(chat_id, None)
return return
@ -673,7 +672,7 @@ class DiscordChannel(BaseChannel):
media_paths.append(str(file_path)) media_paths.append(str(file_path))
markers.append(f"[attachment: {file_path.name}]") markers.append(f"[attachment: {file_path.name}]")
except Exception as e: except Exception as e:
logger.warning("Failed to download Discord attachment: {}", e) self.logger.warning("Failed to download attachment: {}", e)
markers.append(f"[attachment: {filename} - download failed]") markers.append(f"[attachment: {filename} - download failed]")
return media_paths, markers return media_paths, markers
@ -715,8 +714,8 @@ class DiscordChannel(BaseChannel):
if bot_user_id is None and self._client and self._client.user: if bot_user_id is None and self._client and self._client.user:
bot_user_id = str(self._client.user.id) bot_user_id = str(self._client.user.id)
if bot_user_id is None: if bot_user_id is None:
logger.debug( self.logger.debug(
"Discord message in {} ignored (bot identity unavailable)", message.channel.id "message in {} ignored (bot identity unavailable)", message.channel.id
) )
return False return False
@ -729,7 +728,7 @@ class DiscordChannel(BaseChannel):
if self._references_bot_message(message, bot_user_id): if self._references_bot_message(message, bot_user_id):
return True return True
logger.debug("Discord message in {} ignored (bot not mentioned)", message.channel.id) self.logger.debug("message in {} ignored (bot not mentioned)", message.channel.id)
return False return False
return True return True
@ -759,7 +758,7 @@ class DiscordChannel(BaseChannel):
except asyncio.CancelledError: except asyncio.CancelledError:
return return
except Exception as e: except Exception as e:
logger.debug("Discord typing indicator failed for {}: {}", channel_id, e) self.logger.debug("typing indicator failed for {}: {}", channel_id, e)
return return
self._typing_tasks[channel_id] = asyncio.create_task(typing_loop()) self._typing_tasks[channel_id] = asyncio.create_task(typing_loop())
@ -803,6 +802,6 @@ class DiscordChannel(BaseChannel):
try: try:
await self._client.close() await self._client.close()
except Exception as e: except Exception as e:
logger.warning("Discord client close failed: {}", e) self.logger.warning("client close failed: {}", e)
self._client = None self._client = None
self._bot_user_id = None self._bot_user_id = None

View File

@ -128,7 +128,7 @@ class EmailChannel(BaseChannel):
async def start(self) -> None: async def start(self) -> None:
"""Start polling IMAP for inbound emails.""" """Start polling IMAP for inbound emails."""
if not self.config.consent_granted: if not self.config.consent_granted:
logger.warning( self.logger.warning(
"Email channel disabled: consent_granted is false. " "Email channel disabled: consent_granted is false. "
"Set channels.email.consentGranted=true after explicit user permission." "Set channels.email.consentGranted=true after explicit user permission."
) )
@ -139,12 +139,12 @@ class EmailChannel(BaseChannel):
self._running = True self._running = True
if not self.config.verify_dkim and not self.config.verify_spf: if not self.config.verify_dkim and not self.config.verify_spf:
logger.warning( self.logger.warning(
"Email channel: DKIM and SPF verification are both DISABLED. " "DKIM and SPF verification are both DISABLED. "
"Emails with spoofed From headers will be accepted. " "Emails with spoofed From headers will be accepted. "
"Set verify_dkim=true and verify_spf=true for anti-spoofing protection." "Set verify_dkim=true and verify_spf=true for anti-spoofing protection."
) )
logger.info("Starting Email channel (IMAP polling mode)...") self.logger.info("Starting Email channel (IMAP polling mode)...")
poll_seconds = max(5, int(self.config.poll_interval_seconds)) poll_seconds = max(5, int(self.config.poll_interval_seconds))
while self._running: while self._running:
@ -167,8 +167,8 @@ class EmailChannel(BaseChannel):
media=item.get("media") or None, media=item.get("media") or None,
metadata=item.get("metadata", {}), metadata=item.get("metadata", {}),
) )
except Exception as e: except Exception:
logger.error("Email polling error: {}", e) self.logger.exception("Polling error")
await asyncio.sleep(poll_seconds) await asyncio.sleep(poll_seconds)
@ -179,16 +179,16 @@ class EmailChannel(BaseChannel):
async def send(self, msg: OutboundMessage) -> None: async def send(self, msg: OutboundMessage) -> None:
"""Send email via SMTP.""" """Send email via SMTP."""
if not self.config.consent_granted: if not self.config.consent_granted:
logger.warning("Skip email send: consent_granted is false") self.logger.warning("Skip email send: consent_granted is false")
return return
if not self.config.smtp_host: if not self.config.smtp_host:
logger.warning("Email channel SMTP host not configured") self.logger.warning("SMTP host not configured")
return return
to_addr = msg.chat_id.strip() to_addr = msg.chat_id.strip()
if not to_addr: if not to_addr:
logger.warning("Email channel missing recipient address") self.logger.warning("Missing recipient address")
return return
# Determine if this is a reply (recipient has sent us an email before) # Determine if this is a reply (recipient has sent us an email before)
@ -197,7 +197,7 @@ class EmailChannel(BaseChannel):
# autoReplyEnabled only controls automatic replies, not proactive sends # autoReplyEnabled only controls automatic replies, not proactive sends
if is_reply and not self.config.auto_reply_enabled and not force_send: if is_reply and not self.config.auto_reply_enabled and not force_send:
logger.info("Skip automatic email reply to {}: auto_reply_enabled is false", to_addr) self.logger.info("Skip automatic reply to {}: auto_reply_enabled is false", to_addr)
return return
base_subject = self._last_subject_by_chat.get(to_addr, "nanobot reply") base_subject = self._last_subject_by_chat.get(to_addr, "nanobot reply")
@ -220,8 +220,8 @@ class EmailChannel(BaseChannel):
try: try:
await asyncio.to_thread(self._smtp_send, email_msg) await asyncio.to_thread(self._smtp_send, email_msg)
except Exception as e: except Exception:
logger.error("Error sending email to {}: {}", to_addr, e) self.logger.exception("Error sending to {}", to_addr)
raise raise
def _validate_config(self) -> bool: def _validate_config(self) -> bool:
@ -240,7 +240,7 @@ class EmailChannel(BaseChannel):
missing.append("smtp_password") missing.append("smtp_password")
if missing: if missing:
logger.error("Email channel not configured, missing: {}", ', '.join(missing)) self.logger.error("Channel not configured, missing: {}", ', '.join(missing))
return False return False
return True return True
@ -321,7 +321,7 @@ class EmailChannel(BaseChannel):
except Exception as exc: except Exception as exc:
if attempt == 1 or not self._is_stale_imap_error(exc): if attempt == 1 or not self._is_stale_imap_error(exc):
raise raise
logger.warning("Email IMAP connection went stale, retrying once: {}", exc) self.logger.warning("IMAP connection went stale, retrying once: {}", exc)
return messages return messages
@ -348,11 +348,11 @@ class EmailChannel(BaseChannel):
status, _ = client.select(mailbox) status, _ = client.select(mailbox)
except Exception as exc: except Exception as exc:
if self._is_missing_mailbox_error(exc): if self._is_missing_mailbox_error(exc):
logger.warning("Email mailbox unavailable, skipping poll for {}: {}", mailbox, exc) self.logger.warning("Mailbox unavailable, skipping poll for {}: {}", mailbox, exc)
return messages return messages
raise raise
if status != "OK": if status != "OK":
logger.warning("Email mailbox select returned {}, skipping poll for {}", status, mailbox) self.logger.warning("Mailbox select returned {}, skipping poll for {}", status, mailbox)
return messages return messages
status, data = client.search(None, *search_criteria) status, data = client.search(None, *search_criteria)
@ -382,7 +382,7 @@ class EmailChannel(BaseChannel):
if not sender: if not sender:
continue continue
if self._is_self_address(sender): if self._is_self_address(sender):
logger.info("Email from {} ignored: matches bot-owned address", sender) self.logger.info("From {} ignored: matches bot-owned address", sender)
self._remember_processed_uid(uid, dedupe, cycle_uids) self._remember_processed_uid(uid, dedupe, cycle_uids)
if mark_seen: if mark_seen:
client.store(imap_id, "+FLAGS", "\\Seen") client.store(imap_id, "+FLAGS", "\\Seen")
@ -391,22 +391,28 @@ class EmailChannel(BaseChannel):
# --- Anti-spoofing: verify Authentication-Results --- # --- Anti-spoofing: verify Authentication-Results ---
spf_pass, dkim_pass = self._check_authentication_results(parsed) spf_pass, dkim_pass = self._check_authentication_results(parsed)
if self.config.verify_spf and not spf_pass: if self.config.verify_spf and not spf_pass:
logger.warning( self.logger.warning(
"Email from {} rejected: SPF verification failed " "From {} rejected: SPF verification failed "
"(no 'spf=pass' in Authentication-Results header)", "(no 'spf=pass' in Authentication-Results header)",
sender, sender,
) )
self._remember_processed_uid(uid, dedupe, cycle_uids) self._remember_processed_uid(uid, dedupe, cycle_uids)
continue continue
if self.config.verify_dkim and not dkim_pass: if self.config.verify_dkim and not dkim_pass:
logger.warning( self.logger.warning(
"Email from {} rejected: DKIM verification failed " "From {} rejected: DKIM verification failed "
"(no 'dkim=pass' in Authentication-Results header)", "(no 'dkim=pass' in Authentication-Results header)",
sender, sender,
) )
self._remember_processed_uid(uid, dedupe, cycle_uids) self._remember_processed_uid(uid, dedupe, cycle_uids)
continue continue
if not self.is_allowed(sender):
self._remember_processed_uid(uid, dedupe, cycle_uids)
if mark_seen:
client.store(imap_id, "+FLAGS", "\\Seen")
continue
subject = self._decode_header_value(parsed.get("Subject", "")) subject = self._decode_header_value(parsed.get("Subject", ""))
date_value = parsed.get("Date", "") date_value = parsed.get("Date", "")
message_id = parsed.get("Message-ID", "").strip() message_id = parsed.get("Message-ID", "").strip()
@ -635,7 +641,7 @@ class EmailChannel(BaseChannel):
content_type = part.get_content_type() content_type = part.get_content_type()
if not any(fnmatch(content_type, pat) for pat in allowed_types): if not any(fnmatch(content_type, pat) for pat in allowed_types):
logger.debug("Email attachment skipped (type {}): not in allowed list", content_type) logger.debug("Attachment skipped (type {}): not in allowed list", content_type)
continue continue
payload = part.get_payload(decode=True) payload = part.get_payload(decode=True)
@ -643,7 +649,7 @@ class EmailChannel(BaseChannel):
continue continue
if len(payload) > max_size: if len(payload) > max_size:
logger.warning( logger.warning(
"Email attachment skipped: size {} exceeds limit {}", "Attachment skipped: size {} exceeds limit {}",
len(payload), len(payload),
max_size, max_size,
) )
@ -656,9 +662,9 @@ class EmailChannel(BaseChannel):
try: try:
dest.write_bytes(payload) dest.write_bytes(payload)
saved.append(dest) saved.append(dest)
logger.info("Email attachment saved: {}", dest) logger.info("Attachment saved: {}", dest)
except Exception as exc: except Exception as exc:
logger.warning("Failed to save email attachment {}: {}", dest, exc) logger.warning("Failed to save attachment {}: {}", dest, exc)
return saved return saved

View File

@ -15,7 +15,6 @@ from typing import Any, Literal
from lark_oapi.api.im.v1.model import MentionEvent, P2ImMessageReceiveV1 from lark_oapi.api.im.v1.model import MentionEvent, P2ImMessageReceiveV1
from lark_oapi.core.const import FEISHU_DOMAIN, LARK_DOMAIN from lark_oapi.core.const import FEISHU_DOMAIN, LARK_DOMAIN
from loguru import logger
from pydantic import Field from pydantic import Field
from nanobot.bus.events import OutboundMessage from nanobot.bus.events import OutboundMessage
@ -23,6 +22,7 @@ from nanobot.bus.queue import MessageBus
from nanobot.channels.base import BaseChannel from nanobot.channels.base import BaseChannel
from nanobot.config.paths import get_media_dir from nanobot.config.paths import get_media_dir
from nanobot.config.schema import Base from nanobot.config.schema import Base
from nanobot.utils.logging_bridge import redirect_lib_logging
FEISHU_AVAILABLE = importlib.util.find_spec("lark_oapi") is not None FEISHU_AVAILABLE = importlib.util.find_spec("lark_oapi") is not None
@ -320,15 +320,17 @@ class FeishuChannel(BaseChannel):
async def start(self) -> None: async def start(self) -> None:
"""Start the Feishu bot with WebSocket long connection.""" """Start the Feishu bot with WebSocket long connection."""
if not FEISHU_AVAILABLE: if not FEISHU_AVAILABLE:
logger.error("Feishu SDK not installed. Run: pip install lark-oapi") self.logger.error("SDK not installed. Run: pip install lark-oapi")
return return
if not self.config.app_id or not self.config.app_secret: if not self.config.app_id or not self.config.app_secret:
logger.error("Feishu app_id and app_secret not configured") self.logger.error("app_id and app_secret not configured")
return return
import lark_oapi as lark import lark_oapi as lark
redirect_lib_logging("Lark")
self._running = True self._running = True
self._loop = asyncio.get_running_loop() self._loop = asyncio.get_running_loop()
@ -390,7 +392,7 @@ class FeishuChannel(BaseChannel):
try: try:
self._ws_client.start() self._ws_client.start()
except Exception as e: except Exception as e:
logger.warning("Feishu WebSocket error: {}", e) self.logger.warning("WebSocket error: {}", e)
if self._running: if self._running:
time.sleep(5) time.sleep(5)
finally: finally:
@ -404,12 +406,12 @@ class FeishuChannel(BaseChannel):
None, self._fetch_bot_open_id None, self._fetch_bot_open_id
) )
if self._bot_open_id: if self._bot_open_id:
logger.info("Feishu bot open_id: {}", self._bot_open_id) self.logger.info("bot open_id: {}", self._bot_open_id)
else: else:
logger.warning("Could not fetch bot open_id; @mention matching may be inaccurate") self.logger.warning("Could not fetch bot open_id; @mention matching may be inaccurate")
logger.info("Feishu bot started with WebSocket long connection") self.logger.info("bot started with WebSocket long connection")
logger.info("No public IP required - using WebSocket to receive events") self.logger.info("No public IP required - using WebSocket to receive events")
# Keep running until stopped # Keep running until stopped
while self._running: while self._running:
@ -424,7 +426,7 @@ class FeishuChannel(BaseChannel):
Reference: https://github.com/larksuite/oapi-sdk-python/blob/v2_main/lark_oapi/ws/client.py#L86 Reference: https://github.com/larksuite/oapi-sdk-python/blob/v2_main/lark_oapi/ws/client.py#L86
""" """
self._running = False self._running = False
logger.info("Feishu bot stopped") self.logger.info("bot stopped")
def _fetch_bot_open_id(self) -> str | None: def _fetch_bot_open_id(self) -> str | None:
"""Fetch the bot's own open_id via GET /open-apis/bot/v3/info.""" """Fetch the bot's own open_id via GET /open-apis/bot/v3/info."""
@ -445,10 +447,10 @@ class FeishuChannel(BaseChannel):
data = json.loads(response.raw.content) data = json.loads(response.raw.content)
bot = (data.get("data") or data).get("bot") or data.get("bot") or {} bot = (data.get("data") or data).get("bot") or data.get("bot") or {}
return bot.get("open_id") return bot.get("open_id")
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:
logger.warning("Error fetching bot info: {}", e) self.logger.warning("Error fetching bot info: {}", e)
return None return None
@staticmethod @staticmethod
@ -539,15 +541,15 @@ class FeishuChannel(BaseChannel):
response = self._client.im.v1.message_reaction.create(request) response = self._client.im.v1.message_reaction.create(request)
if not response.success(): if not response.success():
logger.warning( self.logger.warning(
"Failed to add reaction: code={}, msg={}", response.code, response.msg "Failed to add reaction: code={}, msg={}", response.code, response.msg
) )
return None return None
else: else:
logger.debug("Added {} reaction to message {}", emoji_type, message_id) self.logger.debug("Added {} reaction to message {}", emoji_type, message_id)
return response.data.reaction_id if response.data else None return response.data.reaction_id if response.data else None
except Exception as e: except Exception as e:
logger.warning("Error adding reaction: {}", e) self.logger.warning("Error adding reaction: {}", e)
return None return None
async def _add_reaction(self, message_id: str, emoji_type: str = "THUMBSUP") -> str | None: async def _add_reaction(self, message_id: str, emoji_type: str = "THUMBSUP") -> str | None:
@ -579,13 +581,13 @@ class FeishuChannel(BaseChannel):
response = self._client.im.v1.message_reaction.delete(request) response = self._client.im.v1.message_reaction.delete(request)
if response.success(): if response.success():
logger.debug("Removed reaction {} from message {}", reaction_id, message_id) self.logger.debug("Removed reaction {} from message {}", reaction_id, message_id)
else: else:
logger.debug( self.logger.debug(
"Failed to remove reaction: code={}, msg={}", response.code, response.msg "Failed to remove reaction: code={}, msg={}", response.code, response.msg
) )
except Exception as e: except Exception as e:
logger.debug("Error removing reaction: {}", e) self.logger.debug("Error removing reaction: {}", e)
async def _remove_reaction(self, message_id: str, reaction_id: str) -> None: async def _remove_reaction(self, message_id: str, reaction_id: str) -> None:
""" """
@ -607,7 +609,7 @@ class FeishuChannel(BaseChannel):
try: try:
task.result() task.result()
except Exception as exc: except Exception as exc:
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) -> None:
"""Callback: store reaction_id after background add-reaction completes.""" """Callback: store reaction_id after background add-reaction completes."""
@ -917,15 +919,15 @@ class FeishuChannel(BaseChannel):
response = self._client.im.v1.image.create(request) response = self._client.im.v1.image.create(request)
if response.success(): if response.success():
image_key = response.data.image_key image_key = response.data.image_key
logger.debug("Uploaded image {}: {}", os.path.basename(file_path), image_key) self.logger.debug("Uploaded image {}: {}", os.path.basename(file_path), image_key)
return image_key return image_key
else: else:
logger.error( self.logger.error(
"Failed to upload image: code={}, msg={}", response.code, response.msg "Failed to upload image: code={}, msg={}", response.code, response.msg
) )
return None return None
except Exception as e: except Exception:
logger.error("Error uploading image {}: {}", file_path, e) self.logger.exception("Error uploading image {}", file_path)
return None return None
def _upload_file_sync(self, file_path: str) -> str | None: def _upload_file_sync(self, file_path: str) -> str | None:
@ -951,15 +953,15 @@ class FeishuChannel(BaseChannel):
response = self._client.im.v1.file.create(request) response = self._client.im.v1.file.create(request)
if response.success(): if response.success():
file_key = response.data.file_key file_key = response.data.file_key
logger.debug("Uploaded file {}: {}", file_name, file_key) self.logger.debug("Uploaded file {}: {}", file_name, file_key)
return file_key return file_key
else: else:
logger.error( self.logger.error(
"Failed to upload file: code={}, msg={}", response.code, response.msg "Failed to upload file: code={}, msg={}", response.code, response.msg
) )
return None return None
except Exception as e: except Exception:
logger.error("Error uploading file {}: {}", file_path, e) self.logger.exception("Error uploading file {}", file_path)
return None return None
def _download_image_sync( def _download_image_sync(
@ -984,12 +986,12 @@ class FeishuChannel(BaseChannel):
file_data = file_data.read() file_data = file_data.read()
return file_data, response.file_name return file_data, response.file_name
else: else:
logger.error( self.logger.error(
"Failed to download image: code={}, msg={}", response.code, response.msg "Failed to download image: code={}, msg={}", response.code, response.msg
) )
return None, None return None, None
except Exception as e: except Exception:
logger.error("Error downloading image {}: {}", image_key, e) self.logger.exception("Error downloading image {}", image_key)
return None, None return None, None
def _download_file_sync( def _download_file_sync(
@ -1018,7 +1020,7 @@ class FeishuChannel(BaseChannel):
file_data = file_data.read() file_data = file_data.read()
return file_data, response.file_name return file_data, response.file_name
else: else:
logger.error( self.logger.error(
"Failed to download {}: code={}, msg={}", "Failed to download {}: code={}, msg={}",
resource_type, resource_type,
response.code, response.code,
@ -1026,7 +1028,7 @@ class FeishuChannel(BaseChannel):
) )
return None, None return None, None
except Exception: except Exception:
logger.exception("Error downloading {} {}", resource_type, file_key) self.logger.exception("Error downloading {} {}", resource_type, file_key)
return None, None return None, None
async def _download_and_save_media( async def _download_and_save_media(
@ -1055,10 +1057,10 @@ class FeishuChannel(BaseChannel):
elif msg_type in ("audio", "file", "media"): elif msg_type in ("audio", "file", "media"):
file_key = content_json.get("file_key") file_key = content_json.get("file_key")
if not file_key: if not file_key:
logger.warning("Feishu {} message missing file_key: {}", msg_type, content_json) self.logger.warning("{} message missing file_key: {}", msg_type, content_json)
return None, f"[{msg_type}: missing file_key]" return None, f"[{msg_type}: missing file_key]"
if not message_id: if not message_id:
logger.warning("Feishu {} message missing message_id", msg_type) self.logger.warning("{} message missing message_id", msg_type)
return None, f"[{msg_type}: missing message_id]" return None, f"[{msg_type}: missing message_id]"
data, filename = await loop.run_in_executor( data, filename = await loop.run_in_executor(
@ -1066,7 +1068,7 @@ class FeishuChannel(BaseChannel):
) )
if not data: if not data:
logger.warning("Feishu {} download failed: file_key={}", msg_type, file_key) self.logger.warning("{} download failed: file_key={}", msg_type, file_key)
return None, f"[{msg_type}: download failed]" return None, f"[{msg_type}: download failed]"
if not filename: if not filename:
@ -1081,8 +1083,9 @@ class FeishuChannel(BaseChannel):
if data and filename: if data and filename:
file_path = media_dir / filename file_path = media_dir / filename
file_path.write_bytes(data) file_path.write_bytes(data)
logger.debug("Downloaded {} to {}", msg_type, file_path) path_str = str(file_path)
return str(file_path), f"[{msg_type}: {filename}]" self.logger.debug("Downloaded {} to {}", msg_type, path_str)
return path_str, f"[{msg_type}: {path_str}]"
return None, f"[{msg_type}: download failed]" return None, f"[{msg_type}: download failed]"
@ -1099,8 +1102,8 @@ class FeishuChannel(BaseChannel):
request = GetMessageRequest.builder().message_id(message_id).build() request = GetMessageRequest.builder().message_id(message_id).build()
response = self._client.im.v1.message.get(request) response = self._client.im.v1.message.get(request)
if not response.success(): if not response.success():
logger.debug( self.logger.debug(
"Feishu: could not fetch parent message {}: code={}, msg={}", "could not fetch parent message {}: code={}, msg={}",
message_id, message_id,
response.code, response.code,
response.msg, response.msg,
@ -1132,7 +1135,7 @@ class FeishuChannel(BaseChannel):
text = text[: self._REPLY_CONTEXT_MAX_LEN] + "..." text = text[: self._REPLY_CONTEXT_MAX_LEN] + "..."
return f"[Reply to: {text}]" return f"[Reply to: {text}]"
except Exception as e: except Exception as e:
logger.debug("Feishu: error fetching parent message {}: {}", message_id, e) self.logger.debug("error fetching parent message {}: {}", message_id, e)
return None return None
def _reply_message_sync(self, parent_message_id: str, msg_type: str, content: str, *, reply_in_thread: bool = False) -> bool: def _reply_message_sync(self, parent_message_id: str, msg_type: str, content: str, *, reply_in_thread: bool = False) -> bool:
@ -1156,18 +1159,18 @@ class FeishuChannel(BaseChannel):
) )
response = self._client.im.v1.message.reply(request) response = self._client.im.v1.message.reply(request)
if not response.success(): if not response.success():
logger.error( self.logger.error(
"Failed to reply to Feishu message {}: code={}, msg={}, log_id={}", "Failed to reply to message {}: code={}, msg={}, log_id={}",
parent_message_id, parent_message_id,
response.code, response.code,
response.msg, response.msg,
response.get_log_id(), response.get_log_id(),
) )
return False return False
logger.debug("Feishu reply sent to message {}", parent_message_id) self.logger.debug("reply sent to message {}", parent_message_id)
return True return True
except Exception as e: except Exception:
logger.error("Error replying to Feishu message {}: {}", parent_message_id, e) self.logger.exception("Error replying to message {}", parent_message_id)
return False return False
def _should_use_reply_in_thread(self, metadata: dict[str, Any]) -> bool: def _should_use_reply_in_thread(self, metadata: dict[str, Any]) -> bool:
@ -1206,8 +1209,8 @@ class FeishuChannel(BaseChannel):
) )
response = self._client.im.v1.message.create(request) response = self._client.im.v1.message.create(request)
if not response.success(): if not response.success():
logger.error( self.logger.error(
"Failed to send Feishu {} message: code={}, msg={}, log_id={}", "Failed to send {} message: code={}, msg={}, log_id={}",
msg_type, msg_type,
response.code, response.code,
response.msg, response.msg,
@ -1215,10 +1218,10 @@ class FeishuChannel(BaseChannel):
) )
return None return None
msg_id = getattr(response.data, "message_id", None) msg_id = getattr(response.data, "message_id", None)
logger.debug("Feishu {} message sent to {}: {}", msg_type, receive_id, msg_id) self.logger.debug("{} message sent to {}: {}", msg_type, receive_id, msg_id)
return msg_id return msg_id
except Exception as e: except Exception:
logger.error("Error sending Feishu {} message: {}", msg_type, e) self.logger.exception("Error sending {} message", msg_type)
return None return None
def _create_streaming_card_sync( def _create_streaming_card_sync(
@ -1258,7 +1261,7 @@ class FeishuChannel(BaseChannel):
) )
response = self._client.cardkit.v1.card.create(request) response = self._client.cardkit.v1.card.create(request)
if not response.success(): if not response.success():
logger.warning( self.logger.warning(
"Failed to create streaming card: code={}, msg={}", response.code, response.msg "Failed to create streaming card: code={}, msg={}", response.code, response.msg
) )
return None return None
@ -1278,12 +1281,12 @@ class FeishuChannel(BaseChannel):
) is not None ) is not None
if sent: if sent:
return card_id return card_id
logger.warning( self.logger.warning(
"Created streaming card {} but failed to send it to {}", card_id, chat_id "Created streaming card {} but failed to send it to {}", card_id, chat_id
) )
return None return None
except Exception as e: except Exception as e:
logger.warning("Error creating streaming card: {}", e) self.logger.warning("Error creating streaming card: {}", e)
return None return None
def _stream_update_text_sync(self, card_id: str, content: str, sequence: int) -> bool: def _stream_update_text_sync(self, card_id: str, content: str, sequence: int) -> bool:
@ -1308,7 +1311,7 @@ class FeishuChannel(BaseChannel):
) )
response = self._client.cardkit.v1.card_element.content(request) response = self._client.cardkit.v1.card_element.content(request)
if not response.success(): if not response.success():
logger.warning( self.logger.warning(
"Failed to stream-update card {}: code={}, msg={}", "Failed to stream-update card {}: code={}, msg={}",
card_id, card_id,
response.code, response.code,
@ -1317,7 +1320,7 @@ class FeishuChannel(BaseChannel):
return False return False
return True return True
except Exception as e: except Exception as e:
logger.warning("Error stream-updating card {}: {}", card_id, e) self.logger.warning("Error stream-updating card {}: {}", card_id, e)
return False return False
def _close_streaming_mode_sync(self, card_id: str, sequence: int) -> bool: def _close_streaming_mode_sync(self, card_id: str, sequence: int) -> bool:
@ -1345,7 +1348,7 @@ class FeishuChannel(BaseChannel):
) )
response = self._client.cardkit.v1.card.settings(request) response = self._client.cardkit.v1.card.settings(request)
if not response.success(): if not response.success():
logger.warning( self.logger.warning(
"Failed to close streaming on card {}: code={}, msg={}", "Failed to close streaming on card {}: code={}, msg={}",
card_id, card_id,
response.code, response.code,
@ -1354,7 +1357,7 @@ class FeishuChannel(BaseChannel):
return False return False
return True return True
except Exception as e: except Exception as e:
logger.warning("Error closing streaming on card {}: {}", card_id, e) self.logger.warning("Error closing streaming on card {}: {}", card_id, e)
return False return False
async def send_delta( async def send_delta(
@ -1415,7 +1418,7 @@ class FeishuChannel(BaseChannel):
buf.sequence, buf.sequence,
) )
return return
logger.warning( self.logger.warning(
"Streaming card {} final update failed, falling back to regular card", "Streaming card {} final update failed, falling back to regular card",
buf.card_id, buf.card_id,
) )
@ -1483,7 +1486,7 @@ class FeishuChannel(BaseChannel):
async def send(self, msg: OutboundMessage) -> None: async def send(self, msg: OutboundMessage) -> None:
"""Send a message through Feishu, including media (images/files) if present.""" """Send a message through Feishu, including media (images/files) if present."""
if not self._client: if not self._client:
logger.warning("Feishu client not initialized") self.logger.warning("client not initialized")
return return
try: try:
@ -1565,7 +1568,7 @@ class FeishuChannel(BaseChannel):
for file_path in msg.media: for file_path in msg.media:
if not os.path.isfile(file_path): if not os.path.isfile(file_path):
logger.warning("Media file not found: {}", file_path) self.logger.warning("Media file not found: {}", file_path)
continue continue
ext = os.path.splitext(file_path)[1].lower() ext = os.path.splitext(file_path)[1].lower()
if ext in self._IMAGE_EXTS: if ext in self._IMAGE_EXTS:
@ -1621,8 +1624,8 @@ class FeishuChannel(BaseChannel):
json.dumps(card, ensure_ascii=False), json.dumps(card, ensure_ascii=False),
) )
except Exception as e: except Exception:
logger.error("Error sending Feishu message: {}", e) self.logger.exception("Error sending message")
raise raise
def _on_message_sync(self, data: Any) -> None: def _on_message_sync(self, data: Any) -> None:
@ -1640,18 +1643,10 @@ class FeishuChannel(BaseChannel):
message = event.message message = event.message
sender = event.sender sender = event.sender
logger.debug("Feishu raw message: {}", message.content) self.logger.debug("raw message: {}", message.content)
logger.debug("Feishu mentions: {}", getattr(message, "mentions", None)) self.logger.debug("mentions: {}", getattr(message, "mentions", None))
# Deduplication check
message_id = message.message_id message_id = message.message_id
if message_id in self._processed_message_ids:
return
self._processed_message_ids[message_id] = None
# Trim cache
while len(self._processed_message_ids) > 1000:
self._processed_message_ids.popitem(last=False)
# Skip bot messages # Skip bot messages
if sender.sender_type == "bot": if sender.sender_type == "bot":
@ -1662,10 +1657,22 @@ class FeishuChannel(BaseChannel):
chat_type = message.chat_type chat_type = message.chat_type
msg_type = message.message_type msg_type = message.message_type
if chat_type == "group" and not self._is_group_message_for_bot(message): if not self.is_allowed(sender_id):
logger.debug("Feishu: skipping group message (not mentioned)")
return return
if chat_type == "group" and not self._is_group_message_for_bot(message):
self.logger.debug("skipping group message (not mentioned)")
return
# Deduplication check
if message_id in self._processed_message_ids:
return
self._processed_message_ids[message_id] = None
# Trim cache
while len(self._processed_message_ids) > 1000:
self._processed_message_ids.popitem(last=False)
# Add reaction (non-blocking — tracked background task) # Add reaction (non-blocking — tracked background task)
task = asyncio.create_task( task = asyncio.create_task(
self._add_reaction(message_id, self.config.react_emoji) self._add_reaction(message_id, self.config.react_emoji)
@ -1779,8 +1786,8 @@ class FeishuChannel(BaseChannel):
session_key=session_key, session_key=session_key,
) )
except Exception as e: except Exception:
logger.error("Error processing Feishu message: {}", e) self.logger.exception("Error processing message")
def _on_reaction_created(self, data: Any) -> None: def _on_reaction_created(self, data: Any) -> None:
"""Ignore reaction events so they do not generate SDK noise.""" """Ignore reaction events so they do not generate SDK noise."""
@ -1796,7 +1803,7 @@ class FeishuChannel(BaseChannel):
def _on_bot_p2p_chat_entered(self, data: Any) -> None: def _on_bot_p2p_chat_entered(self, data: Any) -> None:
"""Ignore p2p-enter events when a user opens a bot chat.""" """Ignore p2p-enter events when a user opens a bot chat."""
logger.debug("Bot entered p2p chat (user opened chat window)") self.logger.debug("Bot entered p2p chat (user opened chat window)")
pass pass
@staticmethod @staticmethod

View File

@ -174,8 +174,8 @@ class ChannelManager:
"""Start a channel and log any exceptions.""" """Start a channel and log any exceptions."""
try: try:
await channel.start() await channel.start()
except Exception as e: except Exception:
logger.error("Failed to start channel {}: {}", name, e) logger.exception("Failed to start channel {}", name)
async def start_all(self) -> None: async def start_all(self) -> None:
"""Start all channels and the outbound dispatcher.""" """Start all channels and the outbound dispatcher."""
@ -230,8 +230,8 @@ class ChannelManager:
try: try:
await channel.stop() await channel.stop()
logger.info("Stopped {} channel", name) logger.info("Stopped {} channel", name)
except Exception as e: except Exception:
logger.error("Error stopping {}: {}", name, e) logger.exception("Error stopping {}", name)
@staticmethod @staticmethod
def _fingerprint_content(content: str) -> str: def _fingerprint_content(content: str) -> str:
@ -392,9 +392,9 @@ class ChannelManager:
raise # Propagate cancellation for graceful shutdown raise # Propagate cancellation for graceful shutdown
except Exception as e: except Exception as e:
if attempt == max_attempts - 1: if attempt == max_attempts - 1:
logger.error( logger.exception(
"Failed to send to {} after {} attempts: {} - {}", "Failed to send to {} after {} attempts",
msg.channel, max_attempts, type(e).__name__, e msg.channel, max_attempts
) )
return return
delay = _SEND_RETRY_DELAYS[min(attempt, len(_SEND_RETRY_DELAYS) - 1)] delay = _SEND_RETRY_DELAYS[min(attempt, len(_SEND_RETRY_DELAYS) - 1)]

View File

@ -2,7 +2,6 @@
import asyncio import asyncio
import json import json
import logging
import mimetypes import mimetypes
import time import time
from contextlib import suppress from contextlib import suppress
@ -10,7 +9,6 @@ from dataclasses import dataclass
from pathlib import Path from pathlib import Path
from typing import Any, Literal, TypeAlias from typing import Any, Literal, TypeAlias
from loguru import logger
from pydantic import Field from pydantic import Field
try: try:
@ -47,6 +45,7 @@ from nanobot.channels.base import BaseChannel
from nanobot.config.paths import get_data_dir, get_media_dir from nanobot.config.paths import get_data_dir, get_media_dir
from nanobot.config.schema import Base from nanobot.config.schema import Base
from nanobot.utils.helpers import safe_filename from nanobot.utils.helpers import safe_filename
from nanobot.utils.logging_bridge import redirect_lib_logging
TYPING_NOTICE_TIMEOUT_MS = 30_000 TYPING_NOTICE_TIMEOUT_MS = 30_000
# Must stay below TYPING_NOTICE_TIMEOUT_MS so the indicator doesn't expire mid-processing. # Must stay below TYPING_NOTICE_TIMEOUT_MS so the indicator doesn't expire mid-processing.
@ -178,28 +177,6 @@ def _build_matrix_text_content(
return content return content
class _NioLoguruHandler(logging.Handler):
"""Route matrix-nio stdlib logs into Loguru."""
def emit(self, record: logging.LogRecord) -> None:
try:
level = logger.level(record.levelname).name
except ValueError:
level = record.levelno
frame, depth = logging.currentframe(), 2
while frame and frame.f_code.co_filename == logging.__file__:
frame, depth = frame.f_back, depth + 1
logger.opt(depth=depth, exception=record.exc_info).log(level, record.getMessage())
def _configure_nio_logging_bridge() -> None:
"""Bridge matrix-nio logs to Loguru (idempotent)."""
nio_logger = logging.getLogger("nio")
if not any(isinstance(h, _NioLoguruHandler) for h in nio_logger.handlers):
nio_logger.handlers = [_NioLoguruHandler()]
nio_logger.propagate = False
class MatrixConfig(Base): class MatrixConfig(Base):
"""Matrix (Element) channel configuration.""" """Matrix (Element) channel configuration."""
@ -259,7 +236,7 @@ class MatrixChannel(BaseChannel):
"""Start Matrix client and begin sync loop.""" """Start Matrix client and begin sync loop."""
self._running = True self._running = True
self._started_at_ms = int(time.time() * 1000) self._started_at_ms = int(time.time() * 1000)
_configure_nio_logging_bridge() redirect_lib_logging("nio", level="WARNING")
self.store_path = get_data_dir() / "matrix-store" self.store_path = get_data_dir() / "matrix-store"
self.store_path.mkdir(parents=True, exist_ok=True) self.store_path.mkdir(parents=True, exist_ok=True)
@ -283,15 +260,15 @@ class MatrixChannel(BaseChannel):
self._register_response_callbacks() self._register_response_callbacks()
if not self.config.e2ee_enabled: if not self.config.e2ee_enabled:
logger.warning("Matrix E2EE disabled; encrypted rooms may be undecryptable.") self.logger.warning("E2EE disabled; encrypted rooms may be undecryptable.")
if self.config.password: if self.config.password:
if self.config.access_token or self.config.device_id: if self.config.access_token or self.config.device_id:
logger.warning("Password-based Matrix login active; access_token and device_id fields will be ignored.") self.logger.warning("Password-based login active; access_token and device_id fields will be ignored.")
create_new_session = True create_new_session = True
if self.session_path.exists(): if self.session_path.exists():
logger.info("Found session.json at {}; attempting to use existing session...", self.session_path) self.logger.info("Found session.json at {}; attempting to use existing session...", self.session_path)
try: try:
with open(self.session_path, "r", encoding="utf-8") as f: with open(self.session_path, "r", encoding="utf-8") as f:
session = json.load(f) session = json.load(f)
@ -299,20 +276,20 @@ class MatrixChannel(BaseChannel):
self.client.access_token = session["access_token"] self.client.access_token = session["access_token"]
self.client.device_id = session["device_id"] self.client.device_id = session["device_id"]
self.client.load_store() self.client.load_store()
logger.info("Successfully loaded from existing session") self.logger.info("Successfully loaded from existing session")
create_new_session = False create_new_session = False
except Exception as e: except Exception as e:
logger.warning("Failed to load from existing session: {}", e) self.logger.warning("Failed to load from existing session: {}", e)
logger.info("Falling back to password login...") self.logger.info("Falling back to password login...")
if create_new_session: if create_new_session:
logger.info("Using password login...") self.logger.info("Using password login...")
resp = await self.client.login(self.config.password) resp = await self.client.login(self.config.password)
if isinstance(resp, LoginResponse): if isinstance(resp, LoginResponse):
logger.info("Logged in using a password; saving details to disk") self.logger.info("Logged in using a password; saving details to disk")
self._write_session_to_disk(resp) self._write_session_to_disk(resp)
else: else:
logger.error("Failed to log in: {}", resp) self.logger.error("Failed to log in: {}", resp)
return return
elif self.config.access_token and self.config.device_id: elif self.config.access_token and self.config.device_id:
@ -321,12 +298,12 @@ class MatrixChannel(BaseChannel):
self.client.access_token = self.config.access_token self.client.access_token = self.config.access_token
self.client.device_id = self.config.device_id self.client.device_id = self.config.device_id
self.client.load_store() self.client.load_store()
logger.info("Successfully loaded from existing session") self.logger.info("Successfully loaded from existing session")
except Exception as e: except Exception as e:
logger.warning("Failed to load from existing session: {}", e) self.logger.warning("Failed to load from existing session: {}", e)
else: else:
logger.warning("Unable to load a Matrix session due to missing password, access_token, or device_id; encryption may not work") self.logger.warning("Unable to load a session due to missing password, access_token, or device_id; encryption may not work")
return return
self._sync_task = asyncio.create_task(self._sync_loop()) self._sync_task = asyncio.create_task(self._sync_loop())
@ -358,9 +335,9 @@ class MatrixChannel(BaseChannel):
try: try:
with open(self.session_path, "w", encoding="utf-8") as f: with open(self.session_path, "w", encoding="utf-8") as f:
json.dump(session, f, indent=2) json.dump(session, f, indent=2)
logger.info("Session saved to {}", self.session_path) self.logger.info("Session saved to {}", self.session_path)
except Exception as e: except Exception as e:
logger.warning("Failed to save session: {}", e) self.logger.warning("Failed to save session: {}", e)
def _is_workspace_path_allowed(self, path: Path) -> bool: def _is_workspace_path_allowed(self, path: Path) -> bool:
"""Check path is inside workspace (when restriction enabled).""" """Check path is inside workspace (when restriction enabled)."""
@ -598,14 +575,14 @@ class MatrixChannel(BaseChannel):
def _log_response_error(self, label: str, response: Any) -> None: def _log_response_error(self, label: str, response: Any) -> None:
"""Log Matrix response errors — auth errors at ERROR level, rest at WARNING.""" """Log Matrix response errors — auth errors at ERROR level, rest at WARNING."""
is_fatal = self._is_fatal_auth_response(response) is_fatal = self._is_fatal_auth_response(response)
(logger.error if is_fatal else logger.warning)("Matrix {} failed: {}", label, response) (self.logger.error if is_fatal else self.logger.warning)("{} failed: {}", label, response)
async def _on_sync_error(self, response: SyncError) -> None: async def _on_sync_error(self, response: SyncError) -> None:
self._log_response_error("sync", response) self._log_response_error("sync", response)
if self._is_fatal_auth_response(response): if self._is_fatal_auth_response(response):
# Auth errors won't recover by retry; stop the sync loop instead of # Auth errors won't recover by retry; stop the sync loop instead of
# spamming the homeserver every 2s (#1851). # spamming the homeserver every 2s (#1851).
logger.error("Matrix authentication failed irrecoverably; stopping sync loop") self.logger.error("Authentication failed irrecoverably; stopping sync loop")
self._running = False self._running = False
if self.client: if self.client:
with suppress(Exception): with suppress(Exception):
@ -625,7 +602,7 @@ class MatrixChannel(BaseChannel):
response = await self.client.room_typing(room_id=room_id, typing_state=typing, response = await self.client.room_typing(room_id=room_id, typing_state=typing,
timeout=TYPING_NOTICE_TIMEOUT_MS) timeout=TYPING_NOTICE_TIMEOUT_MS)
if isinstance(response, RoomTypingError): if isinstance(response, RoomTypingError):
logger.debug("Matrix typing failed for {}: {}", room_id, response) self.logger.debug("typing failed for {}: {}", room_id, response)
async def _start_typing_keepalive(self, room_id: str) -> None: async def _start_typing_keepalive(self, room_id: str) -> None:
"""Start periodic typing refresh (spec-recommended keepalive).""" """Start periodic typing refresh (spec-recommended keepalive)."""
@ -796,7 +773,7 @@ class MatrixChannel(BaseChannel):
return None return None
response = await self.client.download(mxc=mxc_url) response = await self.client.download(mxc=mxc_url)
if isinstance(response, DownloadError): if isinstance(response, DownloadError):
logger.warning("Matrix download failed for {}: {}", mxc_url, response) self.logger.warning("download failed for {}: {}", mxc_url, response)
return None return None
body = getattr(response, "body", None) body = getattr(response, "body", None)
if isinstance(body, (bytes, bytearray)): if isinstance(body, (bytes, bytearray)):
@ -821,7 +798,7 @@ class MatrixChannel(BaseChannel):
try: try:
return decrypt_attachment(ciphertext, key, sha256, iv) return decrypt_attachment(ciphertext, key, sha256, iv)
except (EncryptionError, ValueError, TypeError): except (EncryptionError, ValueError, TypeError):
logger.warning("Matrix decrypt failed for event {}", getattr(event, "event_id", "")) self.logger.warning("decrypt failed for event {}", getattr(event, "event_id", ""))
return None return None
async def _fetch_media_attachment( async def _fetch_media_attachment(

View File

@ -11,7 +11,6 @@ from datetime import datetime
from typing import Any from typing import Any
import httpx import httpx
from loguru import logger
from nanobot.bus.events import OutboundMessage from nanobot.bus.events import OutboundMessage
from nanobot.bus.queue import MessageBus from nanobot.bus.queue import MessageBus
@ -303,7 +302,7 @@ class MochatChannel(BaseChannel):
async def start(self) -> None: async def start(self) -> None:
"""Start Mochat channel workers and websocket connection.""" """Start Mochat channel workers and websocket connection."""
if not self.config.claw_token: if not self.config.claw_token:
logger.error("Mochat claw_token not configured") self.logger.error("claw_token not configured")
return return
self._running = True self._running = True
@ -348,7 +347,7 @@ class MochatChannel(BaseChannel):
async def send(self, msg: OutboundMessage) -> None: async def send(self, msg: OutboundMessage) -> None:
"""Send outbound message to session or panel.""" """Send outbound message to session or panel."""
if not self.config.claw_token: if not self.config.claw_token:
logger.warning("Mochat claw_token missing, skip send") self.logger.warning("claw_token missing, skip send")
return return
parts = ([msg.content.strip()] if msg.content and msg.content.strip() else []) parts = ([msg.content.strip()] if msg.content and msg.content.strip() else [])
@ -360,7 +359,7 @@ class MochatChannel(BaseChannel):
target = resolve_mochat_target(msg.chat_id) target = resolve_mochat_target(msg.chat_id)
if not target.id: if not target.id:
logger.warning("Mochat outbound target is empty") self.logger.warning("outbound target is empty")
return return
is_panel = (target.is_panel or target.id in self._panel_set) and not target.id.startswith("session_") is_panel = (target.is_panel or target.id in self._panel_set) and not target.id.startswith("session_")
@ -371,8 +370,8 @@ class MochatChannel(BaseChannel):
else: else:
await self._api_send("/api/claw/sessions/send", "sessionId", target.id, await self._api_send("/api/claw/sessions/send", "sessionId", target.id,
content, msg.reply_to) content, msg.reply_to)
except Exception as e: except Exception:
logger.error("Failed to send Mochat message: {}", e) self.logger.exception("Failed to send message")
raise raise
# ---- config / init helpers --------------------------------------------- # ---- config / init helpers ---------------------------------------------
@ -395,7 +394,7 @@ class MochatChannel(BaseChannel):
async def _start_socket_client(self) -> bool: async def _start_socket_client(self) -> bool:
if not SOCKETIO_AVAILABLE: if not SOCKETIO_AVAILABLE:
logger.warning("python-socketio not installed, Mochat using polling fallback") self.logger.warning("python-socketio not installed, using polling fallback")
return False return False
serializer = "default" serializer = "default"
@ -403,7 +402,7 @@ class MochatChannel(BaseChannel):
if MSGPACK_AVAILABLE: if MSGPACK_AVAILABLE:
serializer = "msgpack" serializer = "msgpack"
else: else:
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( client = socketio.AsyncClient(
reconnection=True, reconnection=True,
@ -416,7 +415,7 @@ class MochatChannel(BaseChannel):
@client.event @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
logger.info("Mochat websocket connected") self.logger.info("websocket connected")
subscribed = await self._subscribe_all() subscribed = await self._subscribe_all()
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())
@ -426,12 +425,12 @@ class MochatChannel(BaseChannel):
if not self._running: if not self._running:
return return
self._ws_connected = self._ws_ready = False self._ws_connected = self._ws_ready = False
logger.warning("Mochat websocket disconnected") self.logger.warning("websocket disconnected")
await self._ensure_fallback_workers() await self._ensure_fallback_workers()
@client.event @client.event
async def connect_error(data: Any) -> None: async def connect_error(data: Any) -> None:
logger.error("Mochat websocket connect error: {}", data) self.logger.error("websocket connect error: {}", data)
@client.on("claw.session.events") @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:
@ -457,8 +456,8 @@ class MochatChannel(BaseChannel):
wait_timeout=max(1.0, self.config.socket_connect_timeout_ms / 1000.0), wait_timeout=max(1.0, self.config.socket_connect_timeout_ms / 1000.0),
) )
return True return True
except Exception as e: except Exception:
logger.error("Failed to connect Mochat websocket: {}", e) self.logger.exception("Failed to connect websocket")
with suppress(Exception): with suppress(Exception):
await client.disconnect() await client.disconnect()
self._socket = None self._socket = None
@ -493,7 +492,7 @@ class MochatChannel(BaseChannel):
"limit": self.config.watch_limit, "limit": self.config.watch_limit,
}) })
if not ack.get("result"): if not ack.get("result"):
logger.error("Mochat subscribeSessions failed: {}", ack.get('message', 'unknown error')) self.logger.error("subscribeSessions failed: {}", ack.get('message', 'unknown error'))
return False return False
data = ack.get("data") data = ack.get("data")
@ -515,7 +514,7 @@ class MochatChannel(BaseChannel):
return True return True
ack = await self._socket_call("com.claw.im.subscribePanels", {"panelIds": panel_ids}) ack = await self._socket_call("com.claw.im.subscribePanels", {"panelIds": panel_ids})
if not ack.get("result"): if not ack.get("result"):
logger.error("Mochat subscribePanels failed: {}", ack.get('message', 'unknown error')) self.logger.error("subscribePanels failed: {}", ack.get('message', 'unknown error'))
return False return False
return True return True
@ -537,7 +536,7 @@ class MochatChannel(BaseChannel):
try: try:
await self._refresh_targets(subscribe_new=self._ws_ready) await self._refresh_targets(subscribe_new=self._ws_ready)
except Exception as e: except Exception as e:
logger.warning("Mochat refresh failed: {}", e) self.logger.warning("refresh failed: {}", e)
if self._fallback_mode: if self._fallback_mode:
await self._ensure_fallback_workers() await self._ensure_fallback_workers()
@ -551,7 +550,7 @@ class MochatChannel(BaseChannel):
try: try:
response = await self._post_json("/api/claw/sessions/list", {}) response = await self._post_json("/api/claw/sessions/list", {})
except Exception as e: except Exception as e:
logger.warning("Mochat listSessions failed: {}", e) self.logger.warning("listSessions failed: {}", e)
return return
sessions = response.get("sessions") sessions = response.get("sessions")
@ -585,7 +584,7 @@ class MochatChannel(BaseChannel):
try: try:
response = await self._post_json("/api/claw/groups/get", {}) response = await self._post_json("/api/claw/groups/get", {})
except Exception as e: except Exception as e:
logger.warning("Mochat getWorkspaceGroup failed: {}", e) self.logger.warning("getWorkspaceGroup failed: {}", e)
return return
raw_panels = response.get("panels") raw_panels = response.get("panels")
@ -647,7 +646,7 @@ class MochatChannel(BaseChannel):
except asyncio.CancelledError: except asyncio.CancelledError:
break break
except Exception as e: except Exception as e:
logger.warning("Mochat watch fallback error ({}): {}", session_id, e) self.logger.warning("watch fallback error ({}): {}", session_id, e)
await asyncio.sleep(max(0.1, self.config.retry_delay_ms / 1000.0)) await asyncio.sleep(max(0.1, self.config.retry_delay_ms / 1000.0))
async def _panel_poll_worker(self, panel_id: str) -> None: async def _panel_poll_worker(self, panel_id: str) -> None:
@ -674,7 +673,7 @@ class MochatChannel(BaseChannel):
except asyncio.CancelledError: except asyncio.CancelledError:
break break
except Exception as e: except Exception as e:
logger.warning("Mochat panel polling error ({}): {}", panel_id, e) self.logger.warning("panel polling error ({}): {}", panel_id, e)
await asyncio.sleep(sleep_s) await asyncio.sleep(sleep_s)
# ---- inbound event processing ------------------------------------------ # ---- inbound event processing ------------------------------------------
@ -885,7 +884,7 @@ class MochatChannel(BaseChannel):
try: try:
data = json.loads(self._cursor_path.read_text("utf-8")) data = json.loads(self._cursor_path.read_text("utf-8"))
except Exception as e: except Exception as e:
logger.warning("Failed to read Mochat cursor file: {}", e) self.logger.warning("Failed to read cursor file: {}", e)
return return
cursors = data.get("cursors") if isinstance(data, dict) else None cursors = data.get("cursors") if isinstance(data, dict) else None
if isinstance(cursors, dict): if isinstance(cursors, dict):
@ -901,7 +900,7 @@ class MochatChannel(BaseChannel):
"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:
logger.warning("Failed to save Mochat cursor file: {}", e) self.logger.warning("Failed to save cursor file: {}", e)
# ---- HTTP helpers ------------------------------------------------------ # ---- HTTP helpers ------------------------------------------------------

View File

@ -32,7 +32,6 @@ except ImportError: # pragma: no cover
fcntl = None fcntl = None
import httpx import httpx
from loguru import logger
from pydantic import Field from pydantic import Field
from nanobot.bus.events import OutboundMessage from nanobot.bus.events import OutboundMessage
@ -134,16 +133,16 @@ class MSTeamsChannel(BaseChannel):
async def start(self) -> None: async def start(self) -> None:
"""Start the Teams webhook listener.""" """Start the Teams webhook listener."""
if not MSTEAMS_AVAILABLE: if not MSTEAMS_AVAILABLE:
logger.error("PyJWT not installed. Run: pip install nanobot-ai[msteams]") self.logger.error("PyJWT not installed. Run: pip install nanobot-ai[msteams]")
return return
if not self.config.app_id or not self.config.app_password: if not self.config.app_id or not self.config.app_password:
logger.error("MSTeams app_id/app_password not configured") self.logger.error("app_id/app_password not configured")
return return
if not self.config.validate_inbound_auth: if not self.config.validate_inbound_auth:
logger.warning( self.logger.warning(
"MSTeams inbound auth validation was explicitly DISABLED in config. " "Inbound auth validation was explicitly DISABLED in config. "
"Anyone who knows the webhook URL can send messages as any user. " "Anyone who knows the webhook URL can send messages as any user. "
"Only disable this for local development or controlled testing." "Only disable this for local development or controlled testing."
) )
@ -166,7 +165,7 @@ class MSTeamsChannel(BaseChannel):
raw = self.rfile.read(length) if length > 0 else b"{}" raw = self.rfile.read(length) if length > 0 else b"{}"
payload = json.loads(raw.decode("utf-8")) payload = json.loads(raw.decode("utf-8"))
except Exception as e: except Exception as e:
logger.warning("MSTeams invalid request body: {}", e) channel.logger.warning("Invalid request body: {}", e)
self.send_response(400) self.send_response(400)
self.end_headers() self.end_headers()
return return
@ -180,7 +179,7 @@ class MSTeamsChannel(BaseChannel):
) )
fut.result(timeout=15) fut.result(timeout=15)
except Exception as e: except Exception as e:
logger.warning("MSTeams inbound auth validation failed: {}", e) channel.logger.warning("Inbound auth validation failed: {}", e)
self.send_response(401) self.send_response(401)
self.send_header("Content-Type", "application/json") self.send_header("Content-Type", "application/json")
self.end_headers() self.end_headers()
@ -193,7 +192,7 @@ class MSTeamsChannel(BaseChannel):
) )
fut.result(timeout=15) fut.result(timeout=15)
except Exception as e: except Exception as e:
logger.warning("MSTeams activity handling failed: {}", e) channel.logger.warning("Activity handling failed: {}", e)
self.send_response(200) self.send_response(200)
self.send_header("Content-Type", "application/json") self.send_header("Content-Type", "application/json")
@ -211,8 +210,8 @@ class MSTeamsChannel(BaseChannel):
) )
self._server_thread.start() self._server_thread.start()
logger.info( self.logger.info(
"MSTeams webhook listening on http://{}:{}{}", "Webhook listening on http://{}:{}{}",
self.config.host, self.config.host,
self.config.port, self.config.port,
self.config.path, self.config.path,
@ -261,10 +260,10 @@ class MSTeamsChannel(BaseChannel):
try: try:
resp = await self._http.post(base_url, headers=headers, json=payload) resp = await self._http.post(base_url, headers=headers, json=payload)
resp.raise_for_status() resp.raise_for_status()
logger.info("MSTeams message sent to {}", ref.conversation_id) self.logger.info("Message sent to {}", ref.conversation_id)
self._touch_conversation_ref(str(msg.chat_id), persist=True) self._touch_conversation_ref(str(msg.chat_id), persist=True)
except Exception as e: except Exception:
logger.error("MSTeams send failed: {}", e) self.logger.exception("Send failed")
raise raise
async def _handle_activity(self, activity: dict[str, Any]) -> None: async def _handle_activity(self, activity: dict[str, Any]) -> None:
@ -291,18 +290,18 @@ class MSTeamsChannel(BaseChannel):
# DM-only MVP: ignore group/channel traffic for now # DM-only MVP: ignore group/channel traffic for now
if conversation_type and conversation_type not in ("personal", ""): if conversation_type and conversation_type not in ("personal", ""):
logger.debug("MSTeams ignoring non-DM conversation {}", conversation_type) self.logger.debug("Ignoring non-DM conversation {}", conversation_type)
return return
text = self._sanitize_inbound_text(activity) text = self._sanitize_inbound_text(activity)
if not text: if not text:
text = self.config.mention_only_response.strip() text = self.config.mention_only_response.strip()
if not text: if not text:
logger.debug("MSTeams ignoring empty message after Teams text sanitization") self.logger.debug("Ignoring empty message after Teams text sanitization")
return return
if not self.is_allowed(sender_id): if not self.is_allowed(sender_id):
logger.warning( self.logger.warning(
"Access denied for sender {} on channel {}. " "Access denied for sender {} on channel {}. "
"Add them to allowFrom list in config to grant access.", "Add them to allowFrom list in config to grant access.",
sender_id, self.name, sender_id, self.name,
@ -554,7 +553,7 @@ class MSTeamsChannel(BaseChannel):
if isinstance(loaded, dict): if isinstance(loaded, dict):
main_data = loaded main_data = loaded
except Exception as e: except Exception as e:
logger.warning("Failed to load MSTeams conversation refs: {}", e) self.logger.warning("Failed to load conversation refs: {}", e)
if meta_exists: if meta_exists:
try: try:
@ -562,7 +561,7 @@ class MSTeamsChannel(BaseChannel):
if isinstance(loaded_meta, dict): if isinstance(loaded_meta, dict):
meta_data = loaded_meta meta_data = loaded_meta
except Exception as e: except Exception as e:
logger.warning("Failed to load MSTeams conversation refs metadata: {}", e) self.logger.warning("Failed to load conversation refs metadata: {}", e)
return main_data, meta_data, meta_exists return main_data, meta_data, meta_exists
@ -660,8 +659,8 @@ class MSTeamsChannel(BaseChannel):
for key in keys_to_drop: for key in keys_to_drop:
self._conversation_refs.pop(key, None) self._conversation_refs.pop(key, None)
logger.info( self.logger.info(
"MSTeams pruned {} stale/unsupported conversation refs (ttl={} days)", "Pruned {} stale/unsupported conversation refs (ttl={} days)",
len(keys_to_drop), len(keys_to_drop),
ttl_days, ttl_days,
) )
@ -742,7 +741,7 @@ class MSTeamsChannel(BaseChannel):
self._write_json_atomically(self._refs_path, refs_data) self._write_json_atomically(self._refs_path, refs_data)
self._write_json_atomically(self._refs_meta_path, refs_meta) self._write_json_atomically(self._refs_meta_path, refs_meta)
except Exception as e: except Exception as e:
logger.warning("Failed to save MSTeams conversation refs: {}", e) self.logger.warning("Failed to save conversation refs: {}", e)
def _save_refs(self, *, prune: bool = True) -> None: def _save_refs(self, *, prune: bool = True) -> None:
"""Persist conversation references.""" """Persist conversation references."""

View File

@ -38,7 +38,7 @@ from nanobot.bus.events import OutboundMessage
from nanobot.bus.queue import MessageBus from nanobot.bus.queue import MessageBus
from nanobot.channels.base import BaseChannel from nanobot.channels.base import BaseChannel
from nanobot.config.schema import Base from nanobot.config.schema import Base
from nanobot.security.network import validate_url_target from nanobot.utils.logging_bridge import redirect_lib_logging
try: try:
from nanobot.config.paths import get_media_dir from nanobot.config.paths import get_media_dir
@ -187,24 +187,25 @@ class QQChannel(BaseChannel):
root = Path.home() / ".nanobot" / "media" / "qq" root = Path.home() / ".nanobot" / "media" / "qq"
root.mkdir(parents=True, exist_ok=True) root.mkdir(parents=True, exist_ok=True)
logger.info("QQ media directory: {}", str(root)) self.logger.info("media directory: {}", str(root))
return root return root
async def start(self) -> None: async def start(self) -> None:
"""Start the QQ bot with auto-reconnect loop.""" """Start the QQ bot with auto-reconnect loop."""
redirect_lib_logging("botpy", level="WARNING")
if not QQ_AVAILABLE: if not QQ_AVAILABLE:
logger.error("QQ SDK not installed. Run: pip install qq-botpy") self.logger.error("SDK not installed. Run: pip install qq-botpy")
return return
if not self.config.app_id or not self.config.secret: if not self.config.app_id or not self.config.secret:
logger.error("QQ app_id and secret not configured") self.logger.error("app_id and secret not configured")
return return
self._running = True self._running = True
self._http = aiohttp.ClientSession(timeout=aiohttp.ClientTimeout(total=120)) self._http = aiohttp.ClientSession(timeout=aiohttp.ClientTimeout(total=120))
self._client = _make_bot_class(self)() self._client = _make_bot_class(self)()
logger.info("QQ bot started (C2C & Group supported)") self.logger.info("bot started (C2C & Group supported)")
await self._run_bot() await self._run_bot()
async def _run_bot(self) -> None: async def _run_bot(self) -> None:
@ -213,9 +214,9 @@ class QQChannel(BaseChannel):
try: try:
await self._client.start(appid=self.config.app_id, secret=self.config.secret) await self._client.start(appid=self.config.app_id, secret=self.config.secret)
except Exception as e: except Exception as e:
logger.warning("QQ bot error: {}", e) self.logger.warning("bot error: {}", e)
if self._running: if self._running:
logger.info("Reconnecting QQ bot in 5 seconds...") self.logger.info("Reconnecting bot in 5 seconds...")
await asyncio.sleep(5) await asyncio.sleep(5)
async def stop(self) -> None: async def stop(self) -> None:
@ -231,7 +232,7 @@ class QQChannel(BaseChannel):
await self._http.close() await self._http.close()
self._http = None self._http = None
logger.info("QQ bot stopped") self.logger.info("bot stopped")
# --------------------------- # ---------------------------
# Outbound (send) # Outbound (send)
@ -241,7 +242,7 @@ class QQChannel(BaseChannel):
"""Send attachments first, then text.""" """Send attachments first, then text."""
try: try:
if not self._client: if not self._client:
logger.warning("QQ client not initialized") self.logger.warning("client not initialized")
return return
msg_id = msg.metadata.get("message_id") msg_id = msg.metadata.get("message_id")
@ -281,7 +282,7 @@ class QQChannel(BaseChannel):
# Network / transport errors — propagate so ChannelManager can retry # Network / transport errors — propagate so ChannelManager can retry
raise raise
except Exception: except Exception:
logger.exception("Error sending QQ message to chat_id={}", msg.chat_id) self.logger.exception("Error sending message to chat_id={}", msg.chat_id)
async def _send_text_only( async def _send_text_only(
self, self,
@ -339,7 +340,7 @@ class QQChannel(BaseChannel):
srv_send_msg=False, srv_send_msg=False,
) )
if not media_obj: if not media_obj:
logger.error("QQ media upload failed: empty response") self.logger.error("media upload failed: empty response")
return False return False
self._msg_seq += 1 self._msg_seq += 1
@ -360,15 +361,15 @@ class QQChannel(BaseChannel):
media=media_obj, media=media_obj,
) )
logger.info("QQ media sent: {}", filename) self.logger.info("media sent: {}", filename)
return True return True
except (aiohttp.ClientError, OSError) as e: except (aiohttp.ClientError, OSError) as e:
# Network / transport errors — propagate for retry by caller # Network / transport errors — propagate for retry by caller
logger.warning("QQ send media network error filename={} err={}", filename, e) self.logger.warning("send media network error filename={} err={}", filename, e)
raise raise
except Exception as e: except Exception:
# API-level or other non-network errors — return False so send() can fallback # API-level or other non-network errors — return False so send() can fallback
logger.error("QQ send media failed filename={} err={}", filename, e) self.logger.exception("send media failed filename={}", filename)
return False return False
async def _read_media_bytes(self, media_ref: str) -> tuple[bytes | None, str | None]: async def _read_media_bytes(self, media_ref: str) -> tuple[bytes | None, str | None]:
@ -389,19 +390,19 @@ class QQChannel(BaseChannel):
local_path = Path(os.path.expanduser(media_ref)) local_path = Path(os.path.expanduser(media_ref))
if not local_path.is_file(): if not local_path.is_file():
logger.warning("QQ outbound media file not found: {}", str(local_path)) self.logger.warning("outbound media file not found: {}", str(local_path))
return None, None return None, None
data = await asyncio.to_thread(local_path.read_bytes) data = await asyncio.to_thread(local_path.read_bytes)
return data, local_path.name return data, local_path.name
except Exception as e: except Exception as e:
logger.warning("QQ outbound media read error ref={} err={}", media_ref, e) self.logger.warning("outbound media read error ref={} err={}", media_ref, e)
return None, None return None, None
# Remote URL # Remote URL
ok, err = validate_url_target(media_ref) ok, err = validate_url_target(media_ref)
if not ok: if not ok:
logger.warning("QQ outbound media URL validation failed url={} err={}", media_ref, err) self.logger.warning("outbound media URL validation failed url={} err={}", media_ref, err)
return None, None return None, None
if not self._http: if not self._http:
@ -409,8 +410,8 @@ class QQChannel(BaseChannel):
try: try:
async with self._http.get(media_ref, allow_redirects=True) as resp: async with self._http.get(media_ref, allow_redirects=True) as resp:
if resp.status >= 400: if resp.status >= 400:
logger.warning( self.logger.warning(
"QQ outbound media download failed status={} url={}", "outbound media download failed status={} url={}",
resp.status, resp.status,
media_ref, media_ref,
) )
@ -421,7 +422,7 @@ class QQChannel(BaseChannel):
filename = os.path.basename(urlparse(media_ref).path) or "file.bin" filename = os.path.basename(urlparse(media_ref).path) or "file.bin"
return data, filename return data, filename
except Exception as e: except Exception as e:
logger.warning("QQ outbound media download error url={} err={}", media_ref, e) self.logger.warning("outbound media download error url={} err={}", media_ref, e)
return None, None return None, None
# https://github.com/tencent-connect/botpy/issues/198 # https://github.com/tencent-connect/botpy/issues/198
@ -474,24 +475,28 @@ class QQChannel(BaseChannel):
async def _on_message(self, data: C2CMessage | GroupMessage, is_group: bool = False) -> None: async def _on_message(self, data: C2CMessage | GroupMessage, 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:
if data.id in self._processed_ids:
return
self._processed_ids.append(data.id)
if is_group: if is_group:
chat_id = data.group_openid chat_id = data.group_openid
user_id = data.author.member_openid user_id = data.author.member_openid
self._chat_type_cache[chat_id] = "group" chat_type = "group"
else: else:
chat_id = str( chat_id = str(
getattr(data.author, "id", None) getattr(data.author, "id", None)
or getattr(data.author, "user_openid", "unknown") or getattr(data.author, "user_openid", "unknown")
) )
user_id = chat_id user_id = chat_id
self._chat_type_cache[chat_id] = "c2c" chat_type = "c2c"
content = (data.content or "").strip() content = (data.content or "").strip()
if not self.is_allowed(user_id):
return
if data.id in self._processed_ids:
return
self._processed_ids.append(data.id)
self._chat_type_cache[chat_id] = chat_type
# 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 = getattr(data, "attachments", None) or []
@ -521,7 +526,7 @@ class QQChannel(BaseChannel):
content=self.config.ack_message, content=self.config.ack_message,
) )
except Exception: except Exception:
logger.debug("QQ ack message failed for chat_id={}", chat_id) self.logger.debug("ack message failed for chat_id={}", chat_id)
await self._handle_message( await self._handle_message(
sender_id=user_id, sender_id=user_id,
@ -534,7 +539,7 @@ class QQChannel(BaseChannel):
}, },
) )
except Exception: except Exception:
logger.exception("Error handling QQ 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,
@ -553,7 +558,7 @@ class QQChannel(BaseChannel):
filename = getattr(att, "filename", None) or "" filename = getattr(att, "filename", None) or ""
ctype = getattr(att, "content_type", None) or "" ctype = getattr(att, "content_type", None) or ""
logger.info("Downloading file from QQ: {}", filename or url) self.logger.info("Downloading file: {}", filename or url)
local_path = await self._download_to_media_dir_chunked(url, filename_hint=filename) local_path = await self._download_to_media_dir_chunked(url, filename_hint=filename)
att_meta.append( att_meta.append(
@ -604,7 +609,7 @@ class QQChannel(BaseChannel):
allow_redirects=True, allow_redirects=True,
) as resp: ) as resp:
if resp.status != 200: if resp.status != 200:
logger.warning("QQ download failed: status={} url={}", resp.status, url) self.logger.warning("download failed: status={} url={}", resp.status, url)
return None return None
ctype = (resp.headers.get("Content-Type") or "").lower() ctype = (resp.headers.get("Content-Type") or "").lower()
@ -658,8 +663,8 @@ class QQChannel(BaseChannel):
continue continue
downloaded += len(chunk) downloaded += len(chunk)
if downloaded > max_bytes: if downloaded > max_bytes:
logger.warning( self.logger.warning(
"QQ download exceeded max_bytes={} url={} -> abort", "download exceeded max_bytes={} url={} -> abort",
max_bytes, max_bytes,
url, url,
) )
@ -671,11 +676,11 @@ class QQChannel(BaseChannel):
# Atomic rename # Atomic rename
await asyncio.to_thread(os.replace, tmp_path, target) await asyncio.to_thread(os.replace, tmp_path, target)
tmp_path = None # mark as moved tmp_path = None # mark as moved
logger.info("QQ file saved: {}", str(target)) self.logger.info("file saved: {}", str(target))
return str(target) return str(target)
except Exception as e: except Exception:
logger.error("QQ download error: {}", e) self.logger.exception("download error")
return None return None
finally: finally:
# Cleanup partial file # Cleanup partial file

View File

@ -6,7 +6,6 @@ from pathlib import Path
from typing import Any from typing import Any
import httpx import httpx
from loguru import logger
from pydantic import Field from pydantic import Field
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
@ -84,10 +83,10 @@ class SlackChannel(BaseChannel):
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:
logger.error("Slack bot/app token not configured") self.logger.error("bot/app token not configured")
return return
if self.config.mode != "socket": if self.config.mode != "socket":
logger.error("Unsupported Slack mode: {}", self.config.mode) self.logger.error("Unsupported mode: {}", self.config.mode)
return return
self._running = True self._running = True
@ -104,11 +103,11 @@ class SlackChannel(BaseChannel):
try: try:
auth = await self._web_client.auth_test() auth = await self._web_client.auth_test()
self._bot_user_id = auth.get("user_id") self._bot_user_id = auth.get("user_id")
logger.info("Slack 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:
logger.warning("Slack auth_test failed: {}", e) self.logger.warning("auth_test failed: {}", e)
logger.info("Starting Slack Socket Mode client...") self.logger.info("Starting Socket Mode client...")
await self._socket_client.connect() await self._socket_client.connect()
while self._running: while self._running:
@ -121,13 +120,13 @@ class SlackChannel(BaseChannel):
try: try:
await self._socket_client.close() await self._socket_client.close()
except Exception as e: except Exception as e:
logger.warning("Slack socket close failed: {}", e) self.logger.warning("socket close failed: {}", e)
self._socket_client = None self._socket_client = None
async def send(self, msg: OutboundMessage) -> None: async def send(self, msg: OutboundMessage) -> None:
"""Send a message through Slack.""" """Send a message through Slack."""
if not self._web_client: if not self._web_client:
logger.warning("Slack client not running") self.logger.warning("client not running")
return return
try: try:
target_chat_id = await self._resolve_target_chat_id(msg.chat_id) target_chat_id = await self._resolve_target_chat_id(msg.chat_id)
@ -162,16 +161,16 @@ class SlackChannel(BaseChannel):
file=media_path, file=media_path,
thread_ts=thread_ts_param, thread_ts=thread_ts_param,
) )
except Exception as e: except Exception:
logger.error("Failed to upload file {}: {}", media_path, e) self.logger.exception("Failed to upload file {}", media_path)
# Update reaction emoji when the final (non-progress) response is sent # Update reaction emoji when the final (non-progress) response is sent
if not (msg.metadata or {}).get("_progress"): if not (msg.metadata or {}).get("_progress"):
event = slack_meta.get("event", {}) event = slack_meta.get("event", {})
await self._update_react_emoji(origin_chat_id, event.get("ts")) await self._update_react_emoji(origin_chat_id, event.get("ts"))
except Exception as e: except Exception:
logger.error("Error sending Slack message: {}", e) self.logger.exception("Error sending message")
raise raise
async def _resolve_target_chat_id(self, target: str) -> str: async def _resolve_target_chat_id(self, target: str) -> str:
@ -328,8 +327,8 @@ class SlackChannel(BaseChannel):
return return
# Debug: log basic event shape # Debug: log basic event shape
logger.debug( self.logger.debug(
"Slack event: type={} subtype={} user={} channel={} channel_type={} text={}", "event: type={} subtype={} user={} channel={} channel_type={} text={}",
event_type, event_type,
subtype, subtype,
sender_id, sender_id,
@ -371,7 +370,7 @@ class SlackChannel(BaseChannel):
timestamp=event.get("ts"), timestamp=event.get("ts"),
) )
except Exception as e: except Exception as e:
logger.debug("Slack reactions_add failed: {}", e) self.logger.debug("reactions_add failed: {}", e)
# Thread-scoped session key whenever the user is in a real thread # Thread-scoped session key whenever the user is in a real thread
# (raw_thread_ts is set). DM threads get their own session, separate # (raw_thread_ts is set). DM threads get their own session, separate
@ -420,7 +419,7 @@ class SlackChannel(BaseChannel):
session_key=session_key, session_key=session_key,
) )
except Exception: except Exception:
logger.exception("Error handling Slack message from {}", sender_id) self.logger.exception("Error handling message from {}", sender_id)
async def _download_slack_file(self, file_info: dict[str, Any]) -> tuple[str | None, str]: async def _download_slack_file(self, file_info: dict[str, Any]) -> tuple[str | None, str]:
"""Download a Slack private file to the local media directory.""" """Download a Slack private file to the local media directory."""
@ -453,7 +452,7 @@ class SlackChannel(BaseChannel):
path.write_bytes(response.content) path.write_bytes(response.content)
return str(path), marker return str(path), marker
except Exception as e: except Exception as e:
logger.warning("Failed to download Slack file {}: {}", file_id, e) self.logger.warning("Failed to download file {}: {}", file_id, e)
return None, self._download_failure_marker(marker_type, name, "download failed") return None, self._download_failure_marker(marker_type, name, "download failed")
@staticmethod @staticmethod
@ -500,7 +499,7 @@ class SlackChannel(BaseChannel):
session_key=session_key, session_key=session_key,
) )
except Exception: except Exception:
logger.exception("Error handling Slack button click from {}", sender_id) self.logger.exception("Error handling button click from {}", sender_id)
async def _with_thread_context( async def _with_thread_context(
self, self,
@ -537,7 +536,7 @@ class SlackChannel(BaseChannel):
limit=max(1, self.config.thread_context_limit), limit=max(1, self.config.thread_context_limit),
) )
except Exception as e: except Exception as e:
logger.warning("Slack 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(
@ -597,7 +596,7 @@ class SlackChannel(BaseChannel):
timestamp=ts, timestamp=ts,
) )
except Exception as e: except Exception as e:
logger.debug("Slack 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 self._web_client.reactions_add(
@ -606,7 +605,7 @@ class SlackChannel(BaseChannel):
timestamp=ts, timestamp=ts,
) )
except Exception as e: except Exception as e:
logger.debug("Slack done reaction failed: {}", e) self.logger.debug("done reaction failed: {}", e)
def _is_allowed(self, sender_id: str, chat_id: str, channel_type: str) -> bool: def _is_allowed(self, sender_id: str, chat_id: str, channel_type: str) -> bool:
if channel_type == "im": if channel_type == "im":

View File

@ -11,7 +11,6 @@ from dataclasses import dataclass
from pathlib import Path from pathlib import Path
from typing import Any, Literal from typing import Any, Literal
from loguru import logger
from pydantic import Field from pydantic import Field
from telegram import ( from telegram import (
BotCommand, BotCommand,
@ -320,7 +319,7 @@ class TelegramChannel(BaseChannel):
async def start(self) -> None: async def start(self) -> None:
"""Start the Telegram bot with long polling.""" """Start the Telegram bot with long polling."""
if not self.config.token: if not self.config.token:
logger.error("Telegram bot token not configured") self.logger.error("bot token not configured")
return return
self._running = True self._running = True
@ -382,11 +381,11 @@ class TelegramChannel(BaseChannel):
if self.config.inline_keyboards: if self.config.inline_keyboards:
self._app.add_handler(CallbackQueryHandler(self._on_callback_query)) self._app.add_handler(CallbackQueryHandler(self._on_callback_query))
allowed_updates = ["message", "callback_query"] allowed_updates = ["message", "callback_query"]
logger.debug("Telegram inline keyboards enabled") self.logger.debug("inline keyboards enabled")
else: else:
allowed_updates = ["message"] allowed_updates = ["message"]
logger.info("Starting Telegram bot (polling mode)...") self.logger.info("Starting bot (polling mode)...")
# Initialize and start polling # Initialize and start polling
await self._app.initialize() await self._app.initialize()
@ -396,13 +395,13 @@ class TelegramChannel(BaseChannel):
bot_info = await self._app.bot.get_me() bot_info = await self._app.bot.get_me()
self._bot_user_id = getattr(bot_info, "id", None) self._bot_user_id = getattr(bot_info, "id", None)
self._bot_username = getattr(bot_info, "username", None) self._bot_username = getattr(bot_info, "username", None)
logger.info("Telegram bot @{} connected", bot_info.username) self.logger.info("bot @{} connected", bot_info.username)
try: try:
await self._app.bot.set_my_commands(self.BOT_COMMANDS) await self._app.bot.set_my_commands(self.BOT_COMMANDS)
logger.debug("Telegram bot commands registered") self.logger.debug("bot commands registered")
except Exception as e: except Exception as e:
logger.warning("Failed to register bot commands: {}", e) self.logger.warning("Failed to register bot commands: {}", e)
# Start polling (this runs until stopped) # Start polling (this runs until stopped)
await self._app.updater.start_polling( await self._app.updater.start_polling(
@ -429,7 +428,7 @@ class TelegramChannel(BaseChannel):
self._media_group_buffers.clear() self._media_group_buffers.clear()
if self._app: if self._app:
logger.info("Stopping Telegram bot...") self.logger.info("Stopping bot...")
await self._app.updater.stop() await self._app.updater.stop()
await self._app.stop() await self._app.stop()
await self._app.shutdown() await self._app.shutdown()
@ -456,7 +455,7 @@ class TelegramChannel(BaseChannel):
async def send(self, msg: OutboundMessage) -> None: async def send(self, msg: OutboundMessage) -> None:
"""Send a message through Telegram.""" """Send a message through Telegram."""
if not self._app: if not self._app:
logger.warning("Telegram bot not running") self.logger.warning("bot not running")
return return
# Only stop typing indicator and remove reaction for final responses # Only stop typing indicator and remove reaction for final responses
@ -469,7 +468,7 @@ class TelegramChannel(BaseChannel):
try: try:
chat_id = int(msg.chat_id) chat_id = int(msg.chat_id)
except ValueError: except ValueError:
logger.error("Invalid chat_id: {}", msg.chat_id) self.logger.exception("Invalid chat_id: {}", msg.chat_id)
return return
reply_to_message_id = msg.metadata.get("message_id") reply_to_message_id = msg.metadata.get("message_id")
message_thread_id = msg.metadata.get("message_thread_id") message_thread_id = msg.metadata.get("message_thread_id")
@ -533,9 +532,9 @@ class TelegramChannel(BaseChannel):
**extra, **extra,
**send_kwargs, **send_kwargs,
) )
except Exception as e: except Exception:
filename = media_path.rsplit("/", 1)[-1] filename = media_path.rsplit("/", 1)[-1]
logger.error("Failed to send media {}: {}", media_path, e) self.logger.exception("Failed to send media {}", media_path)
await self._app.bot.send_message( await self._app.bot.send_message(
chat_id=chat_id, chat_id=chat_id,
text=f"[Failed to send: {filename}]", text=f"[Failed to send: {filename}]",
@ -572,8 +571,8 @@ class TelegramChannel(BaseChannel):
if attempt == _SEND_MAX_RETRIES: if attempt == _SEND_MAX_RETRIES:
raise raise
delay = _SEND_RETRY_BASE_DELAY * (2 ** (attempt - 1)) delay = _SEND_RETRY_BASE_DELAY * (2 ** (attempt - 1))
logger.warning( self.logger.warning(
"Telegram timeout (attempt {}/{}), retrying in {:.1f}s", "timeout (attempt {}/{}), retrying in {:.1f}s",
attempt, _SEND_MAX_RETRIES, delay, attempt, _SEND_MAX_RETRIES, delay,
) )
await asyncio.sleep(delay) await asyncio.sleep(delay)
@ -581,8 +580,8 @@ class TelegramChannel(BaseChannel):
if attempt == _SEND_MAX_RETRIES: if attempt == _SEND_MAX_RETRIES:
raise raise
delay = float(e.retry_after) delay = float(e.retry_after)
logger.warning( self.logger.warning(
"Telegram 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)
@ -607,7 +606,7 @@ class TelegramChannel(BaseChannel):
**(thread_kwargs or {}), **(thread_kwargs or {}),
) )
except BadRequest as e: except BadRequest as e:
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, self._app.bot.send_message,
@ -617,8 +616,8 @@ class TelegramChannel(BaseChannel):
reply_markup=reply_markup, reply_markup=reply_markup,
**(thread_kwargs or {}), **(thread_kwargs or {}),
) )
except Exception as e2: except Exception:
logger.error("Error sending Telegram message: {}", e2) self.logger.exception("Error sending message")
raise raise
@staticmethod @staticmethod
@ -666,10 +665,10 @@ class TelegramChannel(BaseChannel):
# Network errors (TimedOut, NetworkError) should propagate immediately # Network errors (TimedOut, NetworkError) should propagate immediately
# to avoid doubling connection demand during pool exhaustion. # to avoid doubling connection demand during pool exhaustion.
if self._is_not_modified_error(e): if self._is_not_modified_error(e):
logger.debug("Final stream edit already applied for {}", chat_id) self.logger.debug("Final stream edit already applied for {}", chat_id)
self._stream_bufs.pop(chat_id, None) self._stream_bufs.pop(chat_id, None)
return return
logger.debug("Final stream edit failed (HTML), trying plain: {}", e) self.logger.debug("Final stream edit failed (HTML), trying plain: {}", e)
# Fall back to raw markdown (not HTML) so users don't see raw tags. # Fall back to raw markdown (not HTML) so users don't see raw tags.
primary_plain = split_message(raw_text, TELEGRAM_MAX_MESSAGE_LEN)[0] if len(raw_text) > TELEGRAM_MAX_MESSAGE_LEN else raw_text primary_plain = split_message(raw_text, TELEGRAM_MAX_MESSAGE_LEN)[0] if len(raw_text) > TELEGRAM_MAX_MESSAGE_LEN else raw_text
try: try:
@ -680,9 +679,9 @@ class TelegramChannel(BaseChannel):
) )
except Exception as e2: except Exception as e2:
if self._is_not_modified_error(e2): if self._is_not_modified_error(e2):
logger.debug("Final stream plain edit already applied for {}", chat_id) self.logger.debug("Final stream plain edit already applied for {}", chat_id)
else: else:
logger.warning("Final stream edit failed: {}", e2) self.logger.warning("Final stream edit failed: {}", e2)
raise # Let ChannelManager handle retry raise # Let ChannelManager handle retry
for extra_html_chunk in extra_html_chunks: for extra_html_chunk in extra_html_chunks:
try: try:
@ -724,7 +723,7 @@ class TelegramChannel(BaseChannel):
buf.message_id = sent.message_id buf.message_id = sent.message_id
buf.last_edit = now buf.last_edit = now
except Exception as e: except Exception as e:
logger.warning("Stream initial send failed: {}", e) self.logger.warning("Stream initial send failed: {}", e)
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:
@ -743,7 +742,7 @@ class TelegramChannel(BaseChannel):
if self._is_not_modified_error(e): if self._is_not_modified_error(e):
buf.last_edit = now buf.last_edit = now
return return
logger.warning("Stream edit failed: {}", e) self.logger.warning("Stream edit failed: {}", e)
raise # Let ChannelManager handle retry raise # Let ChannelManager handle retry
async def _flush_stream_overflow( async def _flush_stream_overflow(
@ -769,7 +768,7 @@ class TelegramChannel(BaseChannel):
) )
except Exception as e: except Exception as e:
if not self._is_not_modified_error(e): if not self._is_not_modified_error(e):
logger.warning("Stream overflow edit failed: {}", e) self.logger.warning("Stream overflow edit failed: {}", e)
raise raise
for chunk in chunks[1:-1]: for chunk in chunks[1:-1]:
await self._call_with_retry( await self._call_with_retry(
@ -790,6 +789,8 @@ class TelegramChannel(BaseChannel):
return return
user = update.effective_user user = update.effective_user
if not self.is_allowed(self._sender_id(user)):
return
await update.message.reply_text( await update.message.reply_text(
f"👋 Hi {user.first_name}! I'm nanobot.\n\n" f"👋 Hi {user.first_name}! I'm nanobot.\n\n"
"Send me a message and I'll respond!\n" "Send me a message and I'll respond!\n"
@ -797,8 +798,10 @@ class TelegramChannel(BaseChannel):
) )
async def _on_help(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: async def _on_help(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
"""Handle /help command, bypassing ACL so all users can access it.""" """Handle /help command for allowed users only."""
if not update.message: if not update.message or not update.effective_user:
return
if not self.is_allowed(self._sender_id(update.effective_user)):
return return
await update.message.reply_text(build_help_text()) await update.message.reply_text(build_help_text())
@ -899,12 +902,12 @@ class TelegramChannel(BaseChannel):
if media_type in ("voice", "audio"): if media_type in ("voice", "audio"):
transcription = await self.transcribe_audio(file_path) transcription = await self.transcribe_audio(file_path)
if transcription: if transcription:
logger.info("Transcribed {}: {}...", media_type, transcription[:50]) self.logger.info("Transcribed {}: {}...", media_type, transcription[:50])
return [path_str], [f"[transcription: {transcription}]"] return [path_str], [f"[transcription: {transcription}]"]
return [path_str], [f"[{media_type}: {path_str}]"] return [path_str], [f"[{media_type}: {path_str}]"]
return [path_str], [f"[{media_type}: {path_str}]"] return [path_str], [f"[{media_type}: {path_str}]"]
except Exception as e: except Exception as e:
logger.warning("Failed to download message media: {}", e) self.logger.warning("Failed to download message media: {}", e)
if add_failure_content: if add_failure_content:
return [], [f"[{media_type}: download failed]"] return [], [f"[{media_type}: download failed]"]
return [], [] return [], []
@ -989,6 +992,9 @@ class TelegramChannel(BaseChannel):
return return
message = update.message message = update.message
user = update.effective_user user = update.effective_user
sender_id = self._sender_id(user)
if not self.is_allowed(sender_id):
return
self._remember_thread_context(message) self._remember_thread_context(message)
# Strip @bot_username suffix if present # Strip @bot_username suffix if present
@ -1000,7 +1006,7 @@ class TelegramChannel(BaseChannel):
content = self._normalize_telegram_command(content) content = self._normalize_telegram_command(content)
await self._handle_message( await self._handle_message(
sender_id=self._sender_id(user), sender_id=sender_id,
chat_id=str(message.chat_id), chat_id=str(message.chat_id),
content=content, content=content,
metadata=self._build_message_metadata(message, user), metadata=self._build_message_metadata(message, user),
@ -1016,6 +1022,8 @@ class TelegramChannel(BaseChannel):
user = update.effective_user user = update.effective_user
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):
return
self._remember_thread_context(message) self._remember_thread_context(message)
# Store chat_id for replies # Store chat_id for replies
@ -1047,7 +1055,7 @@ class TelegramChannel(BaseChannel):
media_paths.extend(current_media_paths) media_paths.extend(current_media_paths)
content_parts.extend(current_media_parts) content_parts.extend(current_media_parts)
if current_media_paths: if current_media_paths:
logger.debug("Downloaded message media to {}", current_media_paths[0]) self.logger.debug("Downloaded message media to {}", current_media_paths[0])
# Reply context: text and/or media from the replied-to message # Reply context: text and/or media from the replied-to message
reply = getattr(message, "reply_to_message", None) reply = getattr(message, "reply_to_message", None)
@ -1056,13 +1064,13 @@ class TelegramChannel(BaseChannel):
reply_media, reply_media_parts = await self._download_message_media(reply) reply_media, reply_media_parts = await self._download_message_media(reply)
if reply_media: if reply_media:
media_paths = reply_media + media_paths media_paths = reply_media + media_paths
logger.debug("Attached replied-to media: {}", reply_media[0]) self.logger.debug("Attached replied-to media: {}", reply_media[0])
tag = reply_ctx or (f"[Reply to: {reply_media_parts[0]}]" if reply_media_parts else None) tag = reply_ctx or (f"[Reply to: {reply_media_parts[0]}]" if reply_media_parts else None)
if tag: if tag:
content_parts.insert(0, tag) content_parts.insert(0, tag)
content = "\n".join(content_parts) if content_parts else "[empty message]" content = "\n".join(content_parts) if content_parts else "[empty message]"
logger.debug("Telegram message from {}: {}...", sender_id, content[:50]) self.logger.debug("message from {}: {}...", sender_id, content[:50])
str_chat_id = str(chat_id) str_chat_id = str(chat_id)
metadata = self._build_message_metadata(message, user) metadata = self._build_message_metadata(message, user)
@ -1141,7 +1149,7 @@ class TelegramChannel(BaseChannel):
reaction=[ReactionTypeEmoji(emoji=emoji)], reaction=[ReactionTypeEmoji(emoji=emoji)],
) )
except Exception as e: except Exception as e:
logger.debug("Telegram reaction failed: {}", e) self.logger.debug("reaction failed: {}", e)
async def _remove_reaction(self, chat_id: str, message_id: int) -> None: async def _remove_reaction(self, chat_id: str, message_id: int) -> None:
"""Remove emoji reaction from a message (best-effort, non-blocking).""" """Remove emoji reaction from a message (best-effort, non-blocking)."""
@ -1154,7 +1162,7 @@ class TelegramChannel(BaseChannel):
reaction=[], reaction=[],
) )
except Exception as e: except Exception as e:
logger.debug("Telegram reaction removal failed: {}", e) self.logger.debug("reaction removal failed: {}", e)
async def _typing_loop(self, chat_id: str) -> None: async def _typing_loop(self, chat_id: str) -> None:
"""Repeatedly send 'typing' action until cancelled.""" """Repeatedly send 'typing' action until cancelled."""
@ -1164,7 +1172,7 @@ class TelegramChannel(BaseChannel):
await self._app.bot.send_chat_action(chat_id=int(chat_id), action="typing") await self._app.bot.send_chat_action(chat_id=int(chat_id), action="typing")
await asyncio.sleep(4) await asyncio.sleep(4)
except Exception as e: except Exception as e:
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) -> str:
@ -1184,18 +1192,18 @@ class TelegramChannel(BaseChannel):
"""Keep long-polling network failures to a single readable line.""" """Keep long-polling network failures to a single readable line."""
summary = self._format_telegram_error(exc) summary = self._format_telegram_error(exc)
if isinstance(exc, (NetworkError, TimedOut)): if isinstance(exc, (NetworkError, TimedOut)):
logger.warning("Telegram polling network issue: {}", summary) self.logger.warning("polling network issue: {}", summary)
else: else:
logger.error("Telegram polling error: {}", summary) self.logger.error("polling error: {}", summary)
async def _on_error(self, update: object, context: ContextTypes.DEFAULT_TYPE) -> None: async def _on_error(self, update: object, context: ContextTypes.DEFAULT_TYPE) -> None:
"""Log polling / handler errors instead of silently swallowing them.""" """Log polling / handler errors instead of silently swallowing them."""
summary = self._format_telegram_error(context.error) summary = self._format_telegram_error(context.error)
if isinstance(context.error, (NetworkError, TimedOut)): if isinstance(context.error, (NetworkError, TimedOut)):
logger.warning("Telegram network issue: {}", summary) self.logger.warning("network issue: {}", summary)
else: else:
logger.error("Telegram error: {}", summary) self.logger.error("error: {}", summary)
def _get_extension( def _get_extension(
self, self,
@ -1256,14 +1264,16 @@ class TelegramChannel(BaseChannel):
chat_id = query.message.chat_id if query.message else None 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:
logger.warning("Callback query without chat_id") self.logger.warning("Callback query without chat_id")
return
if not self.is_allowed(sender_id):
return return
button_label = query.data or "" button_label = query.data or ""
await query.answer() await query.answer()
if query.message: if query.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)
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(
sender_id=sender_id, sender_id=sender_id,

View File

@ -32,6 +32,7 @@ from websockets.http11 import Response
from nanobot.bus.events import OutboundMessage from nanobot.bus.events import OutboundMessage
from nanobot.bus.queue import MessageBus from nanobot.bus.queue import MessageBus
from nanobot.channels.base import BaseChannel from nanobot.channels.base import BaseChannel
from nanobot.command.builtin import builtin_command_palette
from nanobot.config.paths import get_media_dir from nanobot.config.paths import get_media_dir
from nanobot.config.schema import Base from nanobot.config.schema import Base
from nanobot.utils.helpers import safe_filename from nanobot.utils.helpers import safe_filename
@ -128,6 +129,17 @@ class WebSocketConfig(Base):
raise ValueError("token_issue_path must differ from path (the WebSocket upgrade path)") raise ValueError("token_issue_path must differ from path (the WebSocket upgrade path)")
return self return self
@model_validator(mode="after")
def wildcard_host_requires_auth(self) -> Self:
if self.host not in ("0.0.0.0", "::"):
return self
if self.token.strip() or self.token_issue_secret.strip():
return self
raise ValueError(
"host is 0.0.0.0 (all interfaces) but neither token nor "
"token_issue_secret is set — set one to prevent unauthenticated access"
)
def _http_json_response(data: dict[str, Any], *, status: int = 200) -> Response: def _http_json_response(data: dict[str, Any], *, status: int = 200) -> Response:
body = json.dumps(data, ensure_ascii=False).encode("utf-8") body = json.dumps(data, ensure_ascii=False).encode("utf-8")
@ -448,7 +460,7 @@ class WebSocketChannel(BaseChannel):
except ConnectionClosed: except ConnectionClosed:
self._cleanup_connection(connection) self._cleanup_connection(connection)
except Exception as e: except Exception as e:
logger.warning("websocket: failed to send {} event: {}", event, e) self.logger.warning("failed to send {} event: {}", event, e)
@classmethod @classmethod
def default_config(cls) -> dict[str, Any]: def default_config(cls) -> dict[str, Any]:
@ -464,7 +476,7 @@ class WebSocketChannel(BaseChannel):
return None return None
if not cert or not key: if not cert or not key:
raise ValueError( raise ValueError(
"websocket: ssl_certfile and ssl_keyfile must both be set for WSS, or both left empty" "ssl_certfile and ssl_keyfile must both be set for WSS, or both left empty"
) )
ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER) ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
ctx.minimum_version = ssl.TLSVersion.TLSv1_2 ctx.minimum_version = ssl.TLSVersion.TLSv1_2
@ -501,14 +513,14 @@ class WebSocketChannel(BaseChannel):
if not _issue_route_secret_matches(request.headers, secret): if not _issue_route_secret_matches(request.headers, secret):
return connection.respond(401, "Unauthorized") return connection.respond(401, "Unauthorized")
else: else:
logger.warning( self.logger.warning(
"websocket: token_issue_path is set but token_issue_secret is empty; " "token_issue_path is set but token_issue_secret is empty; "
"any client can obtain connection tokens — set token_issue_secret for production." "any client can obtain connection tokens — set token_issue_secret for production."
) )
self._purge_expired_issued_tokens() self._purge_expired_issued_tokens()
if len(self._issued_tokens) >= self._MAX_ISSUED_TOKENS: if len(self._issued_tokens) >= self._MAX_ISSUED_TOKENS:
logger.error( self.logger.error(
"websocket: too many outstanding issued tokens ({}), rejecting issuance", "too many outstanding issued tokens ({}), rejecting issuance",
len(self._issued_tokens), len(self._issued_tokens),
) )
return _http_json_response({"error": "too many outstanding tokens"}, status=429) return _http_json_response({"error": "too many outstanding tokens"}, status=429)
@ -531,9 +543,9 @@ class WebSocketChannel(BaseChannel):
if got == issue_expected: if got == issue_expected:
return self._handle_token_issue_http(connection, request) return self._handle_token_issue_http(connection, request)
# 2. WebUI bootstrap: localhost-only, mints tokens for the embedded UI. # 2. WebUI bootstrap: mints tokens for the embedded UI.
if got == "/webui/bootstrap": if got == "/webui/bootstrap":
return self._handle_webui_bootstrap(connection) return self._handle_webui_bootstrap(connection, request)
# 3. REST surface for the embedded UI. # 3. REST surface for the embedded UI.
if got == "/api/sessions": if got == "/api/sessions":
@ -542,6 +554,9 @@ class WebSocketChannel(BaseChannel):
if got == "/api/settings": if got == "/api/settings":
return self._handle_settings(request) return self._handle_settings(request)
if got == "/api/commands":
return self._handle_commands(request)
if got == "/api/settings/update": if got == "/api/settings/update":
return self._handle_settings_update(request) return self._handle_settings_update(request)
@ -606,8 +621,16 @@ class WebSocketChannel(BaseChannel):
if now > expiry: if now > expiry:
self._api_tokens.pop(token_key, None) self._api_tokens.pop(token_key, None)
def _handle_webui_bootstrap(self, connection: Any) -> Response: def _handle_webui_bootstrap(self, connection: Any, request: Any) -> Response:
if not _is_localhost(connection): # When a secret is configured (token_issue_secret or static token),
# validate it regardless of source IP. This secures deployments
# behind a reverse proxy where all connections appear as localhost.
secret = self.config.token_issue_secret.strip() or self.config.token.strip()
if secret:
if not _issue_route_secret_matches(request.headers, secret):
return _http_error(401, "Unauthorized")
elif not _is_localhost(connection):
# No secret configured: only allow localhost (local dev mode).
return _http_error(403, "webui bootstrap is localhost-only") return _http_error(403, "webui bootstrap is localhost-only")
# Cap outstanding tokens to avoid runaway growth from a misbehaving client. # Cap outstanding tokens to avoid runaway growth from a misbehaving client.
self._purge_expired_issued_tokens() self._purge_expired_issued_tokens()
@ -689,6 +712,11 @@ class WebSocketChannel(BaseChannel):
return _http_error(401, "Unauthorized") return _http_error(401, "Unauthorized")
return _http_json_response(self._settings_payload()) return _http_json_response(self._settings_payload())
def _handle_commands(self, request: WsRequest) -> Response:
if not self._check_api_token(request):
return _http_error(401, "Unauthorized")
return _http_json_response({"commands": builtin_command_palette()})
def _handle_settings_update(self, request: WsRequest) -> Response: def _handle_settings_update(self, request: WsRequest) -> Response:
if not self._check_api_token(request): if not self._check_api_token(request):
return _http_error(401, "Unauthorized") return _http_error(401, "Unauthorized")
@ -821,7 +849,7 @@ class WebSocketChannel(BaseChannel):
staged = media_dir / f"{uuid.uuid4().hex[:12]}-{safe_name}" staged = media_dir / f"{uuid.uuid4().hex[:12]}-{safe_name}"
shutil.copyfile(path, staged) shutil.copyfile(path, staged)
except OSError as exc: except OSError as exc:
logger.warning("websocket: failed to stage outbound media {}: {}", path, exc) self.logger.warning("failed to stage outbound media {}: {}", path, exc)
return None return None
signed = self._sign_media_path(staged) signed = self._sign_media_path(staged)
if signed is None: if signed is None:
@ -917,7 +945,7 @@ class WebSocketChannel(BaseChannel):
try: try:
body = candidate.read_bytes() body = candidate.read_bytes()
except OSError as e: except OSError as e:
logger.warning("websocket static: failed to read {}: {}", candidate, e) self.logger.warning("static: failed to read {}: {}", candidate, e)
return _http_error(500, "Internal Server Error") return _http_error(500, "Internal Server Error")
ctype, _ = mimetypes.guess_type(candidate.name) ctype, _ = mimetypes.guess_type(candidate.name)
if ctype is None: if ctype is None:
@ -972,7 +1000,7 @@ class WebSocketChannel(BaseChannel):
async def handler(connection: ServerConnection) -> None: async def handler(connection: ServerConnection) -> None:
await self._connection_loop(connection) await self._connection_loop(connection)
logger.info( self.logger.info(
"WebSocket server listening on {}://{}:{}{}", "WebSocket server listening on {}://{}:{}{}",
scheme, scheme,
self.config.host, self.config.host,
@ -980,7 +1008,7 @@ class WebSocketChannel(BaseChannel):
self.config.path, self.config.path,
) )
if self.config.token_issue_path: if self.config.token_issue_path:
logger.info( self.logger.info(
"WebSocket token issue route: {}://{}:{}{}", "WebSocket token issue route: {}://{}:{}{}",
scheme, scheme,
self.config.host, self.config.host,
@ -1014,7 +1042,7 @@ class WebSocketChannel(BaseChannel):
if not client_id: if not client_id:
client_id = f"anon-{uuid.uuid4().hex[:12]}" client_id = f"anon-{uuid.uuid4().hex[:12]}"
elif len(client_id) > 128: elif len(client_id) > 128:
logger.warning("websocket: client_id too long ({} chars), truncating", len(client_id)) self.logger.warning("client_id too long ({} chars), truncating", len(client_id))
client_id = client_id[:128] client_id = client_id[:128]
default_chat_id = str(uuid.uuid4()) default_chat_id = str(uuid.uuid4())
@ -1039,7 +1067,7 @@ class WebSocketChannel(BaseChannel):
try: try:
raw = raw.decode("utf-8") raw = raw.decode("utf-8")
except UnicodeDecodeError: except UnicodeDecodeError:
logger.warning("websocket: ignoring non-utf8 binary frame") self.logger.warning("ignoring non-utf8 binary frame")
continue continue
envelope = _parse_envelope(raw) envelope = _parse_envelope(raw)
@ -1057,7 +1085,7 @@ class WebSocketChannel(BaseChannel):
metadata={"remote": getattr(connection, "remote_address", None)}, metadata={"remote": getattr(connection, "remote_address", None)},
) )
except Exception as e: except Exception as e:
logger.debug("websocket connection ended: {}", e) self.logger.debug("connection ended: {}", e)
finally: finally:
self._cleanup_connection(connection) self._cleanup_connection(connection)
@ -1097,8 +1125,8 @@ class WebSocketChannel(BaseChannel):
try: try:
Path(p).unlink(missing_ok=True) Path(p).unlink(missing_ok=True)
except OSError as exc: except OSError as exc:
logger.warning( self.logger.warning(
"websocket: failed to unlink partial media {}: {}", p, exc "failed to unlink partial media {}: {}", p, exc
) )
return [], reason return [], reason
@ -1122,7 +1150,7 @@ class WebSocketChannel(BaseChannel):
except FileSizeExceeded: except FileSizeExceeded:
return _abort("size") return _abort("size")
except Exception as exc: except Exception as exc:
logger.warning("websocket: media decode failed: {}", exc) self.logger.warning("media decode failed: {}", exc)
return _abort("decode") return _abort("decode")
if saved is None: if saved is None:
return _abort("decode") return _abort("decode")
@ -1184,12 +1212,15 @@ class WebSocketChannel(BaseChannel):
# Auto-attach on first use so clients can one-shot without a separate attach. # Auto-attach on first use so clients can one-shot without a separate attach.
self._attach(connection, cid) self._attach(connection, cid)
metadata: dict[str, Any] = {"remote": getattr(connection, "remote_address", None)}
if envelope.get("webui") is True:
metadata["webui"] = True
await self._handle_message( await self._handle_message(
sender_id=client_id, sender_id=client_id,
chat_id=cid, chat_id=cid,
content=content, content=content,
media=media_paths or None, media=media_paths or None,
metadata={"remote": getattr(connection, "remote_address", None)}, metadata=metadata,
) )
return return
await self._send_event(connection, "error", detail=f"unknown type: {t!r}") await self._send_event(connection, "error", detail=f"unknown type: {t!r}")
@ -1204,7 +1235,7 @@ class WebSocketChannel(BaseChannel):
try: try:
await self._server_task await self._server_task
except Exception as e: except Exception as e:
logger.warning("websocket: server task error during shutdown: {}", e) self.logger.warning("server task error during shutdown: {}", e)
self._server_task = None self._server_task = None
self._subs.clear() self._subs.clear()
self._conn_chats.clear() self._conn_chats.clear()
@ -1218,16 +1249,23 @@ class WebSocketChannel(BaseChannel):
await connection.send(raw) await connection.send(raw)
except ConnectionClosed: except ConnectionClosed:
self._cleanup_connection(connection) self._cleanup_connection(connection)
logger.warning("websocket{}connection gone", label) self.logger.warning("connection gone{}", label)
except Exception as e: except Exception:
logger.error("websocket{}send failed: {}", label, e) self.logger.exception("send failed{}", label)
raise raise
async def send(self, msg: OutboundMessage) -> None: async def send(self, msg: OutboundMessage) -> None:
# Snapshot the subscriber set so ConnectionClosed cleanups mid-iteration are safe. # Snapshot the subscriber set so ConnectionClosed cleanups mid-iteration are safe.
conns = list(self._subs.get(msg.chat_id, ())) conns = list(self._subs.get(msg.chat_id, ()))
if not conns: if not conns:
logger.warning("websocket: no active subscribers for chat_id={}", msg.chat_id) self.logger.warning("no active subscribers for chat_id={}", msg.chat_id)
return
# Signal that the agent has fully finished processing the current turn.
if msg.metadata.get("_turn_end"):
await self.send_turn_end(msg.chat_id)
return
if msg.metadata.get("_session_updated"):
await self.send_session_updated(msg.chat_id)
return return
text = msg.content text = msg.content
if msg.buttons: if msg.buttons:
@ -1285,3 +1323,23 @@ class WebSocketChannel(BaseChannel):
raw = json.dumps(body, ensure_ascii=False) raw = json.dumps(body, ensure_ascii=False)
for connection in conns: for connection in conns:
await self._safe_send_to(connection, raw, label=" stream ") await self._safe_send_to(connection, raw, label=" stream ")
async def send_turn_end(self, chat_id: str) -> None:
"""Signal that the agent has fully finished processing the current turn."""
conns = list(self._subs.get(chat_id, ()))
if not conns:
return
body: dict[str, Any] = {"event": "turn_end", "chat_id": chat_id}
raw = json.dumps(body, ensure_ascii=False)
for connection in conns:
await self._safe_send_to(connection, raw, label=" turn_end ")
async def send_session_updated(self, chat_id: str) -> None:
"""Notify clients that session metadata changed outside the main turn."""
conns = list(self._subs.get(chat_id, ()))
if not conns:
return
body: dict[str, Any] = {"event": "session_updated", "chat_id": chat_id}
raw = json.dumps(body, ensure_ascii=False)
for connection in conns:
await self._safe_send_to(connection, raw, label=" session_updated ")

View File

@ -10,14 +10,13 @@ from collections import OrderedDict
from pathlib import Path from pathlib import Path
from typing import Any from typing import Any
from loguru import logger from pydantic import Field
from nanobot.bus.events import OutboundMessage from nanobot.bus.events import OutboundMessage
from nanobot.bus.queue import MessageBus from nanobot.bus.queue import MessageBus
from nanobot.channels.base import BaseChannel from nanobot.channels.base import BaseChannel
from nanobot.config.paths import get_media_dir from nanobot.config.paths import get_media_dir
from nanobot.config.schema import Base from nanobot.config.schema import Base
from pydantic import Field
WECOM_AVAILABLE = importlib.util.find_spec("wecom_aibot_sdk") is not None WECOM_AVAILABLE = importlib.util.find_spec("wecom_aibot_sdk") is not None
@ -103,11 +102,11 @@ class WecomChannel(BaseChannel):
async def start(self) -> None: async def start(self) -> None:
"""Start the WeCom bot with WebSocket long connection.""" """Start the WeCom bot with WebSocket long connection."""
if not WECOM_AVAILABLE: if not WECOM_AVAILABLE:
logger.error("WeCom SDK not installed. Run: pip install nanobot-ai[wecom]") self.logger.error("SDK not installed. Run: pip install nanobot-ai[wecom]")
return return
if not self.config.bot_id or not self.config.secret: if not self.config.bot_id or not self.config.secret:
logger.error("WeCom bot_id and secret not configured") self.logger.error("bot_id and secret not configured")
return return
from wecom_aibot_sdk import WSClient, generate_req_id from wecom_aibot_sdk import WSClient, generate_req_id
@ -137,8 +136,8 @@ class WecomChannel(BaseChannel):
self._client.on("message.mixed", self._on_mixed_message) self._client.on("message.mixed", self._on_mixed_message)
self._client.on("event.enter_chat", self._on_enter_chat) self._client.on("event.enter_chat", self._on_enter_chat)
logger.info("WeCom bot starting with WebSocket long connection") self.logger.info("bot starting with WebSocket long connection")
logger.info("No public IP required - using WebSocket to receive events") self.logger.info("No public IP required - using WebSocket to receive events")
# Connect # Connect
await self._client.connect_async() await self._client.connect_async()
@ -152,24 +151,24 @@ class WecomChannel(BaseChannel):
self._running = False self._running = False
if self._client: if self._client:
await self._client.disconnect() await self._client.disconnect()
logger.info("WeCom bot stopped") self.logger.info("bot stopped")
async def _on_connected(self, frame: Any) -> None: async def _on_connected(self, frame: Any) -> None:
"""Handle WebSocket connected event.""" """Handle WebSocket connected event."""
logger.info("WeCom WebSocket connected") self.logger.info("WebSocket connected")
async def _on_authenticated(self, frame: Any) -> None: async def _on_authenticated(self, frame: Any) -> None:
"""Handle authentication success event.""" """Handle authentication success event."""
logger.info("WeCom authenticated successfully") self.logger.info("authenticated successfully")
async def _on_disconnected(self, frame: Any) -> None: async def _on_disconnected(self, frame: Any) -> None:
"""Handle WebSocket disconnected event.""" """Handle WebSocket disconnected event."""
reason = frame.body if hasattr(frame, 'body') else str(frame) reason = frame.body if hasattr(frame, 'body') else str(frame)
logger.warning("WeCom WebSocket disconnected: {}", reason) self.logger.warning("WebSocket disconnected: {}", reason)
async def _on_error(self, frame: Any) -> None: async def _on_error(self, frame: Any) -> None:
"""Handle error event.""" """Handle error event."""
logger.error("WeCom error: {}", frame) self.logger.error("error: {}", frame)
async def _on_text_message(self, frame: Any) -> None: async def _on_text_message(self, frame: Any) -> None:
"""Handle text message.""" """Handle text message."""
@ -204,13 +203,16 @@ class WecomChannel(BaseChannel):
chat_id = body.get("chatid", "") if isinstance(body, dict) else "" chat_id = body.get("chatid", "") if isinstance(body, dict) else ""
if chat_id and not self.is_allowed(chat_id):
return
if chat_id and self.config.welcome_message: if chat_id and self.config.welcome_message:
await self._client.reply_welcome(frame, { await self._client.reply_welcome(frame, {
"msgtype": "text", "msgtype": "text",
"text": {"content": self.config.welcome_message}, "text": {"content": self.config.welcome_message},
}) })
except Exception as e: except Exception:
logger.error("Error handling enter_chat: {}", e) self.logger.exception("Error handling enter_chat")
async def _process_message(self, frame: Any, msg_type: str) -> None: async def _process_message(self, frame: Any, msg_type: str) -> None:
"""Process incoming message and forward to bus.""" """Process incoming message and forward to bus."""
@ -225,7 +227,7 @@ class WecomChannel(BaseChannel):
# Ensure body is a dict # Ensure body is a dict
if not isinstance(body, dict): if not isinstance(body, dict):
logger.warning("Invalid body type: {}", type(body)) self.logger.warning("Invalid body type: {}", type(body))
return return
# Extract message info # Extract message info
@ -233,6 +235,12 @@ class WecomChannel(BaseChannel):
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)
from_info = body.get("from", {})
sender_id = from_info.get("userid", "unknown") if isinstance(from_info, dict) else "unknown"
if not self.is_allowed(sender_id):
return
# Deduplication check # Deduplication check
if msg_id in self._processed_message_ids: if msg_id in self._processed_message_ids:
return return
@ -242,10 +250,6 @@ class WecomChannel(BaseChannel):
while len(self._processed_message_ids) > 1000: while len(self._processed_message_ids) > 1000:
self._processed_message_ids.popitem(last=False) self._processed_message_ids.popitem(last=False)
# Extract sender info from "from" field (SDK format)
from_info = body.get("from", {})
sender_id = from_info.get("userid", "unknown") if isinstance(from_info, dict) else "unknown"
# 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 = body.get("chattype", "single")
@ -345,8 +349,8 @@ class WecomChannel(BaseChannel):
} }
) )
except Exception as e: except Exception:
logger.error("Error processing WeCom message: {}", e) self.logger.exception("Error processing message")
async def _download_and_save_media( async def _download_and_save_media(
self, self,
@ -365,12 +369,12 @@ class WecomChannel(BaseChannel):
data, fname = await self._client.download_file(file_url, aes_key) data, fname = await self._client.download_file(file_url, aes_key)
if not data: if not data:
logger.warning("Failed to download media from WeCom") self.logger.warning("Failed to download media")
return None return None
if len(data) > WECOM_UPLOAD_MAX_BYTES: if len(data) > WECOM_UPLOAD_MAX_BYTES:
logger.warning( self.logger.warning(
"WeCom inbound media too large: {} bytes (max {})", "inbound media too large: {} bytes (max {})",
len(data), len(data),
WECOM_UPLOAD_MAX_BYTES, WECOM_UPLOAD_MAX_BYTES,
) )
@ -383,11 +387,11 @@ class WecomChannel(BaseChannel):
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)
logger.debug("Downloaded {} to {}", media_type, file_path) self.logger.debug("Downloaded {} to {}", media_type, file_path)
return str(file_path) return str(file_path)
except Exception as e: except Exception:
logger.error("Error downloading media: {}", e) self.logger.exception("Error downloading media")
return None return None
async def _upload_media_ws( async def _upload_media_ws(
@ -424,9 +428,9 @@ class WecomChannel(BaseChannel):
# MD5 is used for file integrity only, not cryptographic security # MD5 is used for file integrity only, not cryptographic security
md5_hash = hashlib.md5(data).hexdigest() md5_hash = hashlib.md5(data).hexdigest()
CHUNK_SIZE = 512 * 1024 # 512 KB raw (before base64) chunk_size = 512 * 1024 # 512 KB raw (before base64)
mv = memoryview(data) mv = memoryview(data)
chunk_list = [bytes(mv[i : i + CHUNK_SIZE]) for i in range(0, file_size, CHUNK_SIZE)] chunk_list = [bytes(mv[i : i + chunk_size]) for i in range(0, file_size, chunk_size)]
n_chunks = len(chunk_list) n_chunks = len(chunk_list)
del mv, data del mv, data
@ -440,11 +444,11 @@ class WecomChannel(BaseChannel):
"md5": md5_hash, "md5": md5_hash,
}, "aibot_upload_media_init") }, "aibot_upload_media_init")
if resp.errcode != 0: if resp.errcode != 0:
logger.warning("WeCom upload init failed ({}): {}", resp.errcode, resp.errmsg) self.logger.warning("upload init failed ({}): {}", resp.errcode, resp.errmsg)
return None, None return None, None
upload_id = resp.body.get("upload_id") if resp.body else None upload_id = resp.body.get("upload_id") if resp.body else None
if not upload_id: if not upload_id:
logger.warning("WeCom upload init: no upload_id in response") self.logger.warning("upload init: no upload_id in response")
return None, None return None, None
# Step 2: send chunks # Step 2: send chunks
@ -456,7 +460,7 @@ class WecomChannel(BaseChannel):
"base64_data": base64.b64encode(chunk).decode(), "base64_data": base64.b64encode(chunk).decode(),
}, "aibot_upload_media_chunk") }, "aibot_upload_media_chunk")
if resp.errcode != 0: if resp.errcode != 0:
logger.warning("WeCom upload chunk {} failed ({}): {}", i, resp.errcode, resp.errmsg) self.logger.warning("upload chunk {} failed ({}): {}", i, resp.errcode, resp.errmsg)
return None, None return None, None
# Step 3: finish # Step 3: finish
@ -465,29 +469,29 @@ class WecomChannel(BaseChannel):
"upload_id": upload_id, "upload_id": upload_id,
}, "aibot_upload_media_finish") }, "aibot_upload_media_finish")
if resp.errcode != 0: if resp.errcode != 0:
logger.warning("WeCom upload finish failed ({}): {}", resp.errcode, resp.errmsg) self.logger.warning("upload finish failed ({}): {}", resp.errcode, resp.errmsg)
return None, None return None, None
media_id = resp.body.get("media_id") if resp.body else None media_id = resp.body.get("media_id") if resp.body else None
if not media_id: if not media_id:
logger.warning("WeCom upload finish: no media_id in response body={}", resp.body) self.logger.warning("upload finish: no media_id in response body={}", resp.body)
return None, None return None, None
suffix = "..." if len(media_id) > 16 else "" suffix = "..." if len(media_id) > 16 else ""
logger.debug("WeCom uploaded {} ({}) → media_id={}", fname, media_type, media_id[:16] + suffix) self.logger.debug("uploaded {} ({}) → media_id={}", fname, media_type, media_id[:16] + suffix)
return media_id, media_type return media_id, media_type
except ValueError as e: except ValueError as e:
logger.warning("WeCom upload skipped for {}: {}", file_path, e) self.logger.warning("upload skipped for {}: {}", file_path, e)
return None, None return None, None
except Exception as e: except Exception:
logger.error("WeCom _upload_media_ws error for {}: {}", file_path, e) self.logger.exception("_upload_media_ws error for {}", file_path)
return None, None return None, None
async def send(self, msg: OutboundMessage) -> None: async def send(self, msg: OutboundMessage) -> None:
"""Send a message through WeCom.""" """Send a message through WeCom."""
if not self._client: if not self._client:
logger.warning("WeCom client not initialized") self.logger.warning("client not initialized")
return return
try: try:
@ -500,7 +504,7 @@ class WecomChannel(BaseChannel):
# Send media files via WebSocket upload # Send media files via WebSocket upload
for file_path in msg.media or []: for file_path in msg.media or []:
if not os.path.isfile(file_path): if not os.path.isfile(file_path):
logger.warning("WeCom media file not found: {}", file_path) self.logger.warning("media file not found: {}", file_path)
continue continue
media_id, media_type = await self._upload_media_ws(self._client, file_path) media_id, media_type = await self._upload_media_ws(self._client, file_path)
if media_id: if media_id:
@ -514,7 +518,7 @@ class WecomChannel(BaseChannel):
"msgtype": media_type, "msgtype": media_type,
media_type: {"media_id": media_id}, media_type: {"media_id": media_id},
}) })
logger.debug("WeCom sent {}{}", media_type, msg.chat_id) self.logger.debug("sent {}{}", media_type, msg.chat_id)
else: else:
content += f"\n[file upload failed: {os.path.basename(file_path)}]" content += f"\n[file upload failed: {os.path.basename(file_path)}]"
@ -532,8 +536,8 @@ class WecomChannel(BaseChannel):
content, content,
finish=not is_progress, finish=not is_progress,
) )
logger.debug( self.logger.debug(
"WeCom {} sent to {}", "{} sent to {}",
"progress" if is_progress else "message", "progress" if is_progress else "message",
msg.chat_id, msg.chat_id,
) )
@ -543,7 +547,7 @@ class WecomChannel(BaseChannel):
"msgtype": "markdown", "msgtype": "markdown",
"markdown": {"content": content}, "markdown": {"content": content},
}) })
logger.info("WeCom proactive send to {}", msg.chat_id) self.logger.info("proactive send to {}", msg.chat_id)
except Exception: except Exception:
logger.exception("Error sending WeCom message to chat_id={}", msg.chat_id) self.logger.exception("Error sending message to chat_id={}", msg.chat_id)

View File

@ -366,14 +366,14 @@ class WeixinChannel(BaseChannel):
if base_url: if base_url:
self.config.base_url = base_url self.config.base_url = base_url
self._save_state() self._save_state()
logger.info( self.logger.info(
"WeChat login successful! bot_id={} user_id={}", "login successful! bot_id={} user_id={}",
bot_id, bot_id,
user_id, user_id,
) )
return True return True
else: else:
logger.error("Login confirmed but no bot_token in response") self.logger.error("Login confirmed but no bot_token in response")
return False return False
elif status == "scaned_but_redirect": elif status == "scaned_but_redirect":
redirect_host = str(status_data.get("redirect_host", "") or "").strip() redirect_host = str(status_data.get("redirect_host", "") or "").strip()
@ -387,7 +387,7 @@ class WeixinChannel(BaseChannel):
elif status == "expired": elif status == "expired":
refresh_count += 1 refresh_count += 1
if refresh_count > MAX_QR_REFRESH_COUNT: if refresh_count > MAX_QR_REFRESH_COUNT:
logger.warning( self.logger.warning(
"QR code expired too many times ({}/{}), giving up.", "QR code expired too many times ({}/{}), giving up.",
refresh_count - 1, refresh_count - 1,
MAX_QR_REFRESH_COUNT, MAX_QR_REFRESH_COUNT,
@ -401,8 +401,8 @@ class WeixinChannel(BaseChannel):
await asyncio.sleep(1) await asyncio.sleep(1)
except Exception as e: except Exception:
logger.error("WeChat QR login failed: {}", e) self.logger.exception("QR login failed")
return False return False
@ -469,11 +469,11 @@ class WeixinChannel(BaseChannel):
self._token = self.config.token self._token = self.config.token
elif not self._load_state(): elif not self._load_state():
if not await self._qr_login(): if not await self._qr_login():
logger.error("WeChat login failed. Run 'nanobot channels login weixin' to authenticate.") self.logger.error("login failed. Run 'nanobot channels login weixin' to authenticate.")
self._running = False self._running = False
return return
logger.info("WeChat channel starting with long-poll...") self.logger.info("channel starting with long-poll...")
consecutive_failures = 0 consecutive_failures = 0
while self._running: while self._running:
@ -551,8 +551,8 @@ class WeixinChannel(BaseChannel):
if errcode == ERRCODE_SESSION_EXPIRED or ret == ERRCODE_SESSION_EXPIRED: if errcode == ERRCODE_SESSION_EXPIRED or ret == ERRCODE_SESSION_EXPIRED:
self._pause_session() self._pause_session()
remaining = self._session_pause_remaining_s() remaining = self._session_pause_remaining_s()
logger.warning( self.logger.warning(
"WeChat session expired (errcode {}). Pausing {} min.", "session expired (errcode {}). Pausing {} min.",
errcode, errcode,
max((remaining + 59) // 60, 1), max((remaining + 59) // 60, 1),
) )
@ -588,20 +588,24 @@ class WeixinChannel(BaseChannel):
if msg.get("message_type") == MESSAGE_TYPE_BOT: if msg.get("message_type") == MESSAGE_TYPE_BOT:
return return
# Deduplication by message_id
msg_id = str(msg.get("message_id", "") or msg.get("seq", "")) msg_id = str(msg.get("message_id", "") or msg.get("seq", ""))
if not msg_id: if not msg_id:
msg_id = f"{msg.get('from_user_id', '')}_{msg.get('create_time_ms', '')}" msg_id = f"{msg.get('from_user_id', '')}_{msg.get('create_time_ms', '')}"
from_user_id = msg.get("from_user_id", "") or ""
if not from_user_id:
return
if not self.is_allowed(from_user_id):
return
# Deduplication by message_id
if msg_id in self._processed_ids: if msg_id in self._processed_ids:
return return
self._processed_ids[msg_id] = None self._processed_ids[msg_id] = None
while len(self._processed_ids) > 1000: while len(self._processed_ids) > 1000:
self._processed_ids.popitem(last=False) self._processed_ids.popitem(last=False)
from_user_id = msg.get("from_user_id", "") or ""
if not from_user_id:
return
# Cache context_token (required for all replies — inbound.ts:23-27) # Cache context_token (required for all replies — inbound.ts:23-27)
ctx_token = msg.get("context_token", "") ctx_token = msg.get("context_token", "")
if ctx_token: if ctx_token:
@ -755,8 +759,8 @@ class WeixinChannel(BaseChannel):
if not content: if not content:
return return
logger.info( self.logger.info(
"WeChat inbound: from={} items={} bodyLen={}", "inbound: from={} items={} bodyLen={}",
from_user_id, from_user_id,
",".join(str(i.get("type", 0)) for i in item_list), ",".join(str(i.get("type", 0)) for i in item_list),
len(content), len(content),
@ -839,8 +843,8 @@ class WeixinChannel(BaseChannel):
and self._is_retryable_media_download_error(e) and self._is_retryable_media_download_error(e)
) )
if should_fallback: if should_fallback:
logger.warning( self.logger.warning(
"WeChat media download failed via full_url, falling back to encrypt_query_param: type={} err={}", "media download failed via full_url, falling back to encrypt_query_param: type={} err={}",
media_type, media_type,
e, e,
) )
@ -865,8 +869,8 @@ class WeixinChannel(BaseChannel):
file_path.write_bytes(data) file_path.write_bytes(data)
return str(file_path) return str(file_path)
except Exception as e: except Exception:
logger.error("Error downloading WeChat media: {}", e) self.logger.exception("Error downloading media")
return None return None
# ------------------------------------------------------------------ # ------------------------------------------------------------------
@ -936,12 +940,8 @@ class WeixinChannel(BaseChannel):
async def send(self, msg: OutboundMessage) -> None: async def send(self, msg: OutboundMessage) -> None:
if not self._client or not self._token: if not self._client or not self._token:
logger.warning("WeChat client not initialized or not authenticated") raise RuntimeError("WeChat client not initialized or not authenticated")
return self._assert_session_active()
try:
self._assert_session_active()
except RuntimeError:
return
is_progress = bool((msg.metadata or {}).get("_progress", False)) is_progress = bool((msg.metadata or {}).get("_progress", False))
if not is_progress: if not is_progress:
@ -950,11 +950,9 @@ class WeixinChannel(BaseChannel):
content = msg.content.strip() content = msg.content.strip()
ctx_token = self._context_tokens.get(msg.chat_id, "") ctx_token = self._context_tokens.get(msg.chat_id, "")
if not ctx_token: if not ctx_token:
logger.warning( raise RuntimeError(
"WeChat: no context_token for chat_id={}, cannot send", f"WeChat context_token missing for chat_id={msg.chat_id}, cannot send"
msg.chat_id,
) )
return
typing_ticket = "" typing_ticket = ""
with suppress(Exception): with suppress(Exception):
@ -976,14 +974,13 @@ class WeixinChannel(BaseChannel):
for media_path in (msg.media or []): for media_path in (msg.media or []):
try: try:
await self._send_media_file(msg.chat_id, media_path, ctx_token) await self._send_media_file(msg.chat_id, media_path, ctx_token)
except (httpx.TimeoutException, httpx.TransportError) as net_err: except (httpx.TimeoutException, httpx.TransportError):
# Network/transport errors: do NOT fall back to text — # Network/transport errors: do NOT fall back to text —
# the text send would also likely fail, and the outer # the text send would also likely fail, and the outer
# except will re-raise so ChannelManager retries properly. # except will re-raise so ChannelManager retries properly.
logger.error( self.logger.opt(exception=True).warning(
"Network error sending WeChat media {}: {}", "Network error sending media {}",
media_path, media_path,
net_err,
) )
raise raise
except httpx.HTTPStatusError as http_err: except httpx.HTTPStatusError as http_err:
@ -994,27 +991,26 @@ class WeixinChannel(BaseChannel):
) )
if status_code >= 500: if status_code >= 500:
# Server-side / retryable HTTP error — same as network. # Server-side / retryable HTTP error — same as network.
logger.error( self.logger.exception(
"Server error ({} {}) sending WeChat 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 http_err.response is not None
else "", else "",
media_path, media_path,
http_err,
) )
raise raise
# 4xx client errors are NOT retryable — fall back to text. # 4xx client errors are NOT retryable — fall back to text.
filename = Path(media_path).name filename = Path(media_path).name
logger.error("Failed to send WeChat media {}: {}", media_path, http_err) self.logger.exception("Failed to send media {}", media_path)
await self._send_text( await self._send_text(
msg.chat_id, f"[Failed to send: {filename}]", ctx_token, msg.chat_id, f"[Failed to send: {filename}]", ctx_token,
) )
except Exception as e: except Exception:
# Non-network errors (format, file-not-found, etc.): # Non-network errors (format, file-not-found, etc.):
# notify the user via text fallback. # notify the user via text fallback.
filename = Path(media_path).name filename = Path(media_path).name
logger.error("Failed to send WeChat media {}: {}", media_path, e) self.logger.exception("Failed to send media {}", media_path)
# Notify user about failure via text # Notify user about failure via text
await self._send_text( await self._send_text(
msg.chat_id, f"[Failed to send: {filename}]", ctx_token, msg.chat_id, f"[Failed to send: {filename}]", ctx_token,
@ -1027,8 +1023,8 @@ class WeixinChannel(BaseChannel):
chunks = split_message(content, WEIXIN_MAX_MESSAGE_LEN) chunks = split_message(content, WEIXIN_MAX_MESSAGE_LEN)
for chunk in chunks: for chunk in chunks:
await self._send_text(msg.chat_id, chunk, ctx_token) await self._send_text(msg.chat_id, chunk, ctx_token)
except Exception as e: except Exception:
logger.error("Error sending WeChat message: {}", e) self.logger.exception("Error sending message")
raise raise
finally: finally:
if typing_keepalive_task: if typing_keepalive_task:
@ -1052,7 +1048,7 @@ class WeixinChannel(BaseChannel):
return return
await self._send_typing(chat_id, ticket, TYPING_STATUS_TYPING) await self._send_typing(chat_id, ticket, TYPING_STATUS_TYPING)
except Exception as e: except Exception as e:
logger.debug("WeChat typing indicator start failed for {}: {}", chat_id, e) self.logger.debug("typing indicator start failed for {}: {}", chat_id, e)
return return
stop_event = asyncio.Event() stop_event = asyncio.Event()
@ -1091,7 +1087,7 @@ class WeixinChannel(BaseChannel):
try: try:
await self._send_typing(chat_id, ticket, TYPING_STATUS_CANCEL) await self._send_typing(chat_id, ticket, TYPING_STATUS_CANCEL)
except Exception as e: except Exception as e:
logger.debug("WeChat typing clear failed for {}: {}", chat_id, e) self.logger.debug("typing clear failed for {}: {}", chat_id, e)
async def _send_text( async def _send_text(
self, self,
@ -1126,10 +1122,8 @@ class WeixinChannel(BaseChannel):
data = await self._api_post("ilink/bot/sendmessage", body) data = await self._api_post("ilink/bot/sendmessage", body)
errcode = data.get("errcode", 0) errcode = data.get("errcode", 0)
if errcode and errcode != 0: if errcode and errcode != 0:
logger.warning( raise RuntimeError(
"WeChat send error (code {}): {}", f"WeChat send text error (code {errcode}): {data.get('errmsg', '')}"
errcode,
data.get("errmsg", ""),
) )
async def _send_media_file( async def _send_media_file(

View File

@ -8,8 +8,8 @@ import os
import secrets import secrets
import shutil import shutil
import subprocess import subprocess
from contextlib import suppress
from collections import OrderedDict from collections import OrderedDict
from contextlib import suppress
from pathlib import Path from pathlib import Path
from typing import Any, Literal from typing import Any, Literal
@ -99,15 +99,15 @@ class WhatsAppChannel(BaseChannel):
""" """
try: try:
bridge_dir = _ensure_bridge_setup() bridge_dir = _ensure_bridge_setup()
except RuntimeError as e: except RuntimeError:
logger.error("{}", e) self.logger.exception("bridge setup failed")
return False return False
env = {**os.environ} env = {**os.environ}
env["BRIDGE_TOKEN"] = self._effective_bridge_token() env["BRIDGE_TOKEN"] = self._effective_bridge_token()
env["AUTH_DIR"] = str(_bridge_token_path().parent) env["AUTH_DIR"] = str(_bridge_token_path().parent)
logger.info("Starting WhatsApp bridge for QR login...") self.logger.info("Starting WhatsApp bridge for QR login...")
try: try:
subprocess.run( subprocess.run(
[shutil.which("npm"), "start"], cwd=bridge_dir, check=True, env=env [shutil.which("npm"), "start"], cwd=bridge_dir, check=True, env=env
@ -123,7 +123,7 @@ class WhatsAppChannel(BaseChannel):
bridge_url = self.config.bridge_url bridge_url = self.config.bridge_url
logger.info("Connecting to WhatsApp bridge at {}...", bridge_url) self.logger.info("Connecting to WhatsApp bridge at {}...", bridge_url)
self._running = True self._running = True
@ -135,24 +135,24 @@ class WhatsAppChannel(BaseChannel):
json.dumps({"type": "auth", "token": self._effective_bridge_token()}) json.dumps({"type": "auth", "token": self._effective_bridge_token()})
) )
self._connected = True self._connected = True
logger.info("Connected to WhatsApp bridge") self.logger.info("Connected to WhatsApp bridge")
# Listen for messages # Listen for messages
async for message in ws: async for message in ws:
try: try:
await self._handle_bridge_message(message) await self._handle_bridge_message(message)
except Exception as e: except Exception:
logger.error("Error handling bridge message: {}", e) self.logger.exception("Error handling bridge message")
except asyncio.CancelledError: except asyncio.CancelledError:
break break
except Exception as e: except Exception as e:
self._connected = False self._connected = False
self._ws = None self._ws = None
logger.warning("WhatsApp bridge connection error: {}", e) self.logger.warning("WhatsApp bridge connection error: {}", e)
if self._running: if self._running:
logger.info("Reconnecting in 5 seconds...") self.logger.info("Reconnecting in 5 seconds...")
await asyncio.sleep(5) await asyncio.sleep(5)
async def stop(self) -> None: async def stop(self) -> None:
@ -167,7 +167,7 @@ class WhatsAppChannel(BaseChannel):
async def send(self, msg: OutboundMessage) -> None: async def send(self, msg: OutboundMessage) -> None:
"""Send a message through WhatsApp.""" """Send a message through WhatsApp."""
if not self._ws or not self._connected: if not self._ws or not self._connected:
logger.warning("WhatsApp bridge not connected") self.logger.warning("WhatsApp bridge not connected")
return return
chat_id = msg.chat_id chat_id = msg.chat_id
@ -176,8 +176,8 @@ class WhatsAppChannel(BaseChannel):
try: try:
payload = {"type": "send", "to": chat_id, "text": msg.content} payload = {"type": "send", "to": chat_id, "text": msg.content}
await self._ws.send(json.dumps(payload, ensure_ascii=False)) await self._ws.send(json.dumps(payload, ensure_ascii=False))
except Exception as e: except Exception:
logger.error("Error sending WhatsApp message: {}", e) self.logger.exception("Error sending message")
raise raise
for media_path in msg.media or []: for media_path in msg.media or []:
@ -191,8 +191,8 @@ class WhatsAppChannel(BaseChannel):
"fileName": media_path.rsplit("/", 1)[-1], "fileName": media_path.rsplit("/", 1)[-1],
} }
await self._ws.send(json.dumps(payload, ensure_ascii=False)) await self._ws.send(json.dumps(payload, ensure_ascii=False))
except Exception as e: except Exception:
logger.error("Error sending WhatsApp media {}: {}", media_path, e) self.logger.exception("Error sending media {}", media_path)
raise raise
async def _handle_bridge_message(self, raw: str) -> None: async def _handle_bridge_message(self, raw: str) -> None:
@ -200,7 +200,7 @@ class WhatsAppChannel(BaseChannel):
try: try:
data = json.loads(raw) data = json.loads(raw)
except json.JSONDecodeError: except json.JSONDecodeError:
logger.warning("Invalid JSON from bridge: {}", raw[:100]) self.logger.warning("Invalid JSON from bridge: {}", raw[:100])
return return
msg_type = data.get("type") msg_type = data.get("type")
@ -214,13 +214,6 @@ class WhatsAppChannel(BaseChannel):
content = data.get("content", "") content = data.get("content", "")
message_id = data.get("id", "") message_id = data.get("id", "")
if message_id:
if message_id in self._processed_message_ids:
return
self._processed_message_ids[message_id] = None
while len(self._processed_message_ids) > 1000:
self._processed_message_ids.popitem(last=False)
# Extract just the phone number or lid as chat_id # Extract just the phone number or lid as chat_id
is_group = data.get("isGroup", False) is_group = data.get("isGroup", False)
was_mentioned = data.get("wasMentioned", False) was_mentioned = data.get("wasMentioned", False)
@ -246,11 +239,21 @@ class WhatsAppChannel(BaseChannel):
elif extracted and not phone_id: elif extracted and not phone_id:
phone_id = extracted # best guess for bare values phone_id = extracted # best guess for bare values
sender_id = phone_id or self._lid_to_phone.get(lid_id, "") or lid_id or id_a or id_b
if not self.is_allowed(sender_id):
return
if message_id:
if message_id in self._processed_message_ids:
return
self._processed_message_ids[message_id] = None
while len(self._processed_message_ids) > 1000:
self._processed_message_ids.popitem(last=False)
if phone_id and lid_id: if phone_id and lid_id:
self._lid_to_phone[lid_id] = phone_id self._lid_to_phone[lid_id] = phone_id
sender_id = phone_id or self._lid_to_phone.get(lid_id, "") or lid_id or id_a or id_b
logger.info("Sender phone={} lid={} → sender_id={}", phone_id or "(empty)", lid_id or "(empty)", sender_id) self.logger.info("Sender phone={} lid={} → sender_id={}", phone_id or "(empty)", lid_id or "(empty)", sender_id)
# Extract media paths (images/documents/videos downloaded by the bridge) # Extract media paths (images/documents/videos downloaded by the bridge)
media_paths = data.get("media") or [] media_paths = data.get("media") or []
@ -258,11 +261,11 @@ class WhatsAppChannel(BaseChannel):
# Handle voice transcription if it's a voice message # Handle voice transcription if it's a voice message
if content == "[Voice Message]": if content == "[Voice Message]":
if media_paths: if media_paths:
logger.info("Transcribing voice message from {}...", sender_id) self.logger.info("Transcribing voice message from {}...", sender_id)
transcription = await self.transcribe_audio(media_paths[0]) transcription = await self.transcribe_audio(media_paths[0])
if transcription: if transcription:
content = transcription content = transcription
logger.info("Transcribed voice from {}: {}...", sender_id, transcription[:50]) self.logger.info("Transcribed voice from {}: {}...", sender_id, transcription[:50])
else: else:
content = "[Voice Message: Transcription failed]" content = "[Voice Message: Transcription failed]"
else: else:
@ -291,7 +294,7 @@ class WhatsAppChannel(BaseChannel):
elif msg_type == "status": elif msg_type == "status":
# Connection status update # Connection status update
status = data.get("status") status = data.get("status")
logger.info("WhatsApp status: {}", status) self.logger.info("Status: {}", status)
if status == "connected": if status == "connected":
self._connected = True self._connected = True
@ -300,10 +303,10 @@ class WhatsAppChannel(BaseChannel):
elif msg_type == "qr": elif msg_type == "qr":
# QR code for authentication # QR code for authentication
logger.info("Scan QR code in the bridge terminal to connect WhatsApp") self.logger.info("Scan QR code in the bridge terminal to connect WhatsApp")
elif msg_type == "error": elif msg_type == "error":
logger.error("WhatsApp bridge error: {}", data.get("error")) self.logger.error("Bridge error: {}", data.get("error"))
def _ensure_bridge_setup() -> Path: def _ensure_bridge_setup() -> Path:

View File

@ -21,6 +21,22 @@ if sys.platform == "win32":
import typer import typer
from loguru import logger from loguru import logger
# Remove default handler and re-add with unified nanobot format
logger.remove()
_log_handler_id = logger.add(
sys.stderr,
format=(
"<green>{time:YYYY-MM-DD HH:mm:ss}</green> | "
"<level>{level: <5}</level> | "
"<cyan>{extra[channel]}</cyan> | "
"<level>{message}</level>"
),
level="INFO",
colorize=None,
filter=lambda record: record["extra"].setdefault("channel", "-") or True,
)
from prompt_toolkit import PromptSession, print_formatted_text from prompt_toolkit import PromptSession, print_formatted_text
from prompt_toolkit.application import run_in_terminal from prompt_toolkit.application import run_in_terminal
from prompt_toolkit.formatted_text import ANSI, HTML from prompt_toolkit.formatted_text import ANSI, HTML
@ -217,6 +233,29 @@ async def _print_interactive_progress_line(text: str, thinking: ThinkingSpinner
await _print_interactive_line(text) await _print_interactive_line(text)
async def _maybe_print_interactive_progress(
msg: Any,
thinking: ThinkingSpinner | None,
channels_config: Any,
) -> bool:
metadata = msg.metadata or {}
if metadata.get("_retry_wait"):
await _print_interactive_progress_line(msg.content, thinking)
return True
if not metadata.get("_progress"):
return False
is_tool_hint = metadata.get("_tool_hint", False)
if channels_config and is_tool_hint and not channels_config.send_tool_hints:
return True
if channels_config and not is_tool_hint and not channels_config.send_progress:
return True
await _print_interactive_progress_line(msg.content, thinking)
return True
def _is_exit_command(command: str) -> bool: def _is_exit_command(command: str) -> bool:
"""Return True when input should end interactive chat.""" """Return True when input should end interactive chat."""
return command.lower() in EXIT_COMMANDS return command.lower() in EXIT_COMMANDS
@ -575,9 +614,19 @@ def gateway(
): ):
"""Start the nanobot gateway.""" """Start the nanobot gateway."""
if verbose: if verbose:
import logging logger.remove(_log_handler_id)
logger.add(
logging.basicConfig(level=logging.DEBUG) sys.stderr,
format=(
"<green>{time:YYYY-MM-DD HH:mm:ss}</green> | "
"<level>{level: <5}</level> | "
"<cyan>{extra[channel]}</cyan> | "
"<level>{message}</level>"
),
level="DEBUG",
colorize=None,
filter=lambda record: record["extra"].setdefault("channel", "-") or True,
)
cfg = _load_runtime_config(config, workspace) cfg = _load_runtime_config(config, workspace)
_run_gateway(cfg, port=port) _run_gateway(cfg, port=port)
@ -1131,15 +1180,11 @@ def agent(
turn_done.set() turn_done.set()
continue continue
if msg.metadata.get("_progress"): if await _maybe_print_interactive_progress(
is_tool_hint = msg.metadata.get("_tool_hint", False) msg,
ch = agent_loop.channels_config _thinking,
if ch and is_tool_hint and not ch.send_tool_hints: agent_loop.channels_config,
pass ):
elif ch and not is_tool_hint and not ch.send_progress:
pass
else:
await _print_interactive_progress_line(msg.content, _thinking)
continue continue
if not turn_done.is_set(): if not turn_done.is_set():

View File

@ -840,7 +840,7 @@ def _get_channel_info() -> dict[str, tuple[str, type[BaseModel]]]:
display_name = getattr(channel_cls, "display_name", name.capitalize()) display_name = getattr(channel_cls, "display_name", name.capitalize())
result[name] = (display_name, config_cls) result[name] = (display_name, config_cls)
except Exception: except Exception:
logger.warning(f"Failed to load channel module: {name}") logger.warning("Failed to load channel module: {}", name)
return result return result

View File

@ -6,6 +6,7 @@ import asyncio
import os import os
import sys import sys
from contextlib import suppress from contextlib import suppress
from dataclasses import dataclass
from nanobot import __version__ from nanobot import __version__
from nanobot.bus.events import OutboundMessage from nanobot.bus.events import OutboundMessage
@ -14,6 +15,88 @@ 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
@dataclass(frozen=True)
class BuiltinCommandSpec:
command: str
title: str
description: str
icon: str
arg_hint: str = ""
def as_dict(self) -> dict[str, str]:
return {
"command": self.command,
"title": self.title,
"description": self.description,
"icon": self.icon,
"arg_hint": self.arg_hint,
}
BUILTIN_COMMAND_SPECS: tuple[BuiltinCommandSpec, ...] = (
BuiltinCommandSpec(
"/new",
"New chat",
"Stop the current task and start a fresh conversation.",
"square-pen",
),
BuiltinCommandSpec(
"/stop",
"Stop current task",
"Cancel the active agent turn for this chat.",
"square",
),
BuiltinCommandSpec(
"/restart",
"Restart nanobot",
"Restart the bot process in place.",
"rotate-cw",
),
BuiltinCommandSpec(
"/status",
"Show status",
"Display runtime, provider, and channel status.",
"activity",
),
BuiltinCommandSpec(
"/history",
"Show conversation history",
"Print the last N persisted conversation messages.",
"history",
"[n]",
),
BuiltinCommandSpec(
"/dream",
"Run Dream",
"Manually trigger memory consolidation.",
"sparkles",
),
BuiltinCommandSpec(
"/dream-log",
"Show Dream log",
"Show what the last Dream consolidation changed.",
"book-open",
),
BuiltinCommandSpec(
"/dream-restore",
"Restore memory",
"Revert memory to a previous Dream snapshot.",
"undo-2",
),
BuiltinCommandSpec(
"/help",
"Show help",
"List available slash commands.",
"circle-help",
),
)
def builtin_command_palette() -> list[dict[str, str]]:
"""Return structured command metadata for UI command palettes."""
return [spec.as_dict() for spec in BUILTIN_COMMAND_SPECS]
async def cmd_stop(ctx: CommandContext) -> OutboundMessage: 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
@ -378,18 +461,12 @@ async def cmd_help(ctx: CommandContext) -> OutboundMessage:
def build_help_text() -> str: def build_help_text() -> str:
"""Build canonical help text shared across channels.""" """Build canonical help text shared across channels."""
lines = [ lines = ["🐈 nanobot commands:"]
"🐈 nanobot commands:", for spec in BUILTIN_COMMAND_SPECS:
"/new — Stop current task and start a new conversation", command = spec.command
"/stop — Stop the current task", if spec.arg_hint:
"/restart — Restart the bot", command = f"{command} {spec.arg_hint}"
"/status — Show bot status", lines.append(f"{command}{spec.description}")
"/history [n] — Show the last N conversation messages (default 10)",
"/dream — Manually trigger Dream consolidation",
"/dream-log — Show what the last Dream changed",
"/dream-restore — Revert memory to a previous state",
"/help — Show available commands",
]
return "\n".join(lines) return "\n".join(lines)

View File

@ -49,7 +49,7 @@ def load_config(config_path: Path | None = None) -> Config:
data = _migrate_config(data) data = _migrate_config(data)
config = Config.model_validate(data) config = Config.model_validate(data)
except (json.JSONDecodeError, ValueError, pydantic.ValidationError) as e: except (json.JSONDecodeError, ValueError, pydantic.ValidationError) as e:
logger.warning(f"Failed to load config from {path}: {e}") logger.warning("Failed to load config from {}: {}", path, e)
logger.warning("Using default configuration.") logger.warning("Using default configuration.")
_apply_ssrf_whitelist(config) _apply_ssrf_whitelist(config)

View File

@ -2,8 +2,10 @@
import asyncio import asyncio
import json import json
import os
import time import time
import uuid import uuid
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
@ -12,7 +14,14 @@ from typing import Any, Callable, Coroutine, Literal
from filelock import FileLock from filelock import FileLock
from loguru import logger from loguru import logger
from nanobot.cron.types import CronJob, CronJobState, CronPayload, CronRunRecord, CronSchedule, CronStore from nanobot.cron.types import (
CronJob,
CronJobState,
CronPayload,
CronRunRecord,
CronSchedule,
CronStore,
)
def _now_ms() -> int: def _now_ms() -> int:
@ -83,8 +92,20 @@ class CronService:
self._timer_active = False self._timer_active = False
self.max_sleep_ms = max_sleep_ms self.max_sleep_ms = max_sleep_ms
def _load_jobs(self) -> tuple[list[CronJob], int]: def _load_jobs(self) -> tuple[list[CronJob], int] | None:
jobs = [] """Load jobs from disk.
Returns:
``(jobs, version)`` tuple on success or when no store file exists
(in which case an empty list and version 1 are returned).
``None`` when the store file exists but cannot be parsed; the
corrupt file is preserved with a ``.corrupt-<ts>`` suffix so the
caller can decide whether to overwrite or bail out. Returning a
sentinel here is important: silently treating a parse error as an
empty job list would cause the next ``_save_store`` to wipe every
job from disk.
"""
jobs: list[CronJob] = []
version = 1 version = 1
if self.store_path.exists(): if self.store_path.exists():
try: try:
@ -135,8 +156,22 @@ class CronService:
updated_at_ms=j.get("updatedAtMs", 0), updated_at_ms=j.get("updatedAtMs", 0),
delete_after_run=j.get("deleteAfterRun", False), delete_after_run=j.get("deleteAfterRun", False),
)) ))
except Exception as e: except Exception:
logger.warning("Failed to load cron store: {}", e) # Preserve the corrupt file for forensic recovery instead of
# letting the next save overwrite it with an empty job list.
backup = self.store_path.with_suffix(
self.store_path.suffix + f".corrupt-{int(time.time())}"
)
with suppress(OSError):
self.store_path.rename(backup)
logger.exception(
"Failed to load cron store at {}. "
"Corrupt file preserved at {}. "
"Refusing to overwrite to avoid data loss.",
self.store_path,
backup,
)
return None
return jobs, version return jobs, version
def _merge_action(self): def _merge_action(self):
@ -166,8 +201,8 @@ class CronService:
else: else:
_update(action.get("params", {})) _update(action.get("params", {}))
changed = True changed = True
except Exception as exp: except Exception:
logger.debug(f"load action line error: {exp}") logger.exception("load action line error")
continue continue
self._store.jobs = list(jobs_map.values()) self._store.jobs = list(jobs_map.values())
if self._running and changed: if self._running and changed:
@ -175,15 +210,28 @@ class CronService:
self._save_store() self._save_store()
return return
def _load_store(self) -> CronStore: def _load_store(self) -> CronStore | None:
"""Load jobs from disk. Reloads automatically if file was modified externally. """Load jobs from disk. Reloads automatically if file was modified externally.
- Reload every time because it needs to merge operations on the jobs object from other instances. - Reload every time because it needs to merge operations on the jobs object from other instances.
- During _on_timer execution, return the existing store to prevent concurrent - During _on_timer execution, return the existing store to prevent concurrent
_load_store calls (e.g. from list_jobs polling) from replacing it mid-execution. _load_store calls (e.g. from list_jobs polling) from replacing it mid-execution.
- When the on-disk store exists but is unreadable: keep using the
previous in-memory ``self._store`` if we already have one (so a
transient corruption does not drop live jobs); only the very first
load (during ``start``) can return ``None`` to signal an unrecoverable
state to the caller.
""" """
if self._timer_active and self._store: if self._timer_active and self._store:
return self._store return self._store
jobs, version = self._load_jobs() loaded = self._load_jobs()
if loaded is None:
# Corrupt store on disk. Prefer the last good in-memory snapshot
# over wiping live jobs; ``_load_jobs`` has already moved the
# corrupt file aside with a ``.corrupt-<ts>`` suffix.
if self._store is not None:
return self._store
return None
jobs, version = loaded
self._store = CronStore(version=version, jobs=jobs) self._store = CronStore(version=version, jobs=jobs)
self._merge_action() self._merge_action()
@ -242,12 +290,56 @@ class CronService:
] ]
} }
self.store_path.write_text(json.dumps(data, indent=2, ensure_ascii=False), encoding="utf-8") self._atomic_write(self.store_path, json.dumps(data, indent=2, ensure_ascii=False))
@staticmethod
def _atomic_write(path: Path, content: str) -> None:
"""Write *content* to *path* atomically with fsync.
Uses a temp-file + ``os.replace`` + ``fsync`` pattern so a crash or
SIGKILL mid-write cannot leave the destination truncated or invalid.
Mirrors ``nanobot.session.manager.SessionManager.save`` (see
commit 512bf59, ``fix(session): fsync sessions on graceful shutdown
to prevent data loss``). Without this, ``jobs.json`` could be
corrupted on container shutdown and silently re-created empty on
next start, wiping every scheduled job.
"""
path.parent.mkdir(parents=True, exist_ok=True)
tmp_path = path.with_suffix(path.suffix + ".tmp")
try:
with open(tmp_path, "w", encoding="utf-8") as f:
f.write(content)
f.flush()
os.fsync(f.fileno())
os.replace(tmp_path, path)
# fsync the parent directory so the rename itself is durable.
# Skip on Windows where opening a directory raises PermissionError;
# NTFS journals metadata synchronously so this is a no-op there.
with suppress(PermissionError):
fd = os.open(str(path.parent), os.O_RDONLY)
try:
os.fsync(fd)
finally:
os.close(fd)
except BaseException:
tmp_path.unlink(missing_ok=True)
raise
async def start(self) -> None: async def start(self) -> None:
"""Start the cron service.""" """Start the cron service."""
self._running = True self._running = True
self._load_store() loaded = self._load_store()
if loaded is None:
# Store file existed but was corrupt and has been preserved with
# a ``.corrupt-<ts>`` suffix. Bail out instead of starting with
# an empty store; that would call ``_save_store`` and overwrite
# the now-renamed (but still recoverable) data with [].
self._running = False
raise RuntimeError(
f"cron store at {self.store_path} is corrupt and was preserved; "
"refusing to start with an empty job list. "
"Inspect the .corrupt-<ts> backup and restore manually."
)
self._recompute_next_runs() self._recompute_next_runs()
self._save_store() self._save_store()
self._arm_timer() self._arm_timer()
@ -302,6 +394,9 @@ class CronService:
async def _on_timer(self) -> None: async def _on_timer(self) -> None:
"""Handle timer tick - run due jobs.""" """Handle timer tick - run due jobs."""
self._load_store() self._load_store()
# If a hot reload found a corrupt store on disk, ``self._store`` may
# still hold the previous, known-good in-memory snapshot. Keep using
# it rather than crashing the timer or wiping live jobs.
if not self._store: if not self._store:
self._arm_timer() self._arm_timer()
return return
@ -338,7 +433,7 @@ class CronService:
except Exception as e: except Exception as e:
job.state.last_status = "error" job.state.last_status = "error"
job.state.last_error = str(e) job.state.last_error = str(e)
logger.error("Cron: job '{}' failed: {}", job.name, e) logger.exception("Cron: job '{}' failed", job.name)
end_ms = _now_ms() end_ms = _now_ms()
job.state.last_run_at_ms = start_ms job.state.last_run_at_ms = start_ms

View File

@ -144,8 +144,8 @@ class HeartbeatService:
await self._tick() await self._tick()
except asyncio.CancelledError: except asyncio.CancelledError:
break break
except Exception as e: except Exception:
logger.error("Heartbeat error: {}", e) logger.exception("Heartbeat error")
@staticmethod @staticmethod
def _is_deliverable(response: str) -> bool: def _is_deliverable(response: str) -> bool:

View File

@ -449,59 +449,6 @@ class OpenAICompatProvider(LLMProvider):
clean["content"] = self._coerce_content_to_string(clean.get("content")) clean["content"] = self._coerce_content_to_string(clean.get("content"))
return self._enforce_role_alternation(sanitized) return self._enforce_role_alternation(sanitized)
def _drop_deepseek_incomplete_reasoning_history(
self,
messages: list[dict[str, Any]],
model_name: str,
reasoning_effort: str | None,
) -> list[dict[str, Any]]:
if (
not self._spec
or self._spec.name != "deepseek"
):
return messages
semantic_effort = reasoning_effort.lower() if isinstance(reasoning_effort, str) else None
if semantic_effort in {"none", "minimal", "minimum"}:
return messages
# DeepSeek-V4 can require reasoning_content even when the config did
# not explicitly request reasoning_effort. Keep that implicit-thinking
# cleanup scoped to known thinking-capable DeepSeek models so normal
# deepseek-chat history is not trimmed.
if semantic_effort is None:
model_lower = model_name.lower()
if not any(token in model_lower for token in ("deepseek-v4", "deepseek-reasoner")):
return messages
bad_idx = None
for idx, msg in enumerate(messages):
if (
msg.get("role") == "assistant"
and msg.get("tool_calls")
and not msg.get("reasoning_content")
):
bad_idx = idx
if bad_idx is None:
return messages
keep_from = None
for idx in range(bad_idx + 1, len(messages)):
if messages[idx].get("role") == "user":
keep_from = idx
break
if keep_from is None:
trimmed = messages[:bad_idx]
else:
prefix = [msg for msg in messages[:keep_from] if msg.get("role") == "system"]
trimmed = prefix + messages[keep_from:]
logger.warning(
"Dropped {} DeepSeek thinking history message(s) with incomplete reasoning_content",
len(messages) - len(trimmed),
)
return trimmed
# ------------------------------------------------------------------ # ------------------------------------------------------------------
# Build kwargs # Build kwargs
# ------------------------------------------------------------------ # ------------------------------------------------------------------
@ -542,11 +489,6 @@ class OpenAICompatProvider(LLMProvider):
if spec and spec.strip_model_prefix: if spec and spec.strip_model_prefix:
model_name = model_name.split("/")[-1] model_name = model_name.split("/")[-1]
messages = self._drop_deepseek_incomplete_reasoning_history(
messages,
model_name,
reasoning_effort,
)
kwargs: dict[str, Any] = { kwargs: dict[str, Any] = {
"model": model_name, "model": model_name,
"messages": self._sanitize_messages(self._sanitize_empty_content(messages)), "messages": self._sanitize_messages(self._sanitize_empty_content(messages)),
@ -611,22 +553,22 @@ class OpenAICompatProvider(LLMProvider):
kwargs["tools"] = tools kwargs["tools"] = tools
kwargs["tool_choice"] = tool_choice or "auto" kwargs["tool_choice"] = tool_choice or "auto"
# Backfill reasoning_content on legacy assistant messages. # Backfill reasoning_content="" on assistants missing it: DeepSeek
# DeepSeek V4 (and potentially others) rejects thinking-mode # thinking mode rejects history otherwise (#3554, #3584); "" reads
# requests that contain assistant messages without reasoning_content # as "no thinking that turn". DeepSeek-V4/reasoner reason natively,
# — even on turns that had no tool calls. This happens when a # so backfill even without explicit reasoning_effort.
# session was started with a non-thinking model or without explicit_thinking = (
# reasoning_effort, then the user switches thinking mode on reasoning_effort is not None
# mid-session. Injecting an empty string satisfies the API and semantic_effort not in ("none", "minimal")
# without altering semantics (the model treats it as "no and ((spec and spec.thinking_style) or _is_kimi_thinking_model(model_name))
# thinking happened on that turn").
thinking_active = (
(spec and spec.thinking_style and reasoning_effort is not None
and semantic_effort not in ("none", "minimal"))
or (reasoning_effort is not None and _is_kimi_thinking_model(model_name)
and semantic_effort not in ("none", "minimal"))
) )
if thinking_active: implicit_deepseek_thinking = (
spec is not None
and spec.name == "deepseek"
and semantic_effort not in ("none", "minimal", "minimum")
and any(t in model_name.lower() for t in ("deepseek-v4", "deepseek-reasoner"))
)
if explicit_thinking or implicit_deepseek_thinking:
for msg in kwargs["messages"]: for msg in kwargs["messages"]:
if msg.get("role") == "assistant" and "reasoning_content" not in msg: if msg.get("role") == "assistant" and "reasoning_content" not in msg:
msg["reasoning_content"] = "" msg["reasoning_content"] = ""

View File

@ -547,10 +547,13 @@ class SessionManager:
data = json.loads(first_line) data = json.loads(first_line)
if data.get("_type") == "metadata": if data.get("_type") == "metadata":
key = data.get("key") or path.stem.replace("_", ":", 1) key = data.get("key") or path.stem.replace("_", ":", 1)
metadata = data.get("metadata", {})
title = metadata.get("title") if isinstance(metadata, dict) else None
sessions.append({ sessions.append({
"key": key, "key": key,
"created_at": data.get("created_at"), "created_at": data.get("created_at"),
"updated_at": data.get("updated_at"), "updated_at": data.get("updated_at"),
"title": title if isinstance(title, str) else "",
"path": str(path) "path": str(path)
}) })
except Exception: except Exception:
@ -560,6 +563,11 @@ class SessionManager:
"key": repaired.key, "key": repaired.key,
"created_at": repaired.created_at.isoformat(), "created_at": repaired.created_at.isoformat(),
"updated_at": repaired.updated_at.isoformat(), "updated_at": repaired.updated_at.isoformat(),
"title": (
repaired.metadata.get("title")
if isinstance(repaired.metadata.get("title"), str)
else ""
),
"path": str(path) "path": str(path)
}) })
continue continue

View File

@ -93,7 +93,7 @@ def _extract_pdf(path: Path) -> str:
pages.append(f"--- Page {i} ---\n{text}") pages.append(f"--- Page {i} ---\n{text}")
return _truncate("\n\n".join(pages), _MAX_TEXT_LENGTH) return _truncate("\n\n".join(pages), _MAX_TEXT_LENGTH)
except Exception as e: except Exception as e:
logger.error("Failed to extract PDF {}: {}", path, e) logger.exception("Failed to extract PDF {}", path)
return f"[error: failed to extract PDF: {e!s}]" return f"[error: failed to extract PDF: {e!s}]"
@ -108,7 +108,7 @@ def _extract_docx(path: Path) -> str:
paragraphs: list[str] = [p.text for p in doc.paragraphs if p.text.strip()] paragraphs: list[str] = [p.text for p in doc.paragraphs if p.text.strip()]
return _truncate("\n\n".join(paragraphs), _MAX_TEXT_LENGTH) return _truncate("\n\n".join(paragraphs), _MAX_TEXT_LENGTH)
except Exception as e: except Exception as e:
logger.error("Failed to extract DOCX {}: {}", path, e) logger.exception("Failed to extract DOCX {}", path)
return f"[error: failed to extract DOCX: {e!s}]" return f"[error: failed to extract DOCX: {e!s}]"
@ -135,7 +135,7 @@ def _extract_xlsx(path: Path) -> str:
finally: finally:
wb.close() wb.close()
except Exception as e: except Exception as e:
logger.error("Failed to extract XLSX {}: {}", path, e) logger.exception("Failed to extract XLSX {}", path)
return f"[error: failed to extract XLSX: {e!s}]" return f"[error: failed to extract XLSX: {e!s}]"
@ -156,7 +156,7 @@ def _extract_pptx(path: Path) -> str:
slides.append(f"--- Slide {i} ---\n" + "\n".join(slide_text)) slides.append(f"--- Slide {i} ---\n" + "\n".join(slide_text))
return _truncate("\n\n".join(slides), _MAX_TEXT_LENGTH) return _truncate("\n\n".join(slides), _MAX_TEXT_LENGTH)
except Exception as e: except Exception as e:
logger.error("Failed to extract PPTX {}: {}", path, e) logger.exception("Failed to extract PPTX {}", path)
return f"[error: failed to extract PPTX: {e!s}]" return f"[error: failed to extract PPTX: {e!s}]"
@ -195,7 +195,7 @@ def _extract_text_file(path: Path) -> str:
content = path.read_text(encoding="latin-1") content = path.read_text(encoding="latin-1")
return _truncate(content, _MAX_TEXT_LENGTH) return _truncate(content, _MAX_TEXT_LENGTH)
except Exception as e: except Exception as e:
logger.error("Failed to read text file {}: {}", path, e) logger.exception("Failed to read text file {}", path)
return f"[error: failed to read file: {e!s}]" return f"[error: failed to read file: {e!s}]"

View File

@ -113,7 +113,7 @@ class GitStore:
logger.info("Git store initialized at {}", self._workspace) logger.info("Git store initialized at {}", self._workspace)
return True return True
except Exception: except Exception:
logger.warning("Git store init failed for {}", self._workspace) logger.exception("Git store init failed for {}", self._workspace)
return False return False
# -- daily operations ------------------------------------------------------ # -- daily operations ------------------------------------------------------
@ -149,7 +149,7 @@ class GitStore:
logger.debug("Git auto-commit: {} ({})", sha, message) logger.debug("Git auto-commit: {} ({})", sha, message)
return sha return sha
except Exception: except Exception:
logger.warning("Git auto-commit failed: {}", message) logger.exception("Git auto-commit failed: {}", message)
return None return None
# -- internal helpers ------------------------------------------------------ # -- internal helpers ------------------------------------------------------
@ -243,7 +243,7 @@ class GitStore:
return entries return entries
except Exception: except Exception:
logger.warning("Git log failed") logger.exception("Git log failed")
return [] return []
def line_ages(self, file_path: str) -> list[LineAge]: def line_ages(self, file_path: str) -> list[LineAge]:
@ -266,7 +266,7 @@ class GitStore:
annotated = porcelain.annotate(str(self._workspace), file_path) annotated = porcelain.annotate(str(self._workspace), file_path)
except Exception: except Exception:
logger.warning("Git line_ages annotate failed for {}", file_path) logger.exception("Git line_ages annotate failed for {}", file_path)
return [] return []
if not annotated: if not annotated:
@ -296,7 +296,7 @@ class GitStore:
) )
return out.getvalue().decode("utf-8", errors="replace") return out.getvalue().decode("utf-8", errors="replace")
except Exception: except Exception:
logger.warning("Git diff_commits failed") logger.exception("Git diff_commits failed")
return "" return ""
def find_commit(self, short_sha: str, max_entries: int = 20) -> CommitInfo | None: def find_commit(self, short_sha: str, max_entries: int = 20) -> CommitInfo | None:
@ -367,7 +367,7 @@ class GitStore:
msg = f"revert: undo {commit}" msg = f"revert: undo {commit}"
return self.auto_commit(msg) return self.auto_commit(msg)
except Exception: except Exception:
logger.warning("Git revert failed for {}", commit) logger.exception("Git revert failed for {}", commit)
return None return None
@staticmethod @staticmethod

View File

@ -268,8 +268,8 @@ def maybe_persist_tool_result(
bucket = ensure_dir(root / safe_filename(session_key or "default")) bucket = ensure_dir(root / safe_filename(session_key or "default"))
try: try:
_cleanup_tool_result_buckets(root, bucket) _cleanup_tool_result_buckets(root, bucket)
except Exception as exc: except Exception:
logger.warning("Failed to clean stale tool result buckets in {}: {}", root, exc) logger.exception("Failed to clean stale tool result buckets in {}", root)
path = bucket / f"{safe_filename(tool_call_id)}.{suffix}" path = bucket / f"{safe_filename(tool_call_id)}.{suffix}"
if not path.exists(): if not path.exists():
if suffix == "json" and isinstance(content, list): if suffix == "json" and isinstance(content, list):
@ -540,6 +540,6 @@ def sync_workspace_templates(workspace: Path, silent: bool = False) -> list[str]
) )
gs.init() gs.init()
except Exception: except Exception:
logger.warning("Failed to initialize git store for {}", workspace) logger.exception("Failed to initialize git store for {}", workspace)
return added return added

View File

@ -0,0 +1,47 @@
"""Utilities for redirecting stdlib logging to loguru."""
from __future__ import annotations
import logging
from loguru import logger
class _LoguruBridge(logging.Handler):
"""Route stdlib log records into loguru with consistent formatting."""
_LEVEL_MAP: dict[int, str] = {
logging.DEBUG: "DEBUG",
logging.INFO: "INFO",
logging.WARNING: "WARNING",
logging.ERROR: "ERROR",
logging.CRITICAL: "CRITICAL",
}
def __init__(self, lib_name: str) -> None:
super().__init__()
self.lib_name = lib_name
def emit(self, record: logging.LogRecord) -> None:
level = self._LEVEL_MAP.get(record.levelno, "INFO")
frame, depth = logging.currentframe(), 2
while frame and frame.f_code.co_filename == logging.__file__:
frame, depth = frame.f_back, depth + 1
logger.opt(depth=depth, exception=record.exc_info).log(
level, "[{lib}] {message}", lib=self.lib_name, message=record.getMessage()
)
def redirect_lib_logging(name: str, level: str | None = None) -> None:
"""Redirect stdlib logging from *name* into loguru.
Adds a bridge handler if one is not already present and disables
propagation so messages are not duplicated. When *level* is None the
handler does not filter loguru's own level controls visibility.
"""
lib_logger = logging.getLogger(name)
if not any(isinstance(h, _LoguruBridge) for h in lib_logger.handlers):
handler = _LoguruBridge(name)
if level is not None:
handler.setLevel(getattr(logging, level.upper(), logging.WARNING))
lib_logger.handlers = [handler]
lib_logger.propagate = False

View File

@ -2,6 +2,8 @@
from __future__ import annotations from __future__ import annotations
import re
from pathlib import Path
from typing import Any from typing import Any
from loguru import logger from loguru import logger
@ -10,6 +12,9 @@ from nanobot.utils.helpers import stringify_text_blocks
_MAX_REPEAT_EXTERNAL_LOOKUPS = 2 _MAX_REPEAT_EXTERNAL_LOOKUPS = 2
# Third same-target workspace violation in a turn escalates to "stop retrying".
_MAX_REPEAT_WORKSPACE_VIOLATIONS = 2
EMPTY_FINAL_RESPONSE_MESSAGE = ( EMPTY_FINAL_RESPONSE_MESSAGE = (
"I completed the tool steps but couldn't produce a final answer. " "I completed the tool steps but couldn't produce a final answer. "
"Please try again or narrow the task." "Please try again or narrow the task."
@ -95,3 +100,71 @@ def repeated_external_lookup_error(
"Error: repeated external lookup blocked. " "Error: repeated external lookup blocked. "
"Use the results you already have to answer, or try a meaningfully different source." "Use the results you already have to answer, or try a meaningfully different source."
) )
# Workspace-boundary violations are soft errors, with per-target throttling.
_OUTSIDE_PATH_PATTERN = re.compile(r"(?:^|[\s|>'\"])((?:/[^\s\"'>;|<]+)|(?:~[^\s\"'>;|<]+))")
def workspace_violation_signature(
tool_name: str,
arguments: dict[str, Any],
) -> str | None:
"""Return a stable cross-tool signature for the outside-workspace target."""
for key in ("path", "file_path", "target", "source", "destination"):
val = arguments.get(key)
if isinstance(val, str) and val.strip():
return _normalize_violation_target(val.strip())
if tool_name in {"exec", "shell"}:
cmd = str(arguments.get("command") or "").strip()
if cmd:
match = _OUTSIDE_PATH_PATTERN.search(cmd)
if match:
return _normalize_violation_target(match.group(1))
cwd = str(arguments.get("working_dir") or "").strip()
if cwd:
return _normalize_violation_target(cwd)
return None
def _normalize_violation_target(raw: str) -> str:
"""Normalize *raw* path so that equivalent spellings collide on the same key."""
try:
normalized = Path(raw).expanduser().resolve().as_posix()
except Exception:
normalized = raw.replace("\\", "/")
return f"violation:{normalized}".lower()
def repeated_workspace_violation_error(
tool_name: str,
arguments: dict[str, Any],
seen_counts: dict[str, int],
) -> str | None:
"""Return an escalated error after repeated bypass attempts."""
signature = workspace_violation_signature(tool_name, arguments)
if signature is None:
return None
count = seen_counts.get(signature, 0) + 1
seen_counts[signature] = count
if count <= _MAX_REPEAT_WORKSPACE_VIOLATIONS:
return None
logger.warning(
"Escalating repeated workspace bypass attempt {} (attempt {})",
signature[:160],
count,
)
target = signature.split("violation:", 1)[1] if "violation:" in signature else signature
return (
"Error: refusing repeated workspace-bypass attempts.\n"
f"You have tried to access '{target}' (or an equivalent path) "
f"{count} times in this turn. This is a hard policy boundary -- "
"switching tools, shell tricks, working_dir overrides, symlinks, "
"or base64 piping will NOT change the answer. Stop retrying. "
"If the user genuinely needs this resource, tell them you cannot "
"access it and ask how they want to proceed (e.g. copy the file "
"into the workspace, or disable restrict_to_workspace for this run)."
)

View File

@ -0,0 +1,138 @@
"""Helpers for WebUI chat title generation."""
from __future__ import annotations
import re
from typing import Any
from loguru import logger
from nanobot.providers.base import LLMProvider
from nanobot.session.manager import Session, SessionManager
from nanobot.utils.helpers import truncate_text
WEBUI_SESSION_METADATA_KEY = "webui"
WEBUI_TITLE_METADATA_KEY = "title"
WEBUI_TITLE_USER_EDITED_METADATA_KEY = "title_user_edited"
TITLE_MAX_CHARS = 60
def mark_webui_session(session: Session, metadata: dict[str, Any]) -> bool:
"""Persist a WebUI marker only when the inbound websocket frame opted in."""
if metadata.get(WEBUI_SESSION_METADATA_KEY) is not True:
return False
session.metadata[WEBUI_SESSION_METADATA_KEY] = True
return True
def clean_generated_title(raw: str | None) -> str:
text = (raw or "").strip()
if not text:
return ""
text = re.sub(r"^\s*(title|标题)\s*[:]\s*", "", text, flags=re.IGNORECASE)
text = text.strip().strip("\"'`“”‘’")
text = re.sub(r"\s+", " ", text).strip()
text = text.rstrip("。.!?,;:")
if len(text) > TITLE_MAX_CHARS:
text = text[: TITLE_MAX_CHARS - 1].rstrip() + ""
return text
def _title_inputs(session: Session) -> tuple[str, str]:
user_text = ""
assistant_text = ""
for message in session.messages:
role = message.get("role")
content = message.get("content")
if not isinstance(content, str) or not content.strip():
continue
if role == "user" and not user_text:
user_text = content.strip()
elif role == "assistant" and not assistant_text:
assistant_text = content.strip()
if user_text and assistant_text:
break
return user_text, assistant_text
async def maybe_generate_webui_title(
*,
sessions: SessionManager,
session_key: str,
provider: LLMProvider,
model: str,
) -> bool:
"""Generate and persist a short title for WebUI-owned sessions only."""
session = sessions.get_or_create(session_key)
if session.metadata.get(WEBUI_SESSION_METADATA_KEY) is not True:
return False
if session.metadata.get(WEBUI_TITLE_USER_EDITED_METADATA_KEY) is True:
return False
current_title = session.metadata.get(WEBUI_TITLE_METADATA_KEY)
if isinstance(current_title, str) and current_title.strip():
return False
user_text, assistant_text = _title_inputs(session)
if not user_text:
return False
prompt = (
"Generate a concise title for this chat.\n"
"Rules:\n"
"- Use the same language as the user when practical.\n"
"- 3 to 8 words.\n"
"- No quotes.\n"
"- No punctuation at the end.\n"
"- Return only the title.\n\n"
f"User: {truncate_text(user_text, 1_000)}"
)
if assistant_text:
prompt += f"\nAssistant: {truncate_text(assistant_text, 1_000)}"
try:
response = await provider.chat_with_retry(
[
{
"role": "system",
"content": (
"You write short, neutral chat titles. "
"Return only the title text."
),
},
{"role": "user", "content": prompt},
],
tools=None,
model=model,
max_tokens=32,
temperature=0.2,
retry_mode="standard",
)
except Exception:
logger.debug("Failed to generate webui session title for {}", session_key, exc_info=True)
return False
title = clean_generated_title(response.content)
if not title or title.lower().startswith("error"):
return False
session.metadata[WEBUI_TITLE_METADATA_KEY] = title
sessions.save(session)
return True
async def maybe_generate_webui_title_after_turn(
*,
channel: str,
metadata: dict[str, Any],
sessions: SessionManager,
session_key: str,
provider: LLMProvider,
model: str,
) -> bool:
if channel != "websocket" or metadata.get(WEBUI_SESSION_METADATA_KEY) is not True:
return False
return await maybe_generate_webui_title(
sessions=sessions,
session_key=session_key,
provider=provider,
model=model,
)

View File

@ -1,5 +1,6 @@
"""Tests for structured tool-event progress metadata emitted by AgentLoop.""" """Tests for structured tool-event progress metadata emitted by AgentLoop."""
import asyncio
from pathlib import Path from pathlib import Path
from unittest.mock import AsyncMock, MagicMock from unittest.mock import AsyncMock, MagicMock
@ -130,11 +131,44 @@ class TestToolEventProgress:
assert finish["result"] == "file.txt" assert finish["result"] == "file.txt"
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_bus_progress_streams_provider_deltas_for_codex_style_provider( async def test_non_streaming_channel_does_not_publish_codex_progress_deltas(
self, self,
tmp_path: Path, tmp_path: Path,
) -> None: ) -> None:
"""Providers that opt in can stream content deltas through _progress messages.""" """Non-streaming channels should get one final reply, not token progress spam."""
bus = MessageBus()
provider = MagicMock()
provider.supports_progress_deltas = True
provider.get_default_model.return_value = "openai-codex/gpt-5.5"
provider.chat_with_retry = AsyncMock(return_value=LLMResponse(content="Hello", tool_calls=[]))
provider.chat_stream_with_retry = AsyncMock()
loop = AgentLoop(bus=bus, provider=provider, workspace=tmp_path, model="openai-codex/gpt-5.5")
loop.tools.get_definitions = MagicMock(return_value=[])
loop.consolidator.maybe_consolidate_by_tokens = AsyncMock(return_value=False) # type: ignore[method-assign]
await loop._dispatch(InboundMessage(
channel="whatsapp",
sender_id="u1",
chat_id="chat1",
content="say hello",
))
outbound = []
while bus.outbound_size > 0:
outbound.append(await bus.consume_outbound())
assert [m.content for m in outbound] == ["Hello"]
assert not any(m.metadata.get("_progress") for m in outbound)
assert not any(m.metadata.get("_streamed") for m in outbound)
provider.chat_stream_with_retry.assert_not_awaited()
provider.chat_with_retry.assert_awaited_once()
@pytest.mark.asyncio
async def test_streaming_channel_streams_provider_deltas_for_codex_style_provider(
self,
tmp_path: Path,
) -> None:
"""Streaming channels still receive provider deltas through _stream_delta messages."""
bus = MessageBus() bus = MessageBus()
provider = MagicMock() provider = MagicMock()
provider.supports_progress_deltas = True provider.supports_progress_deltas = True
@ -149,23 +183,34 @@ class TestToolEventProgress:
provider.chat_with_retry = AsyncMock() provider.chat_with_retry = AsyncMock()
loop = AgentLoop(bus=bus, provider=provider, workspace=tmp_path, model="openai-codex/gpt-5.5") loop = AgentLoop(bus=bus, provider=provider, workspace=tmp_path, model="openai-codex/gpt-5.5")
loop.tools.get_definitions = MagicMock(return_value=[]) loop.tools.get_definitions = MagicMock(return_value=[])
loop.consolidator.maybe_consolidate_by_tokens = AsyncMock(return_value=False) # type: ignore[method-assign]
await loop._dispatch(InboundMessage( await loop._dispatch(InboundMessage(
channel="websocket", channel="websocket",
sender_id="u1", sender_id="u1",
chat_id="chat1", chat_id="chat1",
content="say hello", content="say hello",
metadata={"_wants_stream": True},
)) ))
outbound = [] outbound = []
while bus.outbound_size > 0: while bus.outbound_size > 0:
outbound.append(await bus.consume_outbound()) outbound.append(await bus.consume_outbound())
progress = [m for m in outbound if m.metadata.get("_progress")] deltas = [m for m in outbound if m.metadata.get("_stream_delta")]
final = [m for m in outbound if not m.metadata.get("_progress")] stream_end = [m for m in outbound if m.metadata.get("_stream_end")]
final = [
m for m in outbound
if not m.metadata.get("_stream_delta")
and not m.metadata.get("_stream_end")
and not m.metadata.get("_turn_end")
]
assert [m.content for m in progress] == ["Hel", "lo"] assert [m.content for m in deltas] == ["Hel", "lo"]
assert len(stream_end) == 1
assert final[-1].content == "Hello" assert final[-1].content == "Hello"
assert final[-1].metadata.get("_streamed") is True
assert outbound[-1].metadata.get("_turn_end") is True
provider.chat_with_retry.assert_not_awaited() provider.chat_with_retry.assert_not_awaited()
@pytest.mark.asyncio @pytest.mark.asyncio
@ -195,8 +240,12 @@ class TestToolEventProgress:
loop.tools.prepare_call = MagicMock(return_value=(None, {"path": "foo.txt"}, None)) loop.tools.prepare_call = MagicMock(return_value=(None, {"path": "foo.txt"}, None))
loop.tools.execute = AsyncMock(return_value="ok") loop.tools.execute = AsyncMock(return_value="ok")
streamed: list[str] = []
progress: list[tuple[str, bool, list[dict] | None]] = [] progress: list[tuple[str, bool, list[dict] | None]] = []
async def on_stream(delta: str) -> None:
streamed.append(delta)
async def on_progress( async def on_progress(
content: str, content: str,
*, *,
@ -205,12 +254,107 @@ class TestToolEventProgress:
) -> None: ) -> None:
progress.append((content, tool_hint, tool_events)) progress.append((content, tool_hint, tool_events))
final_content, _, _, _, _ = await loop._run_agent_loop([], on_progress=on_progress) final_content, _, _, _, _ = await loop._run_agent_loop(
[],
on_progress=on_progress,
on_stream=on_stream,
)
assert final_content == "Done" assert final_content == "Done"
assert [item[0] for item in progress[:3]] == [ assert streamed == ["I will", " inspect it."]
"I will", assert progress[0][0] == 'custom_tool("foo.txt")'
" inspect it.",
'custom_tool("foo.txt")',
]
assert all(item[0] != "I will inspect it." for item in progress) assert all(item[0] != "I will inspect it." for item in progress)
@pytest.mark.asyncio
async def test_websocket_dispatch_publishes_final_turn_end_marker(self, tmp_path: Path) -> None:
bus = MessageBus()
provider = MagicMock()
provider.get_default_model.return_value = "test-model"
provider.chat_with_retry = AsyncMock(return_value=LLMResponse(content="Done", tool_calls=[]))
loop = AgentLoop(bus=bus, provider=provider, workspace=tmp_path, model="test-model")
loop.tools.get_definitions = MagicMock(return_value=[])
loop.consolidator.maybe_consolidate_by_tokens = AsyncMock(return_value=False) # type: ignore[method-assign]
await loop._dispatch(InboundMessage(
channel="websocket",
sender_id="u1",
chat_id="chat1",
content="say hello",
))
outbound = []
while bus.outbound_size > 0:
outbound.append(await bus.consume_outbound())
assert outbound[-2].content == "Done"
assert (outbound[-2].metadata or {}).get("_turn_end") is not True
assert outbound[-1].content == ""
assert (outbound[-1].metadata or {}).get("_turn_end") is True
assert outbound[-1].chat_id == "chat1"
@pytest.mark.asyncio
async def test_webui_title_generation_runs_after_turn_end(self, tmp_path: Path) -> None:
bus = MessageBus()
provider = MagicMock()
provider.get_default_model.return_value = "test-model"
title_started = asyncio.Event()
release_title = asyncio.Event()
calls = 0
async def chat_with_retry(*_args: object, **_kwargs: object) -> LLMResponse:
nonlocal calls
calls += 1
if calls == 1:
return LLMResponse(content="Done", tool_calls=[])
title_started.set()
await release_title.wait()
return LLMResponse(content="Generated title", tool_calls=[])
provider.chat_with_retry = AsyncMock(side_effect=chat_with_retry)
loop = AgentLoop(bus=bus, provider=provider, workspace=tmp_path, model="test-model")
loop.tools.get_definitions = MagicMock(return_value=[])
loop.consolidator.maybe_consolidate_by_tokens = AsyncMock(return_value=False) # type: ignore[method-assign]
await asyncio.wait_for(loop._dispatch(InboundMessage(
channel="websocket",
sender_id="u1",
chat_id="chat1",
content="say hello",
metadata={"webui": True},
)), timeout=0.5)
outbound = [await bus.consume_outbound(), await bus.consume_outbound()]
assert outbound[0].content == "Done"
assert (outbound[1].metadata or {}).get("_turn_end") is True
await asyncio.wait_for(title_started.wait(), timeout=0.5)
release_title.set()
session_updated = await asyncio.wait_for(bus.consume_outbound(), timeout=0.5)
assert (session_updated.metadata or {}).get("_session_updated") is True
assert provider.chat_with_retry.await_count == 2
@pytest.mark.asyncio
async def test_non_websocket_dispatch_does_not_publish_turn_end_marker(self, tmp_path: Path) -> None:
bus = MessageBus()
provider = MagicMock()
provider.get_default_model.return_value = "test-model"
provider.chat_with_retry = AsyncMock(return_value=LLMResponse(content="Done", tool_calls=[]))
loop = AgentLoop(bus=bus, provider=provider, workspace=tmp_path, model="test-model")
loop.tools.get_definitions = MagicMock(return_value=[])
loop.consolidator.maybe_consolidate_by_tokens = AsyncMock(return_value=False) # type: ignore[method-assign]
await loop._dispatch(InboundMessage(
channel="slack",
sender_id="u1",
chat_id="chat1",
content="say hello",
))
outbound = []
while bus.outbound_size > 0:
outbound.append(await bus.consume_outbound())
assert len(outbound) == 1
assert outbound[0].content == "Done"
assert (outbound[0].metadata or {}).get("_turn_end") is not True

View File

@ -8,7 +8,13 @@ from nanobot.agent.context import ContextBuilder
from nanobot.agent.loop import AgentLoop from nanobot.agent.loop import AgentLoop
from nanobot.bus.events import InboundMessage from nanobot.bus.events import InboundMessage
from nanobot.bus.queue import MessageBus from nanobot.bus.queue import MessageBus
from nanobot.providers.base import LLMResponse
from nanobot.session.manager import Session from nanobot.session.manager import Session
from nanobot.utils.webui_titles import (
WEBUI_SESSION_METADATA_KEY,
WEBUI_TITLE_METADATA_KEY,
maybe_generate_webui_title,
)
def _mk_loop() -> AgentLoop: def _mk_loop() -> AgentLoop:
@ -22,9 +28,56 @@ def _mk_loop() -> AgentLoop:
def _make_full_loop(tmp_path: Path) -> AgentLoop: def _make_full_loop(tmp_path: Path) -> AgentLoop:
provider = MagicMock() provider = MagicMock()
provider.get_default_model.return_value = "test-model" provider.get_default_model.return_value = "test-model"
provider.chat_with_retry = AsyncMock(return_value=LLMResponse(content="Test title"))
return AgentLoop(bus=MessageBus(), provider=provider, workspace=tmp_path, model="test-model") return AgentLoop(bus=MessageBus(), provider=provider, workspace=tmp_path, model="test-model")
@pytest.mark.asyncio
async def test_generate_webui_title_only_for_marked_webui_sessions(tmp_path: Path) -> None:
loop = _make_full_loop(tmp_path)
loop.provider.chat_with_retry = AsyncMock(
return_value=LLMResponse(content='"优化 WebUI 侧边栏。"', finish_reason="stop")
)
session = loop.sessions.get_or_create("websocket:chat-title")
session.metadata[WEBUI_SESSION_METADATA_KEY] = True
session.add_message("user", "帮我优化一下 webui 的 sidebar")
session.add_message("assistant", "可以,我会先调整布局和视觉层级。")
loop.sessions.save(session)
generated = await maybe_generate_webui_title(
sessions=loop.sessions,
session_key="websocket:chat-title",
provider=loop.provider,
model=loop.model,
)
assert generated is True
assert session.metadata[WEBUI_TITLE_METADATA_KEY] == "优化 WebUI 侧边栏"
loop.provider.chat_with_retry.assert_awaited_once()
@pytest.mark.asyncio
async def test_generate_webui_title_skips_plain_websocket_sessions(tmp_path: Path) -> None:
loop = _make_full_loop(tmp_path)
loop.provider.chat_with_retry = AsyncMock(
return_value=LLMResponse(content="Plain websocket title", finish_reason="stop")
)
session = loop.sessions.get_or_create("websocket:custom-client")
session.add_message("user", "hello from a custom websocket client")
loop.sessions.save(session)
generated = await maybe_generate_webui_title(
sessions=loop.sessions,
session_key="websocket:custom-client",
provider=loop.provider,
model=loop.model,
)
assert generated is False
assert WEBUI_TITLE_METADATA_KEY not in session.metadata
loop.provider.chat_with_retry.assert_not_awaited()
def test_save_turn_skips_multimodal_user_when_only_runtime_context() -> None: def test_save_turn_skips_multimodal_user_when_only_runtime_context() -> None:
loop = _mk_loop() loop = _mk_loop()
session = Session(key="test:runtime-only") session = Session(key="test:runtime-only")

View File

@ -313,21 +313,33 @@ async def test_runner_returns_structured_tool_error():
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_runner_stops_on_workspace_violation_without_fail_on_tool_error(): async def test_runner_does_not_abort_on_workspace_violation_anymore():
"""v2 behavior: workspace-bound rejections are *soft* tool errors.
Previously (PR #3493) any workspace boundary error became a fatal
RuntimeError that aborted the turn. That silently killed legitimate
workspace commands once the heuristic guard misfired (#3599 #3605), so
we now hand the error back to the LLM as a recoverable tool result and
rely on ``repeated_workspace_violation_error`` to throttle bypass loops.
"""
from nanobot.agent.runner import AgentRunSpec, AgentRunner from nanobot.agent.runner import AgentRunSpec, AgentRunner
provider = MagicMock() provider = MagicMock()
provider.chat_with_retry = AsyncMock(side_effect=[ provider.chat_with_retry = AsyncMock(side_effect=[
LLMResponse( LLMResponse(
content="working", content="trying outside",
tool_calls=[ToolCallRequest(id="call_1", name="read_file", arguments={"path": "/tmp/outside.md"})], tool_calls=[ToolCallRequest(
id="call_1", name="read_file", arguments={"path": "/tmp/outside.md"},
)],
), ),
LLMResponse(content="should not continue", tool_calls=[]), LLMResponse(content="ok, telling the user instead", tool_calls=[]),
]) ])
tools = MagicMock() tools = MagicMock()
tools.get_definitions.return_value = [] tools.get_definitions.return_value = []
tools.execute = AsyncMock( tools.execute = AsyncMock(
side_effect=PermissionError("Path /tmp/outside.md is outside allowed directory /workspace") side_effect=PermissionError(
"Path /tmp/outside.md is outside allowed directory /workspace"
)
) )
runner = AgentRunner(provider) runner = AgentRunner(provider)
@ -336,41 +348,202 @@ async def test_runner_stops_on_workspace_violation_without_fail_on_tool_error():
initial_messages=[], initial_messages=[],
tools=tools, tools=tools,
model="test-model", model="test-model",
max_iterations=2, max_iterations=3,
max_tool_result_chars=_MAX_TOOL_RESULT_CHARS, max_tool_result_chars=_MAX_TOOL_RESULT_CHARS,
)) ))
assert provider.chat_with_retry.await_count == 1 assert provider.chat_with_retry.await_count == 2, (
assert result.stop_reason == "tool_error" "workspace violation must NOT short-circuit the loop"
assert "outside allowed directory" in (result.error or "") )
assert result.tool_events == [ assert result.stop_reason != "tool_error"
{ assert result.error is None
"name": "read_file", assert result.final_content == "ok, telling the user instead"
"status": "error", assert result.tool_events and result.tool_events[0]["status"] == "error"
"detail": "workspace_violation: Path /tmp/outside.md is outside allowed directory /workspace", # Detail still carries the workspace_violation breadcrumb for telemetry,
} # but the runner did not raise.
] assert "workspace_violation" in result.tool_events[0]["detail"]
def test_is_workspace_violation_recognizes_ssrf_block(): def test_is_ssrf_violation_recognizes_private_url_blocks():
"""Internal/private URL block must be classified as a fatal workspace violation. """SSRF rejections are classified separately from workspace boundaries."""
Regression guard: the deny/allowlist filter messages were intentionally split
out of `_WORKSPACE_BLOCK_MARKERS` so the LLM can retry, but SSRF rejections
are a hard security boundary and must remain fatal.
"""
from nanobot.agent.runner import AgentRunner from nanobot.agent.runner import AgentRunner
ssrf_msg = "Error: Command blocked by safety guard (internal/private URL detected)" ssrf_msg = "Error: Command blocked by safety guard (internal/private URL detected)"
assert AgentRunner._is_workspace_violation(ssrf_msg) is True assert AgentRunner._is_ssrf_violation(ssrf_msg) is True
assert AgentRunner._is_ssrf_violation(
"URL validation failed: Blocked: host resolves to private/internal address 192.168.1.2"
) is True
# Sanity: deny/allowlist filter messages are deliberately *not* fatal. # Workspace-bound markers are NOT classified as SSRF.
assert AgentRunner._is_workspace_violation( assert AgentRunner._is_ssrf_violation(
"Error: Command blocked by safety guard (path outside working dir)"
) is False
assert AgentRunner._is_ssrf_violation(
"Path /tmp/x is outside allowed directory /ws"
) is False
# Deny / allowlist filter messages stay non-fatal too.
assert AgentRunner._is_ssrf_violation(
"Error: Command blocked by deny pattern filter" "Error: Command blocked by deny pattern filter"
) is False ) is False
assert AgentRunner._is_workspace_violation(
"Error: Command blocked by allowlist filter (not in allowlist)"
) is False @pytest.mark.asyncio
async def test_runner_returns_non_retryable_hint_on_ssrf_violation():
"""SSRF stays blocked, but the runtime gives the LLM a final chance to recover."""
from nanobot.agent.runner import AgentRunSpec, AgentRunner
provider = MagicMock()
provider.chat_with_retry = AsyncMock(side_effect=[
LLMResponse(
content="curl-ing metadata",
tool_calls=[ToolCallRequest(
id="call_ssrf",
name="exec",
arguments={"command": "curl http://169.254.169.254"},
)],
),
LLMResponse(
content="I cannot access that private URL. Please share local files.",
tool_calls=[],
),
])
tools = MagicMock()
tools.get_definitions.return_value = []
tools.execute = AsyncMock(return_value=(
"Error: Command blocked by safety guard (internal/private URL detected)"
))
runner = AgentRunner(provider)
result = await runner.run(AgentRunSpec(
initial_messages=[],
tools=tools,
model="test-model",
max_iterations=3,
max_tool_result_chars=_MAX_TOOL_RESULT_CHARS,
))
assert provider.chat_with_retry.await_count == 2
assert result.stop_reason == "completed"
assert result.error is None
assert result.final_content == "I cannot access that private URL. Please share local files."
assert result.tool_events and result.tool_events[0]["detail"].startswith("ssrf_violation:")
tool_messages = [m for m in result.messages if m.get("role") == "tool"]
assert tool_messages
assert "non-bypassable security boundary" in tool_messages[0]["content"]
assert "Do not retry" in tool_messages[0]["content"]
assert "tools.ssrfWhitelist" in tool_messages[0]["content"]
@pytest.mark.asyncio
async def test_runner_lets_llm_recover_from_shell_guard_path_outside():
"""Reporter scenario for #3599 / #3605 -- guard hit, agent recovers.
The shell `_guard_command` heuristic fires on `2>/dev/null`-style
redirects and other shell idioms. Before v2 that abort'd the whole
turn (silent hang on Telegram per #3605); now the LLM gets the soft
error back and can finalize on the next iteration.
"""
from nanobot.agent.runner import AgentRunSpec, AgentRunner
provider = MagicMock()
captured_second_call: list[dict] = []
async def chat_with_retry(*, messages, **kwargs):
if provider.chat_with_retry.await_count == 1:
return LLMResponse(
content="trying noisy cleanup",
tool_calls=[ToolCallRequest(
id="call_blocked",
name="exec",
arguments={"command": "rm scratch.txt 2>/dev/null"},
)],
)
captured_second_call[:] = list(messages)
return LLMResponse(content="recovered final answer", tool_calls=[])
provider.chat_with_retry = AsyncMock(side_effect=chat_with_retry)
tools = MagicMock()
tools.get_definitions.return_value = []
tools.execute = AsyncMock(
return_value="Error: Command blocked by safety guard (path outside working dir)"
)
runner = AgentRunner(provider)
result = await runner.run(AgentRunSpec(
initial_messages=[],
tools=tools,
model="test-model",
max_iterations=3,
max_tool_result_chars=_MAX_TOOL_RESULT_CHARS,
))
assert provider.chat_with_retry.await_count == 2, (
"guard hit must NOT short-circuit the loop -- LLM should get a second turn"
)
assert result.stop_reason != "tool_error"
assert result.error is None
assert result.final_content == "recovered final answer"
assert result.tool_events and result.tool_events[0]["status"] == "error"
# v2: detail keeps the breadcrumb but the runner did not raise.
assert "workspace_violation" in result.tool_events[0]["detail"]
@pytest.mark.asyncio
async def test_runner_throttles_repeated_workspace_bypass_attempts():
"""#3493 motivation: stop the LLM bypass loop without aborting the turn.
LLM keeps switching tools (read_file -> exec cat -> python -c open(...))
against the same outside path. After the soft retry budget is exhausted
the runner replaces the tool result with a hard "stop trying" message
so the model finally gives up and surfaces the boundary to the user.
"""
from nanobot.agent.runner import AgentRunSpec, AgentRunner
bypass_attempts = [
ToolCallRequest(
id=f"a{i}", name="exec",
arguments={"command": f"cat /Users/x/Downloads/01.md # try {i}"},
)
for i in range(4)
]
responses: list[LLMResponse] = [
LLMResponse(content=f"try {i}", tool_calls=[bypass_attempts[i]])
for i in range(4)
]
responses.append(LLMResponse(content="ok telling user", tool_calls=[]))
provider = MagicMock()
provider.chat_with_retry = AsyncMock(side_effect=responses)
tools = MagicMock()
tools.get_definitions.return_value = []
tools.execute = AsyncMock(
return_value="Error: Command blocked by safety guard (path outside working dir)"
)
runner = AgentRunner(provider)
result = await runner.run(AgentRunSpec(
initial_messages=[],
tools=tools,
model="test-model",
max_iterations=10,
max_tool_result_chars=_MAX_TOOL_RESULT_CHARS,
))
# All 4 bypass attempts surface to the LLM (no fatal abort), and the
# runner finally completes once the LLM stops asking.
assert result.stop_reason != "tool_error"
assert result.error is None
assert result.final_content == "ok telling user"
# The third+ attempts must have been escalated -- look at the events.
escalated = [
ev for ev in result.tool_events
if ev["status"] == "error"
and ev["detail"].startswith("workspace_violation_escalated:")
]
assert escalated, (
"expected at least one escalated workspace_violation event, got: "
f"{result.tool_events}"
)
@pytest.mark.asyncio @pytest.mark.asyncio
@ -470,7 +643,7 @@ def test_persist_tool_result_logs_cleanup_failures(monkeypatch, tmp_path):
lambda *_args, **_kwargs: (_ for _ in ()).throw(OSError("busy")), lambda *_args, **_kwargs: (_ for _ in ()).throw(OSError("busy")),
) )
monkeypatch.setattr( monkeypatch.setattr(
"nanobot.utils.helpers.logger.warning", "nanobot.utils.helpers.logger.exception",
lambda message, *args: warnings.append(message.format(*args)), lambda message, *args: warnings.append(message.format(*args)),
) )
@ -851,6 +1024,7 @@ async def test_runner_batches_read_only_tools_before_exclusive_work():
ToolCallRequest(id="rw1", name="write_a", arguments={}), ToolCallRequest(id="rw1", name="write_a", arguments={}),
], ],
{}, {},
{},
) )
assert shared_events[0:2] == ["start:read_a", "start:read_b"] assert shared_events[0:2] == ["start:read_a", "start:read_b"]
@ -895,6 +1069,7 @@ async def test_runner_does_not_batch_exclusive_read_only_tools():
ToolCallRequest(id="ro2", name="read_b", arguments={}), ToolCallRequest(id="ro2", name="read_b", arguments={}),
], ],
{}, {},
{},
) )
assert shared_events[0] == "start:read_a" assert shared_events[0] == "start:read_a"
@ -1122,6 +1297,51 @@ async def test_streamed_flag_not_set_on_llm_error(tmp_path):
"_streamed must not be set when stop_reason is error" "_streamed must not be set when stop_reason is error"
@pytest.mark.asyncio
async def test_ssrf_soft_block_can_finalize_after_streamed_tool_call(tmp_path):
from nanobot.agent.loop import AgentLoop
from nanobot.bus.events import InboundMessage
from nanobot.bus.queue import MessageBus
bus = MessageBus()
provider = MagicMock()
provider.get_default_model.return_value = "test-model"
tool_call_resp = LLMResponse(
content="checking metadata",
tool_calls=[ToolCallRequest(
id="call_ssrf",
name="exec",
arguments={"command": "curl http://169.254.169.254/latest/meta-data/"},
)],
usage={},
)
provider.chat_stream_with_retry = AsyncMock(side_effect=[
tool_call_resp,
LLMResponse(
content="I cannot access private URLs. Please share the local file.",
tool_calls=[],
usage={},
),
])
loop = AgentLoop(bus=bus, provider=provider, workspace=tmp_path, model="test-model")
loop.tools.get_definitions = MagicMock(return_value=[])
loop.tools.prepare_call = MagicMock(return_value=(None, {}, None))
loop.tools.execute = AsyncMock(return_value=(
"Error: Command blocked by safety guard (internal/private URL detected)"
))
result = await loop._process_message(
InboundMessage(channel="telegram", sender_id="u1", chat_id="c1", content="hi"),
on_stream=AsyncMock(),
on_stream_end=AsyncMock(),
)
assert result is not None
assert result.content == "I cannot access private URLs. Please share the local file."
assert result.metadata.get("_streamed") is True
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_next_turn_after_llm_error_keeps_turn_boundary(tmp_path): async def test_next_turn_after_llm_error_keeps_turn_boundary(tmp_path):
from nanobot.agent.loop import AgentLoop from nanobot.agent.loop import AgentLoop

View File

@ -0,0 +1,79 @@
"""Tests for provider progress delta routing in the shared runner."""
from unittest.mock import AsyncMock, MagicMock
import pytest
from nanobot.agent.runner import AgentRunner, AgentRunSpec
from nanobot.config.schema import AgentDefaults
from nanobot.providers.base import LLMResponse
_MAX_TOOL_RESULT_CHARS = AgentDefaults().max_tool_result_chars
@pytest.mark.asyncio
async def test_runner_can_disable_provider_progress_delta_streaming():
"""AgentLoop disables token progress streaming for non-streaming channels."""
provider = MagicMock()
provider.supports_progress_deltas = True
provider.chat_with_retry = AsyncMock(
return_value=LLMResponse(content="done", tool_calls=[], usage={})
)
provider.chat_stream_with_retry = AsyncMock()
tools = MagicMock()
tools.get_definitions.return_value = []
progress_cb = AsyncMock()
runner = AgentRunner(provider)
result = await runner.run(AgentRunSpec(
initial_messages=[
{"role": "system", "content": "system"},
{"role": "user", "content": "hi"},
],
tools=tools,
model="test-model",
max_iterations=1,
max_tool_result_chars=_MAX_TOOL_RESULT_CHARS,
progress_callback=progress_cb,
stream_progress_deltas=False,
))
assert result.final_content == "done"
provider.chat_with_retry.assert_awaited_once()
provider.chat_stream_with_retry.assert_not_awaited()
progress_cb.assert_not_awaited()
@pytest.mark.asyncio
async def test_runner_streams_provider_progress_deltas_by_default():
"""Direct runner users keep the existing opt-in provider progress behavior."""
provider = MagicMock()
provider.supports_progress_deltas = True
async def chat_stream_with_retry(*, on_content_delta, **kwargs):
await on_content_delta("he")
await on_content_delta("llo")
return LLMResponse(content="hello", tool_calls=[], usage={})
provider.chat_stream_with_retry = chat_stream_with_retry
provider.chat_with_retry = AsyncMock()
tools = MagicMock()
tools.get_definitions.return_value = []
progress_cb = AsyncMock()
runner = AgentRunner(provider)
result = await runner.run(AgentRunSpec(
initial_messages=[
{"role": "system", "content": "system"},
{"role": "user", "content": "hi"},
],
tools=tools,
model="test-model",
max_iterations=1,
max_tool_result_chars=_MAX_TOOL_RESULT_CHARS,
progress_callback=progress_cb,
))
assert result.final_content == "hello"
assert [call.args[0] for call in progress_cb.await_args_list] == ["he", "llo"]
provider.chat_with_retry.assert_not_awaited()

View File

@ -1,4 +1,4 @@
from nanobot.session.manager import Session from nanobot.session.manager import Session, SessionManager
def _assert_no_orphans(history: list[dict]) -> None: def _assert_no_orphans(history: list[dict]) -> None:
@ -31,6 +31,18 @@ def _tool_turn(prefix: str, idx: int) -> list[dict]:
] ]
def test_list_sessions_includes_metadata_title(tmp_path):
manager = SessionManager(tmp_path)
session = manager.get_or_create("websocket:chat-title")
session.metadata["title"] = "自动生成标题"
manager.save(session)
rows = manager.list_sessions()
assert rows[0]["key"] == "websocket:chat-title"
assert rows[0]["title"] == "自动生成标题"
# --- Original regression test (from PR 2075) --- # --- Original regression test (from PR 2075) ---
def test_get_history_drops_orphan_tool_results_when_window_cuts_tool_calls(): def test_get_history_drops_orphan_tool_results_when_window_cuts_tool_calls():

View File

@ -1,14 +1,13 @@
from email.message import EmailMessage
from datetime import date
from pathlib import Path
import imaplib import imaplib
from datetime import date
from email.message import EmailMessage
from pathlib import Path
import pytest import pytest
from nanobot.bus.events import OutboundMessage from nanobot.bus.events import OutboundMessage
from nanobot.bus.queue import MessageBus from nanobot.bus.queue import MessageBus
from nanobot.channels.email import EmailChannel from nanobot.channels.email import EmailChannel, EmailConfig
from nanobot.channels.email import EmailConfig
def _make_config(**overrides) -> EmailConfig: def _make_config(**overrides) -> EmailConfig:
@ -24,6 +23,7 @@ def _make_config(**overrides) -> EmailConfig:
smtp_username="bot@example.com", smtp_username="bot@example.com",
smtp_password="secret", smtp_password="secret",
mark_seen=True, mark_seen=True,
allow_from=["*"],
# Disable auth verification by default so existing tests are unaffected # Disable auth verification by default so existing tests are unaffected
verify_dkim=False, verify_dkim=False,
verify_spf=False, verify_spf=False,
@ -707,8 +707,8 @@ def test_email_content_tagged_with_email_context(monkeypatch) -> None:
def test_check_authentication_results_method() -> None: def test_check_authentication_results_method() -> None:
"""Unit test for the _check_authentication_results static method.""" """Unit test for the _check_authentication_results static method."""
from email.parser import BytesParser
from email import policy from email import policy
from email.parser import BytesParser
# No Authentication-Results header # No Authentication-Results header
msg_no_auth = EmailMessage() msg_no_auth = EmailMessage()
@ -788,6 +788,32 @@ def _make_raw_email_with_attachment(
return msg.as_bytes() return msg.as_bytes()
def test_fetch_new_messages_ignores_unauthorized_sender_before_attachments(monkeypatch) -> None:
raw = _make_raw_email_with_attachment(from_addr="blocked@example.com")
fake = _make_fake_imap(raw)
monkeypatch.setattr("nanobot.channels.email.imaplib.IMAP4_SSL", lambda _h, _p: fake)
called = {"attachments": False}
def _extract_attachments(*_args, **_kwargs):
called["attachments"] = True
return []
monkeypatch.setattr(EmailChannel, "_extract_attachments", _extract_attachments)
cfg = _make_config(
allow_from=["allowed@example.com"],
allowed_attachment_types=["application/pdf"],
verify_dkim=False,
verify_spf=False,
)
channel = EmailChannel(cfg, MessageBus())
assert channel._fetch_new_messages() == []
assert called["attachments"] is False
assert fake.store_calls == [(b"1", "+FLAGS", "\\Seen")]
def test_extract_attachments_saves_pdf(tmp_path, monkeypatch) -> None: def test_extract_attachments_saves_pdf(tmp_path, monkeypatch) -> None:
"""PDF attachment is saved to media dir and path returned in media list.""" """PDF attachment is saved to media dir and path returned in media list."""
monkeypatch.setattr("nanobot.channels.email.get_media_dir", lambda ch: tmp_path) monkeypatch.setattr("nanobot.channels.email.get_media_dir", lambda ch: tmp_path)

View File

@ -445,6 +445,58 @@ async def test_on_message_no_extra_api_call_when_no_parent_id() -> None:
assert len(captured) == 1 assert len(captured) == 1
# ---------------------------------------------------------------------------
# Inbound media tests
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_on_message_audio_publishes_downloaded_path_and_transcription() -> None:
channel = _make_feishu_channel()
channel._processed_message_ids.clear()
captured = []
async def capture(msg):
captured.append(msg)
channel.bus.publish_inbound = capture
channel._download_and_save_media = AsyncMock(
return_value=(r"C:\\Users\\dodre\\.nanobot\\media\\feishu\\voice.ogg", "[audio: voice.ogg]")
)
channel.transcribe_audio = AsyncMock(return_value="hello from voice")
channel._add_reaction = AsyncMock(return_value=None)
event = _make_feishu_event(
msg_type="audio",
content='{"file_key": "audio_key", "duration": 1000}',
message_id="om_audio",
)
await channel._on_message(event)
channel._download_and_save_media.assert_awaited_once_with(
"audio", {"file_key": "audio_key", "duration": 1000}, "om_audio"
)
channel.transcribe_audio.assert_awaited_once_with(r"C:\\Users\\dodre\\.nanobot\\media\\feishu\\voice.ogg")
assert len(captured) == 1
assert captured[0].media == [r"C:\\Users\\dodre\\.nanobot\\media\\feishu\\voice.ogg"]
assert captured[0].content == "[transcription: hello from voice]"
@pytest.mark.asyncio
async def test_download_and_save_media_returns_absolute_path_in_content(monkeypatch, tmp_path) -> None:
channel = _make_feishu_channel()
monkeypatch.setattr(feishu, "get_media_dir", lambda _channel: tmp_path)
channel._download_file_sync = MagicMock(return_value=(b"voice-bytes", None))
file_path, content_text = await channel._download_and_save_media(
"audio", {"file_key": "voice_key"}, "om_audio"
)
assert file_path == str(tmp_path / "voice_key.ogg")
assert (tmp_path / "voice_key.ogg").read_bytes() == b"voice-bytes"
assert content_text == f"[audio: {file_path}]"
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Session key derivation tests # Session key derivation tests
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@ -754,3 +806,26 @@ def test_on_background_task_done_removes_from_set() -> None:
loop.close() loop.close()
assert task not in channel._background_tasks assert task not in channel._background_tasks
@pytest.mark.asyncio
async def test_on_message_ignores_unauthorized_sender_before_side_effects() -> None:
channel = _make_feishu_channel(group_policy="open")
channel.config.allow_from = ["ou_allowed"]
channel._add_reaction = AsyncMock()
channel._download_and_save_media = AsyncMock(return_value=("/tmp/audio.ogg", "[audio]"))
channel.transcribe_audio = AsyncMock(return_value="transcript")
channel._handle_message = AsyncMock()
event = _make_feishu_event(
msg_type="audio",
content='{"file_key": "file_1"}',
sender_open_id="ou_blocked",
)
await channel._on_message(event)
channel._add_reaction.assert_not_awaited()
channel._download_and_save_media.assert_not_awaited()
channel.transcribe_audio.assert_not_awaited()
channel._handle_message.assert_not_awaited()

View File

@ -1,7 +1,7 @@
"""Tests for QQ channel media support: helpers, send, inbound, and upload.""" """Tests for QQ channel media support: helpers, send, inbound, and upload."""
from types import SimpleNamespace from types import SimpleNamespace
from unittest.mock import AsyncMock, MagicMock, patch from unittest.mock import AsyncMock, patch
import pytest import pytest
@ -182,6 +182,35 @@ async def test_send_media_failure_falls_back_to_text() -> None:
assert "bad.png" in failure_calls[0]["content"] assert "bad.png" in failure_calls[0]["content"]
@pytest.mark.asyncio
async def test_on_message_ignores_unauthorized_sender_before_attachments_and_ack() -> None:
channel = QQChannel(
QQConfig(
app_id="app",
secret="secret",
allow_from=["allowed-user"],
ack_message="Processing...",
),
MessageBus(),
)
channel._client = _FakeClient()
channel._handle_attachments = AsyncMock(return_value=(["/tmp/a.png"], ["file"], []))
channel._handle_message = AsyncMock()
data = SimpleNamespace(
id="msg-blocked",
content="hello",
author=SimpleNamespace(user_openid="blocked-user"),
attachments=[SimpleNamespace(filename="a.png")],
)
await channel._on_message(data, is_group=False)
channel._handle_attachments.assert_not_awaited()
channel._handle_message.assert_not_awaited()
assert channel._client.api.c2c_calls == []
# ── _on_message() exception handling ──────────────────────────────── # ── _on_message() exception handling ────────────────────────────────

View File

@ -306,17 +306,19 @@ async def test_on_error_logs_network_issues_as_warning(monkeypatch) -> None:
recorded: list[tuple[str, str]] = [] recorded: list[tuple[str, str]] = []
monkeypatch.setattr( monkeypatch.setattr(
"nanobot.channels.telegram.logger.warning", channel.logger,
"warning",
lambda message, error: recorded.append(("warning", message.format(error))), lambda message, error: recorded.append(("warning", message.format(error))),
) )
monkeypatch.setattr( monkeypatch.setattr(
"nanobot.channels.telegram.logger.error", channel.logger,
"error",
lambda message, error: recorded.append(("error", message.format(error))), lambda message, error: recorded.append(("error", message.format(error))),
) )
await channel._on_error(object(), SimpleNamespace(error=NetworkError("proxy disconnected"))) await channel._on_error(object(), SimpleNamespace(error=NetworkError("proxy disconnected")))
assert recorded == [("warning", "Telegram network issue: proxy disconnected")] assert recorded == [("warning", "network issue: proxy disconnected")]
@pytest.mark.asyncio @pytest.mark.asyncio
@ -330,13 +332,14 @@ async def test_on_error_summarizes_empty_network_error(monkeypatch) -> None:
recorded: list[tuple[str, str]] = [] recorded: list[tuple[str, str]] = []
monkeypatch.setattr( monkeypatch.setattr(
"nanobot.channels.telegram.logger.warning", channel.logger,
"warning",
lambda message, error: recorded.append(("warning", message.format(error))), lambda message, error: recorded.append(("warning", message.format(error))),
) )
await channel._on_error(object(), SimpleNamespace(error=NetworkError(""))) await channel._on_error(object(), SimpleNamespace(error=NetworkError("")))
assert recorded == [("warning", "Telegram network issue: NetworkError")] assert recorded == [("warning", "network issue: NetworkError")]
@pytest.mark.asyncio @pytest.mark.asyncio
@ -348,17 +351,19 @@ async def test_on_error_keeps_non_network_exceptions_as_error(monkeypatch) -> No
recorded: list[tuple[str, str]] = [] recorded: list[tuple[str, str]] = []
monkeypatch.setattr( monkeypatch.setattr(
"nanobot.channels.telegram.logger.warning", channel.logger,
"warning",
lambda message, error: recorded.append(("warning", message.format(error))), lambda message, error: recorded.append(("warning", message.format(error))),
) )
monkeypatch.setattr( monkeypatch.setattr(
"nanobot.channels.telegram.logger.error", channel.logger,
"error",
lambda message, error: recorded.append(("error", message.format(error))), lambda message, error: recorded.append(("error", message.format(error))),
) )
await channel._on_error(object(), SimpleNamespace(error=RuntimeError("boom"))) await channel._on_error(object(), SimpleNamespace(error=RuntimeError("boom")))
assert recorded == [("error", "Telegram error: boom")] assert recorded == [("error", "error: boom")]
@pytest.mark.asyncio @pytest.mark.asyncio
@ -1309,6 +1314,58 @@ async def test_on_help_includes_restart_command() -> None:
assert "/dream-restore" in help_text assert "/dream-restore" in help_text
@pytest.mark.asyncio
async def test_on_start_ignores_unauthorized_user_silently() -> None:
channel = TelegramChannel(
TelegramConfig(enabled=True, token="123:abc", allow_from=["999"], group_policy="open"),
MessageBus(),
)
update = _make_telegram_update(text="/start", chat_type="private")
update.message.reply_text = AsyncMock()
await channel._on_start(update, None)
update.message.reply_text.assert_not_awaited()
@pytest.mark.asyncio
async def test_on_help_ignores_unauthorized_user_silently() -> None:
channel = TelegramChannel(
TelegramConfig(enabled=True, token="123:abc", allow_from=["999"], group_policy="open"),
MessageBus(),
)
update = _make_telegram_update(text="/help", chat_type="private")
update.message.reply_text = AsyncMock()
await channel._on_help(update, None)
update.message.reply_text.assert_not_awaited()
@pytest.mark.asyncio
async def test_on_message_ignores_unauthorized_user_before_side_effects() -> None:
channel = TelegramChannel(
TelegramConfig(enabled=True, token="123:abc", allow_from=["999"], group_policy="open"),
MessageBus(),
)
channel._app = _FakeApp(lambda: None)
started_typing: list[str] = []
handled: list[dict] = []
channel._start_typing = lambda chat_id: started_typing.append(chat_id)
channel._add_reaction = AsyncMock(return_value=None)
async def capture_handle(**kwargs) -> None:
handled.append(kwargs)
channel._handle_message = capture_handle
await channel._on_message(_make_telegram_update(text="hello", chat_type="private"), None)
assert started_typing == []
channel._add_reaction.assert_not_awaited()
assert handled == []
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_on_message_location_content() -> None: async def test_on_message_location_content() -> None:
"""Location messages are forwarded as [location: lat, lon] content.""" """Location messages are forwarded as [location: lat, lon] content."""
@ -1750,3 +1807,32 @@ async def test_send_uses_native_keyboard_when_flag_on() -> None:
sent = channel._app.bot.sent_messages[0] sent = channel._app.bot.sent_messages[0]
assert isinstance(sent.get("reply_markup"), InlineKeyboardMarkup) assert isinstance(sent.get("reply_markup"), InlineKeyboardMarkup)
assert "[Yes]" not in sent["text"] # native keyboard owns the rendering assert "[Yes]" not in sent["text"] # native keyboard owns the rendering
@pytest.mark.asyncio
async def test_callback_query_ignores_unauthorized_user_before_side_effects() -> None:
channel = TelegramChannel(
TelegramConfig(enabled=True, token="123:abc", allow_from=["999"], inline_keyboards=True),
MessageBus(),
)
channel._handle_message = AsyncMock()
query = SimpleNamespace(
id="cb_1",
data="Yes",
answer=AsyncMock(),
message=SimpleNamespace(
chat_id=123,
edit_reply_markup=AsyncMock(),
),
)
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_not_awaited()
query.message.edit_reply_markup.assert_not_awaited()
channel._handle_message.assert_not_awaited()

View File

@ -167,6 +167,40 @@ def test_issue_route_secret_matches_empty_secret() -> None:
assert _issue_route_secret_matches(Headers([("Authorization", "Bearer anything")]), "") is True assert _issue_route_secret_matches(Headers([("Authorization", "Bearer anything")]), "") is True
@pytest.mark.asyncio
async def test_webui_message_envelope_marks_inbound_metadata(bus: MagicMock) -> None:
channel = _ch(bus)
conn = MagicMock()
conn.remote_address = ("127.0.0.1", 50123)
await channel._dispatch_envelope(
conn,
"webui-client",
{"type": "message", "chat_id": "chat-1", "content": "hello", "webui": True},
)
msg = bus.publish_inbound.await_args.args[0]
assert msg.channel == "websocket"
assert msg.chat_id == "chat-1"
assert msg.metadata["webui"] is True
assert msg.metadata["_wants_stream"] is True
@pytest.mark.asyncio
async def test_plain_websocket_message_does_not_mark_webui(bus: MagicMock) -> None:
channel = _ch(bus)
conn = MagicMock()
await channel._dispatch_envelope(
conn,
"custom-client",
{"type": "message", "chat_id": "chat-1", "content": "hello"},
)
msg = bus.publish_inbound.await_args.args[0]
assert "webui" not in msg.metadata
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_send_delivers_json_message_with_media_and_reply() -> None: async def test_send_delivers_json_message_with_media_and_reply() -> None:
bus = MagicMock() bus = MagicMock()
@ -287,6 +321,44 @@ async def test_send_delta_emits_delta_and_stream_end() -> None:
assert second["stream_id"] == "sid" assert second["stream_id"] == "sid"
@pytest.mark.asyncio
async def test_send_turn_end_emits_turn_end_event() -> None:
bus = MagicMock()
channel = WebSocketChannel({"enabled": True, "allowFrom": ["*"]}, bus)
mock_ws = AsyncMock()
channel._attach(mock_ws, "chat-1")
await channel.send(OutboundMessage(
channel="websocket",
chat_id="chat-1",
content="",
metadata={"_turn_end": True},
))
mock_ws.send.assert_awaited_once()
body = json.loads(mock_ws.send.await_args.args[0])
assert body == {"event": "turn_end", "chat_id": "chat-1"}
@pytest.mark.asyncio
async def test_send_session_updated_emits_session_updated_event() -> None:
bus = MagicMock()
channel = WebSocketChannel({"enabled": True, "allowFrom": ["*"]}, bus)
mock_ws = AsyncMock()
channel._attach(mock_ws, "chat-1")
await channel.send(OutboundMessage(
channel="websocket",
chat_id="chat-1",
content="",
metadata={"_session_updated": True},
))
mock_ws.send.assert_awaited_once()
body = json.loads(mock_ws.send.await_args.args[0])
assert body == {"event": "session_updated", "chat_id": "chat-1"}
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_send_non_connection_closed_exception_is_raised() -> None: async def test_send_non_connection_closed_exception_is_raised() -> None:
bus = MagicMock() bus = MagicMock()
@ -491,6 +563,34 @@ async def test_settings_api_returns_safe_subset_and_updates_whitelist(
await server_task await server_task
@pytest.mark.asyncio
async def test_commands_api_returns_slash_command_metadata(bus: MagicMock) -> None:
port = 29892
channel = _ch(bus, port=port)
channel._api_tokens["tok"] = time.monotonic() + 300
server_task = asyncio.create_task(channel.start())
await asyncio.sleep(0.3)
try:
denied = await _http_get(f"http://127.0.0.1:{port}/api/commands")
assert denied.status_code == 401
response = await _http_get(
f"http://127.0.0.1:{port}/api/commands",
headers={"Authorization": "Bearer tok"},
)
assert response.status_code == 200
body = response.json()
commands = {row["command"]: row for row in body["commands"]}
assert commands["/stop"]["title"] == "Stop current task"
assert commands["/history"]["arg_hint"] == "[n]"
assert all("description" in row for row in body["commands"])
finally:
await channel.stop()
await server_task
def test_settings_payload_normalizes_camel_case_provider( def test_settings_payload_normalizes_camel_case_provider(
bus: MagicMock, bus: MagicMock,
monkeypatch, monkeypatch,
@ -545,6 +645,16 @@ async def test_end_to_end_server_pushes_streaming_deltas_to_client(bus: MagicMoc
end = json.loads(await client.recv()) end = json.loads(await client.recv())
assert end["event"] == "stream_end" assert end["event"] == "stream_end"
assert end["stream_id"] == "s1" assert end["stream_id"] == "s1"
await channel.send(OutboundMessage(
channel="websocket",
chat_id=chat_id,
content="",
metadata={"_turn_end": True},
))
turn_end = json.loads(await client.recv())
assert turn_end == {"event": "turn_end", "chat_id": chat_id}
finally: finally:
await channel.stop() await channel.stop()
await server_task await server_task

View File

@ -379,3 +379,111 @@ async def test_api_token_pool_purges_expired(bus: MagicMock, tmp_path: Path) ->
headers = {"Authorization": "Bearer live"} headers = {"Authorization": "Bearer live"}
assert channel._check_api_token(_LiveReq()) is True assert channel._check_api_token(_LiveReq()) is True
class _FakeConn:
"""Minimal connection stub with a configurable remote_address."""
def __init__(self, remote_address: tuple[str, int]):
self.remote_address = remote_address
def respond(self, status: int, body: str) -> Any:
from websockets.http11 import Response
return Response(status=status, body=body.encode())
class _FakeReq:
"""Minimal request stub with configurable headers."""
def __init__(self, headers: dict[str, str] | None = None):
self.headers = headers or {}
_REMOTE = _FakeConn(("192.168.1.5", 12345))
_LOCAL = _FakeConn(("127.0.0.1", 12345))
_NO_HEADERS = _FakeReq()
def test_wildcard_host_without_auth_raises_on_startup(bus: MagicMock) -> None:
import pytest
from pydantic_core import ValidationError
with pytest.raises(ValidationError, match="token"):
_ch(bus, host="0.0.0.0")
def test_wildcard_host_with_token_is_valid(bus: MagicMock) -> None:
channel = _ch(bus, host="0.0.0.0", token="my-token")
assert channel.config.host == "0.0.0.0"
def test_wildcard_host_with_secret_is_valid(bus: MagicMock) -> None:
channel = _ch(bus, host="0.0.0.0", tokenIssueSecret="s3cret")
assert channel.config.host == "0.0.0.0"
def test_wildcard_ipv6_without_auth_raises(bus: MagicMock) -> None:
import pytest
from pydantic_core import ValidationError
with pytest.raises(ValidationError, match="token"):
_ch(bus, host="::")
def test_wildcard_ipv6_with_secret_is_valid(bus: MagicMock) -> None:
channel = _ch(bus, host="::", tokenIssueSecret="s3cret")
resp = channel._handle_webui_bootstrap(
_REMOTE, _FakeReq({"X-Nanobot-Auth": "s3cret"})
)
assert resp.status_code == 200
def test_bootstrap_accepts_static_token_as_secret(bus: MagicMock) -> None:
"""When only token (not token_issue_secret) is set, bootstrap accepts it."""
channel = _ch(bus, host="0.0.0.0", token="static-tok")
resp = channel._handle_webui_bootstrap(
_REMOTE, _FakeReq({"Authorization": "Bearer static-tok"})
)
assert resp.status_code == 200
body = json.loads(resp.body)
assert body["token"].startswith("nbwt_")
def test_localhost_without_auth_is_valid(bus: MagicMock) -> None:
channel = _ch(bus, host="127.0.0.1")
resp = channel._handle_webui_bootstrap(_LOCAL, _NO_HEADERS)
assert resp.status_code == 200
def test_bootstrap_rejects_wrong_secret(bus: MagicMock) -> None:
channel = _ch(bus, host="0.0.0.0", tokenIssueSecret="correct")
resp = channel._handle_webui_bootstrap(
_REMOTE, _FakeReq({"Authorization": "Bearer wrong"})
)
assert resp.status_code == 401
def test_bootstrap_accepts_remote_with_valid_secret(bus: MagicMock) -> None:
channel = _ch(bus, host="0.0.0.0", tokenIssueSecret="s3cret")
resp = channel._handle_webui_bootstrap(
_REMOTE, _FakeReq({"Authorization": "Bearer s3cret"})
)
assert resp.status_code == 200
body = json.loads(resp.body)
assert body["token"].startswith("nbwt_")
def test_bootstrap_accepts_x_nanobot_auth_header(bus: MagicMock) -> None:
channel = _ch(bus, host="0.0.0.0", tokenIssueSecret="s3cret")
resp = channel._handle_webui_bootstrap(
_REMOTE, _FakeReq({"X-Nanobot-Auth": "s3cret"})
)
assert resp.status_code == 200
def test_bootstrap_secret_also_enforced_on_localhost(bus: MagicMock) -> None:
"""When secret is set, even localhost must provide it (reverse-proxy safety)."""
channel = _ch(bus, host="0.0.0.0", tokenIssueSecret="s3cret")
resp = channel._handle_webui_bootstrap(_LOCAL, _NO_HEADERS)
assert resp.status_code == 401

View File

@ -3,7 +3,6 @@
import os import os
import tempfile import tempfile
from pathlib import Path from pathlib import Path
from types import SimpleNamespace
from unittest.mock import AsyncMock, MagicMock, patch from unittest.mock import AsyncMock, MagicMock, patch
import pytest import pytest
@ -451,6 +450,39 @@ async def test_process_text_message() -> None:
assert msg.metadata["msg_type"] == "text" assert msg.metadata["msg_type"] == "text"
@pytest.mark.asyncio
async def test_enter_chat_ignores_unauthorized_user_before_welcome() -> None:
channel = WecomChannel(WecomConfig(bot_id="b", secret="s", allow_from=["allowed"]), MessageBus())
client = _FakeWeComClient()
channel._client = client
channel.config.welcome_message = "hello"
await channel._on_enter_chat(_FakeFrame(body={"chatid": "blocked"}))
client.reply_welcome.assert_not_awaited()
@pytest.mark.asyncio
async def test_process_message_ignores_unauthorized_sender_before_download() -> None:
channel = WecomChannel(WecomConfig(bot_id="b", secret="s", allow_from=["allowed"]), MessageBus())
client = _FakeWeComClient()
channel._client = client
channel._handle_message = AsyncMock()
frame = _FakeFrame(body={
"msgid": "msg_blocked",
"chatid": "chat1",
"from": {"userid": "blocked"},
"image": {"url": "https://example.com/img.png", "aeskey": "key123"},
})
await channel._process_message(frame, "image")
client.download_file.assert_not_awaited()
channel._handle_message.assert_not_awaited()
assert channel.bus.inbound_size == 0
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_process_image_message() -> None: async def test_process_image_message() -> None:
"""Image message: download success → media_paths non-empty.""" """Image message: download success → media_paths non-empty."""

View File

@ -5,8 +5,8 @@ from pathlib import Path
from types import SimpleNamespace from types import SimpleNamespace
from unittest.mock import AsyncMock from unittest.mock import AsyncMock
import pytest
import httpx import httpx
import pytest
import nanobot.channels.weixin as weixin_mod import nanobot.channels.weixin as weixin_mod
from nanobot.bus.queue import MessageBus from nanobot.bus.queue import MessageBus
@ -15,10 +15,10 @@ from nanobot.channels.weixin import (
ITEM_TEXT, ITEM_TEXT,
MESSAGE_TYPE_BOT, MESSAGE_TYPE_BOT,
WEIXIN_CHANNEL_VERSION, WEIXIN_CHANNEL_VERSION,
_decrypt_aes_ecb,
_encrypt_aes_ecb,
WeixinChannel, WeixinChannel,
WeixinConfig, WeixinConfig,
_decrypt_aes_ecb,
_encrypt_aes_ecb,
) )
@ -128,6 +128,34 @@ async def test_process_message_caches_context_token_and_send_uses_it() -> None:
channel._send_text.assert_awaited_once_with("wx-user", "pong", "ctx-2") channel._send_text.assert_awaited_once_with("wx-user", "pong", "ctx-2")
@pytest.mark.asyncio
async def test_process_message_ignores_unauthorized_sender_before_side_effects(tmp_path) -> None:
bus = MessageBus()
channel = WeixinChannel(
WeixinConfig(enabled=True, allow_from=["allowed-user"], state_dir=str(tmp_path)),
bus,
)
channel._download_media_item = AsyncMock(return_value="/tmp/test.jpg")
channel._start_typing = AsyncMock()
await channel._process_message(
{
"message_type": 1,
"message_id": "m-unauthorized",
"from_user_id": "blocked-user",
"context_token": "ctx-blocked",
"item_list": [
{"type": ITEM_IMAGE, "image_item": {"media": {"encrypt_query_param": "x"}}},
],
}
)
assert channel._context_tokens == {}
channel._download_media_item.assert_not_awaited()
channel._start_typing.assert_not_awaited()
assert bus.inbound_size == 0
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_process_message_persists_context_token_to_state_file(tmp_path) -> None: async def test_process_message_persists_context_token_to_state_file(tmp_path) -> None:
bus = MessageBus() bus = MessageBus()
@ -291,21 +319,22 @@ async def test_process_message_does_not_fallback_when_top_level_media_exists_but
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_send_without_context_token_does_not_send_text() -> None: async def test_send_without_context_token_raises() -> None:
channel, _bus = _make_channel() channel, _bus = _make_channel()
channel._client = object() channel._client = object()
channel._token = "token" channel._token = "token"
channel._send_text = AsyncMock() channel._send_text = AsyncMock()
await channel.send( with pytest.raises(RuntimeError, match="context_token missing"):
type("Msg", (), {"chat_id": "unknown-user", "content": "pong", "media": [], "metadata": {}})() await channel.send(
) type("Msg", (), {"chat_id": "unknown-user", "content": "pong", "media": [], "metadata": {}})()
)
channel._send_text.assert_not_awaited() channel._send_text.assert_not_awaited()
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_send_does_not_send_when_session_is_paused() -> None: async def test_send_raises_when_session_is_paused() -> None:
channel, _bus = _make_channel() channel, _bus = _make_channel()
channel._client = object() channel._client = object()
channel._token = "token" channel._token = "token"
@ -313,9 +342,10 @@ async def test_send_does_not_send_when_session_is_paused() -> None:
channel._pause_session(60) channel._pause_session(60)
channel._send_text = AsyncMock() channel._send_text = AsyncMock()
await channel.send( with pytest.raises(RuntimeError, match="session paused"):
type("Msg", (), {"chat_id": "wx-user", "content": "pong", "media": [], "metadata": {}})() await channel.send(
) type("Msg", (), {"chat_id": "wx-user", "content": "pong", "media": [], "metadata": {}})()
)
channel._send_text.assert_not_awaited() channel._send_text.assert_not_awaited()
@ -1185,3 +1215,38 @@ async def test_send_media_network_error_does_not_double_api_calls() -> None:
# _send_media_file called once, _send_text never called # _send_media_file called once, _send_text never called
channel._send_media_file.assert_awaited_once() channel._send_media_file.assert_awaited_once()
channel._send_text.assert_not_awaited() channel._send_text.assert_not_awaited()
# ---------------------------------------------------------------------------
# Tests for _send_text raising on API errors (previously silently swallowed)
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_send_text_raises_on_api_error() -> None:
"""_send_text must raise RuntimeError when the API returns a non-zero errcode,
matching _send_media_file behavior. This ensures ChannelManager can retry."""
channel, _bus = _make_channel()
channel._client = object()
channel._token = "token"
channel._api_post = AsyncMock(
return_value={"errcode": -14, "errmsg": "session expired"}
)
with pytest.raises(RuntimeError, match="WeChat send text error.*-14"):
await channel._send_text("wx-user", "hello", "ctx-expired")
channel._api_post.assert_awaited_once()
@pytest.mark.asyncio
async def test_send_text_succeeds_on_zero_errcode() -> None:
"""_send_text must NOT raise when errcode is 0."""
channel, _bus = _make_channel()
channel._client = object()
channel._token = "token"
channel._api_post = AsyncMock(return_value={"errcode": 0})
await channel._send_text("wx-user", "hello", "ctx-ok")
channel._api_post.assert_awaited_once()

View File

@ -116,7 +116,7 @@ async def test_send_when_disconnected_is_noop():
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_group_policy_mention_skips_unmentioned_group_message(): async def test_group_policy_mention_skips_unmentioned_group_message():
ch = WhatsAppChannel({"enabled": True, "groupPolicy": "mention"}, MagicMock()) ch = WhatsAppChannel({"enabled": True, "allowFrom": ["*"], "groupPolicy": "mention"}, MagicMock())
ch._handle_message = AsyncMock() ch._handle_message = AsyncMock()
await ch._handle_bridge_message( await ch._handle_bridge_message(
@ -139,7 +139,7 @@ async def test_group_policy_mention_skips_unmentioned_group_message():
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_group_policy_mention_accepts_mentioned_group_message(): async def test_group_policy_mention_accepts_mentioned_group_message():
ch = WhatsAppChannel({"enabled": True, "groupPolicy": "mention"}, MagicMock()) ch = WhatsAppChannel({"enabled": True, "allowFrom": ["*"], "groupPolicy": "mention"}, MagicMock())
ch._handle_message = AsyncMock() ch._handle_message = AsyncMock()
await ch._handle_bridge_message( await ch._handle_bridge_message(
@ -166,7 +166,7 @@ async def test_group_policy_mention_accepts_mentioned_group_message():
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_sender_id_prefers_phone_jid_over_lid(): async def test_sender_id_prefers_phone_jid_over_lid():
"""sender_id should resolve to phone number when @s.whatsapp.net JID is present.""" """sender_id should resolve to phone number when @s.whatsapp.net JID is present."""
ch = WhatsAppChannel({"enabled": True}, MagicMock()) ch = WhatsAppChannel({"enabled": True, "allowFrom": ["*"]}, MagicMock())
ch._handle_message = AsyncMock() ch._handle_message = AsyncMock()
await ch._handle_bridge_message( await ch._handle_bridge_message(
@ -187,7 +187,7 @@ async def test_sender_id_prefers_phone_jid_over_lid():
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_lid_to_phone_cache_resolves_lid_only_messages(): async def test_lid_to_phone_cache_resolves_lid_only_messages():
"""When only LID is present, a cached LID→phone mapping should be used.""" """When only LID is present, a cached LID→phone mapping should be used."""
ch = WhatsAppChannel({"enabled": True}, MagicMock()) ch = WhatsAppChannel({"enabled": True, "allowFrom": ["*"]}, MagicMock())
ch._handle_message = AsyncMock() ch._handle_message = AsyncMock()
# First message: both phone and LID → builds cache # First message: both phone and LID → builds cache
@ -220,7 +220,7 @@ async def test_lid_to_phone_cache_resolves_lid_only_messages():
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_voice_message_transcription_uses_media_path(): async def test_voice_message_transcription_uses_media_path():
"""Voice messages are transcribed when media path is available.""" """Voice messages are transcribed when media path is available."""
ch = WhatsAppChannel({"enabled": True}, MagicMock()) ch = WhatsAppChannel({"enabled": True, "allowFrom": ["*"]}, MagicMock())
ch.transcription_provider = "openai" ch.transcription_provider = "openai"
ch.transcription_api_key = "sk-test" ch.transcription_api_key = "sk-test"
ch._handle_message = AsyncMock() ch._handle_message = AsyncMock()
@ -243,10 +243,32 @@ async def test_voice_message_transcription_uses_media_path():
assert kwargs["content"].startswith("Hello world") assert kwargs["content"].startswith("Hello world")
@pytest.mark.asyncio
async def test_unauthorized_voice_message_does_not_transcribe() -> None:
ch = WhatsAppChannel({"enabled": True, "allowFrom": ["allowed"]}, MagicMock())
ch._handle_message = AsyncMock()
ch.transcribe_audio = AsyncMock(return_value="Hello world")
await ch._handle_bridge_message(
json.dumps({
"type": "message",
"id": "v-blocked",
"sender": "blocked@s.whatsapp.net",
"pn": "",
"content": "[Voice Message]",
"timestamp": 1,
"media": ["/tmp/voice.ogg"],
})
)
ch.transcribe_audio.assert_not_awaited()
ch._handle_message.assert_not_awaited()
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_voice_message_no_media_shows_not_available(): async def test_voice_message_no_media_shows_not_available():
"""Voice messages without media produce a fallback placeholder.""" """Voice messages without media produce a fallback placeholder."""
ch = WhatsAppChannel({"enabled": True}, MagicMock()) ch = WhatsAppChannel({"enabled": True, "allowFrom": ["*"]}, MagicMock())
ch._handle_message = AsyncMock() ch._handle_message = AsyncMock()
await ch._handle_bridge_message( await ch._handle_bridge_message(

View File

@ -0,0 +1,31 @@
from types import SimpleNamespace
from unittest.mock import patch
import pytest
from nanobot.cli import commands
@pytest.mark.asyncio
async def test_interactive_retry_wait_is_rendered_as_progress_even_when_progress_disabled():
"""Provider retry waits should not fall through as assistant responses."""
calls: list[tuple[str, object | None]] = []
thinking = None
channels_config = SimpleNamespace(send_progress=False, send_tool_hints=False)
msg = SimpleNamespace(
content="Model request failed, retry in 2s (attempt 1).",
metadata={"_retry_wait": True},
)
async def fake_print(text: str, active_thinking: object | None) -> None:
calls.append((text, active_thinking))
with patch("nanobot.cli.commands._print_interactive_progress_line", side_effect=fake_print):
handled = await commands._maybe_print_interactive_progress(
msg,
thinking,
channels_config,
)
assert handled is True
assert calls == [("Model request failed, retry in 2s (attempt 1).", thinking)]

View File

@ -0,0 +1,166 @@
"""Persistence tests for ``nanobot.cron.service.CronService``.
These tests target the specific failure mode where a corrupt or partially
written ``jobs.json`` would silently turn into an empty job list on the next
start, deleting every scheduled job. See ``fix(cron): atomic write for
jobs.json + don't silently overwrite corrupt store``.
"""
from __future__ import annotations
import json
from pathlib import Path
import pytest
from nanobot.cron.service import CronService
from nanobot.cron.types import CronSchedule
def _seeded_store(tmp_path: Path) -> tuple[CronService, Path]:
"""Build a service with one persisted job on disk and return both the
service and the resolved store path. Adds the job via the action log
(the path used when the service is not running) and then triggers a
merge so ``jobs.json`` is written, mirroring the persisted on-disk
state seen in production."""
store_path = tmp_path / "cron" / "jobs.json"
service = CronService(store_path)
service.add_job(
name="Daily Loving Message",
schedule=CronSchedule(kind="cron", expr="0 10 * * *", tz="Asia/Kuwait"),
message="hello",
)
# add_job appended to action.jsonl; flush to jobs.json by toggling
# ``_running`` long enough for ``_merge_action`` to do its rewrite.
service._running = True
try:
service._load_store()
finally:
service._running = False
assert store_path.exists()
return service, store_path
def test_save_store_is_atomic(tmp_path: Path) -> None:
"""``_save_store`` must use temp-file + rename so an interrupted write
cannot leave the destination truncated or invalid."""
service, store_path = _seeded_store(tmp_path)
# Simulate an arbitrary save and confirm the result parses cleanly and
# no orphan ``.tmp`` is left behind.
service._save_store()
data = json.loads(store_path.read_text(encoding="utf-8"))
assert len(data["jobs"]) == 1
tmp_files = list(store_path.parent.glob("*.tmp"))
assert tmp_files == [], f"unexpected temp files left behind: {tmp_files}"
def test_save_store_failure_does_not_corrupt_existing_file(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""If writing the temp file blows up partway through, the previous
``jobs.json`` must remain readable. This is the regression we are
actually fixing: pre-fix, ``write_text`` would truncate the destination
in place and leave it corrupt."""
service, store_path = _seeded_store(tmp_path)
original = store_path.read_bytes()
# Inject a failure inside the temp-file write. ``os.replace`` should
# never run; the destination must keep its previous content.
real_open = open
def boom(path, *args, **kwargs): # type: ignore[no-untyped-def]
if str(path).endswith(".tmp"):
raise OSError("simulated disk full")
return real_open(path, *args, **kwargs)
monkeypatch.setattr("builtins.open", boom)
with pytest.raises(OSError, match="simulated disk full"):
service._save_store()
assert store_path.read_bytes() == original
def test_load_jobs_preserves_corrupt_store_and_returns_none(
tmp_path: Path,
) -> None:
"""A corrupt ``jobs.json`` must not be silently treated as an empty
list. The loader returns ``None`` and the corrupt file is moved aside
with a ``.corrupt-<ts>`` suffix so an operator can recover it."""
store_path = tmp_path / "cron" / "jobs.json"
store_path.parent.mkdir(parents=True)
store_path.write_text("{not valid json", encoding="utf-8")
service = CronService(store_path)
assert service._load_jobs() is None
# Original path is gone; a ``.corrupt-<ts>`` backup exists alongside it.
assert not store_path.exists()
backups = list(store_path.parent.glob("jobs.json.corrupt-*"))
assert len(backups) == 1
assert backups[0].read_text(encoding="utf-8") == "{not valid json"
def test_start_refuses_to_overwrite_corrupt_store(tmp_path: Path) -> None:
"""``start`` must abort instead of running ``_save_store`` against an
empty in-memory state when the on-disk store is corrupt. Otherwise the
next save would overwrite the (recoverable) corrupt file with an empty
job list and the user's jobs would be unrecoverable."""
store_path = tmp_path / "cron" / "jobs.json"
store_path.parent.mkdir(parents=True)
store_path.write_text("{still not json", encoding="utf-8")
service = CronService(store_path)
import asyncio
with pytest.raises(RuntimeError, match="corrupt"):
asyncio.run(service.start())
# Service is left in a stopped state so the operator notices.
assert service._running is False
# And the corrupt file is still recoverable from the .corrupt-<ts> copy.
backups = list(store_path.parent.glob("jobs.json.corrupt-*"))
assert len(backups) == 1
def test_load_store_falls_back_to_in_memory_on_corruption_after_start(
tmp_path: Path,
) -> None:
"""If the store file becomes corrupt *after* a successful start (e.g. a
rclone-mounted Drive returns a partial read), the service must keep
using its existing in-memory snapshot instead of dropping every job."""
service, store_path = _seeded_store(tmp_path)
# Force load so ``self._store`` is populated.
service._load_store()
snapshot = service._store
assert snapshot is not None and len(snapshot.jobs) == 1
# Now corrupt the file on disk.
store_path.write_text("\x00garbage\x00", encoding="utf-8")
# Subsequent reload returns the in-memory snapshot, not None or empty.
result = service._load_store()
assert result is snapshot
assert len(result.jobs) == 1
assert result.jobs[0].name == "Daily Loving Message"
def test_full_round_trip_survives_repeated_save_load(tmp_path: Path) -> None:
"""Sanity check: jobs survive add → save → reload across fresh
``CronService`` instances pointing at the same store."""
store_path = tmp_path / "cron" / "jobs.json"
s1 = CronService(store_path)
s1.add_job(
name="Daily Loving Message",
schedule=CronSchedule(kind="cron", expr="0 10 * * *", tz="Asia/Kuwait"),
message="hello",
)
s2 = CronService(store_path)
s2._load_store()
assert s2._store is not None
assert [j.name for j in s2._store.jobs] == ["Daily Loving Message"]

View File

@ -228,8 +228,9 @@ async def test_running_service_honors_external_disable(tmp_path) -> None:
) )
await service.start() await service.start()
try: try:
# Wait slightly to ensure file mtime is definitively different # Disable before yielding back to the event loop. On slower Windows CI
await asyncio.sleep(0.05) # a short sleep here can overrun the 200ms schedule and let the job fire
# before the external update is written.
external = CronService(store_path) external = CronService(store_path)
updated = external.enable_job(job.id, enabled=False) updated = external.enable_job(job.id, enabled=False)
assert updated is not None assert updated is not None
@ -552,7 +553,7 @@ def test_update_job_offline_writes_action(tmp_path) -> None:
action_path = tmp_path / "cron" / "action.jsonl" action_path = tmp_path / "cron" / "action.jsonl"
assert action_path.exists() assert action_path.exists()
lines = [l for l in action_path.read_text().strip().split("\n") if l] lines = [line for line in action_path.read_text().strip().split("\n") if line]
last = json.loads(lines[-1]) last = json.loads(lines[-1])
assert last["action"] == "update" assert last["action"] == "update"
assert last["params"]["name"] == "updated-offline" assert last["params"]["name"] == "updated-offline"

View File

@ -620,7 +620,8 @@ def _tool_call(call_id: str) -> dict:
} }
def test_deepseek_thinking_drops_tool_history_missing_reasoning_content() -> None: def test_deepseek_thinking_backfills_missing_reasoning_content_on_tool_history() -> None:
"""Backfill reasoning_content="" instead of dropping the turn (#3554, #3584)."""
kwargs = _deepseek_kwargs([ kwargs = _deepseek_kwargs([
{"role": "system", "content": "system"}, {"role": "system", "content": "system"},
{"role": "user", "content": "can we use wechat?"}, {"role": "user", "content": "can we use wechat?"},
@ -629,10 +630,12 @@ def test_deepseek_thinking_drops_tool_history_missing_reasoning_content() -> Non
{"role": "user", "content": "continue"}, {"role": "user", "content": "continue"},
]) ])
assert kwargs["messages"] == [ assert [m["role"] for m in kwargs["messages"]] == [
{"role": "system", "content": "system"}, "system", "user", "assistant", "tool", "user",
{"role": "user", "content": "continue"},
] ]
assistant = kwargs["messages"][2]
assert assistant["reasoning_content"] == ""
assert assistant["tool_calls"][0]["function"]["name"] == "my"
def test_deepseek_thinking_keeps_tool_history_with_reasoning_content() -> None: def test_deepseek_thinking_keeps_tool_history_with_reasoning_content() -> None:
@ -654,20 +657,6 @@ def test_deepseek_thinking_keeps_tool_history_with_reasoning_content() -> None:
assert kwargs["messages"][2]["role"] == "tool" assert kwargs["messages"][2]["role"] == "tool"
def test_deepseek_thinking_drops_current_bad_tool_turn_without_followup_user() -> None:
kwargs = _deepseek_kwargs([
{"role": "system", "content": "system"},
{"role": "user", "content": "can we use wechat?"},
{"role": "assistant", "content": "", "tool_calls": [_tool_call("call_bad")]},
{"role": "tool", "tool_call_id": "call_bad", "name": "my", "content": "channels"},
])
assert kwargs["messages"] == [
{"role": "system", "content": "system"},
{"role": "user", "content": "can we use wechat?"},
]
def test_openai_compat_keeps_tool_calls_after_consecutive_assistant_messages() -> None: def test_openai_compat_keeps_tool_calls_after_consecutive_assistant_messages() -> None:
with patch("nanobot.providers.openai_compat_provider.AsyncOpenAI"): with patch("nanobot.providers.openai_compat_provider.AsyncOpenAI"):
provider = OpenAICompatProvider() provider = OpenAICompatProvider()
@ -937,8 +926,8 @@ def test_backfill_does_not_touch_messages_when_thinking_explicitly_off() -> None
assert "reasoning_content" not in msg assert "reasoning_content" not in msg
def test_deepseek_v4_drops_incomplete_reasoning_history_when_effort_implicit() -> None: def test_deepseek_v4_backfills_incomplete_reasoning_history_when_effort_implicit() -> None:
"""DeepSeek-V4 may default to thinking, so incomplete legacy history is trimmed.""" """DeepSeek-V4 reasons natively: backfill even without explicit reasoning_effort."""
spec = find_by_name("deepseek") spec = find_by_name("deepseek")
with patch("nanobot.providers.openai_compat_provider.AsyncOpenAI"): with patch("nanobot.providers.openai_compat_provider.AsyncOpenAI"):
p = OpenAICompatProvider(api_key="k", default_model="deepseek-v4-pro", spec=spec) p = OpenAICompatProvider(api_key="k", default_model="deepseek-v4-pro", spec=spec)
@ -958,12 +947,16 @@ def test_deepseek_v4_drops_incomplete_reasoning_history_when_effort_implicit() -
reasoning_effort=None, tool_choice=None, reasoning_effort=None, tool_choice=None,
) )
assert [msg["role"] for msg in kw["messages"]] == ["system", "user"] assert [msg["role"] for msg in kw["messages"]] == [
"system", "user", "assistant", "tool", "user",
]
assert kw["messages"][2]["reasoning_content"] == ""
assert kw["messages"][-1]["content"] == "thanks" assert kw["messages"][-1]["content"] == "thanks"
def test_deepseek_chat_keeps_tool_history_when_effort_implicit() -> None: def test_deepseek_chat_keeps_tool_history_when_effort_implicit() -> None:
"""Implicit cleanup must not trim non-thinking DeepSeek chat models.""" """Non-thinking deepseek-chat must keep history untouched and must NOT
receive backfilled reasoning_content (#3554, #3584)."""
spec = find_by_name("deepseek") spec = find_by_name("deepseek")
with patch("nanobot.providers.openai_compat_provider.AsyncOpenAI"): with patch("nanobot.providers.openai_compat_provider.AsyncOpenAI"):
p = OpenAICompatProvider(api_key="k", default_model="deepseek-chat", spec=spec) p = OpenAICompatProvider(api_key="k", default_model="deepseek-chat", spec=spec)
@ -985,6 +978,7 @@ def test_deepseek_chat_keeps_tool_history_when_effort_implicit() -> None:
roles = [msg["role"] for msg in kw["messages"]] roles = [msg["role"] for msg in kw["messages"]]
assert roles == ["user", "assistant", "tool", "user"] assert roles == ["user", "assistant", "tool", "user"]
assert kw["messages"][1]["tool_calls"] assert kw["messages"][1]["tool_calls"]
assert "reasoning_content" not in kw["messages"][1]
def test_deepseek_coerces_list_content_to_string() -> None: def test_deepseek_coerces_list_content_to_string() -> None:

View File

@ -835,7 +835,7 @@ async def test_start_logs_install_hint_when_pyjwt_missing(make_channel, monkeypa
ch = make_channel() ch = make_channel()
errors = [] errors = []
monkeypatch.setattr(msteams_module, "MSTEAMS_AVAILABLE", False) monkeypatch.setattr(msteams_module, "MSTEAMS_AVAILABLE", False)
monkeypatch.setattr(msteams_module.logger, "error", lambda message, *args: errors.append(message.format(*args))) monkeypatch.setattr(ch.logger, "error", lambda message, *args: errors.append(message.format(*args)))
await ch.start() await ch.start()

View File

@ -112,33 +112,31 @@ class TestSpawnUnix:
class TestSpawnWindows: class TestSpawnWindows:
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_uses_comspec_from_env(self): async def test_uses_create_subprocess_shell(self):
env = {"COMSPEC": r"C:\Windows\system32\cmd.exe", "PATH": ""} env = {"COMSPEC": r"C:\Windows\system32\cmd.exe", "PATH": ""}
with ( with (
patch("nanobot.agent.tools.shell._IS_WINDOWS", True), patch("nanobot.agent.tools.shell._IS_WINDOWS", True),
patch("asyncio.create_subprocess_exec", new_callable=AsyncMock) as mock_exec, patch("asyncio.create_subprocess_shell", new_callable=AsyncMock) as mock_shell,
): ):
mock_exec.return_value = AsyncMock() mock_shell.return_value = AsyncMock()
await ExecTool._spawn("dir", r"C:\Users", env) await ExecTool._spawn("dir", r"C:\work", env)
args = mock_exec.call_args[0] args = mock_shell.call_args[0]
assert "cmd.exe" in args[0]
assert "/c" in args
assert "dir" in args assert "dir" in args
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_falls_back_to_default_comspec(self): async def test_passes_cwd_and_env(self):
env = {"PATH": ""} env = {"PATH": "/usr/bin"}
with ( with (
patch("nanobot.agent.tools.shell._IS_WINDOWS", True), patch("nanobot.agent.tools.shell._IS_WINDOWS", True),
patch.dict("os.environ", {}, clear=True), patch("asyncio.create_subprocess_shell", new_callable=AsyncMock) as mock_shell,
patch("asyncio.create_subprocess_exec", new_callable=AsyncMock) as mock_exec,
): ):
mock_exec.return_value = AsyncMock() mock_shell.return_value = AsyncMock()
await ExecTool._spawn("dir", r"C:\Users", env) await ExecTool._spawn("echo hi", r"C:\work", env)
args = mock_exec.call_args[0] kwargs = mock_shell.call_args[1]
assert args[0] == "cmd.exe" assert kwargs["cwd"] == r"C:\work"
assert kwargs["env"] == env
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------

View File

@ -3,6 +3,7 @@
from __future__ import annotations from __future__ import annotations
import socket import socket
import sys
from unittest.mock import patch from unittest.mock import patch
import pytest import pytest
@ -182,3 +183,63 @@ async def test_exec_ignores_workspace_check_when_not_restricted(tmp_path):
result = await tool.execute(command="echo ok", working_dir=str(other)) result = await tool.execute(command="echo ok", working_dir=str(other))
assert "ok" in result assert "ok" in result
assert "outside the configured workspace" not in result assert "outside the configured workspace" not in result
# --- #3599: stdio redirects to /dev/null must not trip the workspace guard ----
@pytest.mark.parametrize(
"command",
[
# The exact command from the #3599 reporter.
'rm test_print.txt 2>/dev/null; echo "done"',
# Plain redirect of stdout / stderr.
"find . -type f >/dev/null",
"noisy_cmd 2>/dev/null",
"noisy_cmd >/dev/null 2>&1",
# Read from /dev/urandom is also a benign device read.
"head -c 16 /dev/urandom | xxd",
"echo done >/dev/stderr",
"echo line </dev/stdin",
# Per-process FD aliases never escape the workspace.
"cat /dev/fd/3",
],
)
def test_exec_allows_benign_device_targets_inside_workspace(tmp_path, command):
workspace = tmp_path / "workspace"
workspace.mkdir()
tool = ExecTool(working_dir=str(workspace), restrict_to_workspace=True)
assert tool._guard_command(command, str(workspace)) is None
@pytest.mark.asyncio
@pytest.mark.skipif(sys.platform == "win32", reason="POSIX rm and /dev/null syntax")
async def test_exec_3599_regression_rm_with_dev_null_redirect(tmp_path):
"""#3599: ``rm <ws-path> 2>/dev/null`` must succeed against the workspace guard."""
workspace = tmp_path / "workspace"
workspace.mkdir()
target = workspace / "test_print.txt"
target.write_text("scratch")
tool = ExecTool(working_dir=str(workspace), restrict_to_workspace=True, timeout=5)
result = await tool.execute(
command=f'rm {target} 2>/dev/null; echo "done"',
working_dir=str(workspace),
)
assert "done" in result
assert "path outside working dir" not in result
assert not target.exists()
def test_exec_still_blocks_real_outside_path_via_redirect(tmp_path):
"""Redirect *targets* outside the workspace (not /dev/...) must still be blocked.
We only whitelist kernel device files; arbitrary outside redirects such as
``> /etc/issue`` should remain caught by the workspace guard so a buggy
LLM cannot exfiltrate data outside the workspace via stderr redirection.
"""
workspace = tmp_path / "workspace"
workspace.mkdir()
tool = ExecTool(working_dir=str(workspace), restrict_to_workspace=True)
blocked = tool._guard_command("echo pwn > /etc/issue", str(workspace))
assert blocked is not None
assert "path outside working dir" in blocked

View File

@ -467,7 +467,7 @@ async def test_connect_mcp_servers_logs_stdio_pollution_hint(
yield # pragma: no cover yield # pragma: no cover
monkeypatch.setattr(sys.modules["mcp.client.stdio"], "stdio_client", _broken_stdio_client) monkeypatch.setattr(sys.modules["mcp.client.stdio"], "stdio_client", _broken_stdio_client)
monkeypatch.setattr("nanobot.agent.tools.mcp.logger.error", _error) monkeypatch.setattr("nanobot.agent.tools.mcp.logger.exception", _error)
registry = ToolRegistry() registry = ToolRegistry()
stacks = await connect_mcp_servers({"gh": MCPServerConfig(command="github-mcp")}, registry) stacks = await connect_mcp_servers({"gh": MCPServerConfig(command="github-mcp")}, registry)

View File

@ -242,13 +242,21 @@ def test_exec_extract_absolute_paths_captures_quoted_paths() -> None:
def test_exec_guard_blocks_home_path_outside_workspace(tmp_path) -> None: def test_exec_guard_blocks_home_path_outside_workspace(tmp_path) -> None:
tool = ExecTool(restrict_to_workspace=True) tool = ExecTool(restrict_to_workspace=True)
error = tool._guard_command("cat ~/.nanobot/config.json", str(tmp_path)) error = tool._guard_command("cat ~/.nanobot/config.json", str(tmp_path))
assert error == "Error: Command blocked by safety guard (path outside working dir)" assert error is not None
assert error.startswith(
"Error: Command blocked by safety guard (path outside working dir)"
)
assert "hard policy boundary" in error
def test_exec_guard_blocks_quoted_home_path_outside_workspace(tmp_path) -> None: def test_exec_guard_blocks_quoted_home_path_outside_workspace(tmp_path) -> None:
tool = ExecTool(restrict_to_workspace=True) tool = ExecTool(restrict_to_workspace=True)
error = tool._guard_command('cat "~/.nanobot/config.json"', str(tmp_path)) error = tool._guard_command('cat "~/.nanobot/config.json"', str(tmp_path))
assert error == "Error: Command blocked by safety guard (path outside working dir)" assert error is not None
assert error.startswith(
"Error: Command blocked by safety guard (path outside working dir)"
)
assert "hard policy boundary" in error
def test_exec_guard_allows_media_path_outside_workspace(tmp_path, monkeypatch) -> None: def test_exec_guard_allows_media_path_outside_workspace(tmp_path, monkeypatch) -> None:
@ -300,7 +308,39 @@ def test_exec_guard_blocks_windows_drive_root_outside_workspace(monkeypatch) ->
tool = ExecTool(restrict_to_workspace=True) tool = ExecTool(restrict_to_workspace=True)
error = tool._guard_command("dir E:\\", "E:\\workspace") error = tool._guard_command("dir E:\\", "E:\\workspace")
assert error == "Error: Command blocked by safety guard (path outside working dir)" assert error is not None
assert error.startswith(
"Error: Command blocked by safety guard (path outside working dir)"
)
assert "hard policy boundary" in error
def test_exec_guard_allows_dev_null_redirect(tmp_path) -> None:
tool = ExecTool(restrict_to_workspace=True)
ws = tmp_path / "workspace"
ws.mkdir()
(ws / "file.txt").write_text("ok", encoding="utf-8")
error = tool._guard_command(f'rm "{ws / "file.txt"}" 2>/dev/null', str(ws))
assert error is None
def test_exec_guard_allows_dev_urandom(tmp_path) -> None:
tool = ExecTool(restrict_to_workspace=True)
error = tool._guard_command("cat /dev/urandom | head -c 16 > random.bin", str(tmp_path))
assert error is None
def test_exec_guard_blocks_non_benign_dev_path(tmp_path) -> None:
tool = ExecTool(restrict_to_workspace=True)
error = tool._guard_command("cat /dev/sda", str(tmp_path))
assert error is not None
assert "path outside working dir" in error
def test_exec_extract_absolute_paths_ignores_pipe_tilde() -> None:
cmd = "python query.py --query '{job=\"app\"} |~ \"error\"'"
paths = ExecTool._extract_absolute_paths(cmd)
assert not any(p.startswith("~") for p in paths)
# --- cast_params tests --- # --- cast_params tests ---

View File

@ -0,0 +1,120 @@
"""Tests for repeated_workspace_violation throttle and signature."""
from __future__ import annotations
from nanobot.utils.runtime import (
repeated_workspace_violation_error,
workspace_violation_signature,
)
def test_signature_for_filesystem_tools_uses_path_argument():
sig_a = workspace_violation_signature(
"read_file", {"path": "/Users/x/Downloads/01.md"}
)
sig_b = workspace_violation_signature(
"write_file", {"path": "/Users/x/Downloads/01.md"}
)
sig_c = workspace_violation_signature(
"edit_file", {"file_path": "/Users/x/Downloads/01.md"}
)
assert sig_a is not None
assert sig_a == sig_b == sig_c, (
"the throttle must collapse equivalent paths across different tools "
"so the LLM cannot bypass it by switching tool"
)
assert "/users/x/downloads/01.md" in sig_a
def test_signature_for_exec_extracts_first_absolute_path_in_command():
sig = workspace_violation_signature(
"exec",
{"command": "cat /Users/x/Downloads/01.md && echo done"},
)
assert sig is not None
assert "/users/x/downloads/01.md" in sig
def test_signature_collides_across_filesystem_and_exec_for_same_target():
"""LLM bypass loops jump tools (read_file -> exec cat). Throttle must
treat both attempts as targeting the same outside resource."""
fs_sig = workspace_violation_signature(
"read_file", {"path": "/Users/x/Downloads/01.md"}
)
exec_sig = workspace_violation_signature(
"exec", {"command": "cat /Users/x/Downloads/01.md"}
)
assert fs_sig == exec_sig
def test_signature_falls_back_to_working_dir_when_no_absolute_in_command():
sig = workspace_violation_signature(
"exec",
{"command": "ls -la", "working_dir": "/etc"},
)
assert sig is not None
assert "/etc" in sig
def test_signature_is_none_for_unknown_tool_with_no_path():
assert workspace_violation_signature("web_search", {"query": "anything"}) is None
assert workspace_violation_signature("exec", {"command": "echo hello"}) is None
def test_repeated_workspace_violation_returns_none_within_budget():
counts: dict[str, int] = {}
arguments = {"path": "/Users/x/Downloads/01.md"}
assert repeated_workspace_violation_error("read_file", arguments, counts) is None
assert repeated_workspace_violation_error("read_file", arguments, counts) is None
def test_repeated_workspace_violation_escalates_after_third_attempt():
counts: dict[str, int] = {}
arguments = {"path": "/Users/x/Downloads/01.md"}
repeated_workspace_violation_error("read_file", arguments, counts)
repeated_workspace_violation_error("read_file", arguments, counts)
third = repeated_workspace_violation_error("read_file", arguments, counts)
assert third is not None
assert "refusing repeated workspace-bypass" in third
assert "/users/x/downloads/01.md" in third
assert "ask how they want to proceed" in third
def test_repeated_workspace_violation_independent_per_target():
"""Different outside paths must each get their own retry budget."""
counts: dict[str, int] = {}
repeated_workspace_violation_error(
"read_file", {"path": "/Users/x/Downloads/01.md"}, counts,
)
repeated_workspace_violation_error(
"read_file", {"path": "/Users/x/Downloads/01.md"}, counts,
)
# Different target, fresh budget.
assert repeated_workspace_violation_error(
"read_file", {"path": "/Users/x/Documents/notes.md"}, counts,
) is None
def test_repeated_workspace_violation_collapses_tool_switching():
"""LLM switches from read_file to exec cat then to python -c open(...)
against the same path; the throttle must escalate on the third attempt."""
counts: dict[str, int] = {}
repeated_workspace_violation_error(
"read_file", {"path": "/Users/x/Downloads/01.md"}, counts,
)
repeated_workspace_violation_error(
"exec", {"command": "cat /Users/x/Downloads/01.md"}, counts,
)
third = repeated_workspace_violation_error(
"exec",
{"command": "python3 -c \"open('/Users/x/Downloads/01.md').read()\""},
counts,
)
assert third is not None
assert "refusing repeated workspace-bypass" in third

View File

@ -72,6 +72,27 @@ If your gateway listens on a non-default port, point the dev server at it:
NANOBOT_API_URL=http://127.0.0.1:9000 bun run dev NANOBOT_API_URL=http://127.0.0.1:9000 bun run dev
``` ```
### Access from another device (LAN)
To use the webui from another device on the same network, set `host` to `"0.0.0.0"` and configure a `token` or `tokenIssueSecret` in `~/.nanobot/config.json`:
```json
{
"channels": {
"websocket": {
"enabled": true,
"host": "0.0.0.0",
"port": 8765,
"tokenIssueSecret": "your-secret-here"
}
}
}
```
The gateway will refuse to start if `host` is `"0.0.0.0"` and neither `token` nor `tokenIssueSecret` is set.
Then open `http://<your-ip>:8765` on the other device. The webui will show an authentication form where you enter the secret. It is saved in your browser so you only need to enter it once.
## Build for packaged runtime ## Build for packaged runtime
```bash ```bash

View File

@ -9,14 +9,23 @@ import { preloadMarkdownText } from "@/components/MarkdownText";
import { useSessions } from "@/hooks/useSessions"; import { useSessions } from "@/hooks/useSessions";
import { useTheme } from "@/hooks/useTheme"; import { useTheme } from "@/hooks/useTheme";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { deriveWsUrl, fetchBootstrap } from "@/lib/bootstrap"; import {
clearSavedSecret,
deriveWsUrl,
fetchBootstrap,
loadSavedSecret,
saveSecret,
} from "@/lib/bootstrap";
import { NanobotClient } from "@/lib/nanobot-client"; import { NanobotClient } from "@/lib/nanobot-client";
import { ClientProvider } from "@/providers/ClientProvider"; import { ClientProvider } from "@/providers/ClientProvider";
import type { ChatSummary } from "@/lib/types"; import type { ChatSummary } from "@/lib/types";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
type BootState = type BootState =
| { status: "loading" } | { status: "loading" }
| { status: "error"; message: string } | { status: "error"; message: string }
| { status: "auth"; failed?: boolean }
| { | {
status: "ready"; status: "ready";
client: NanobotClient; client: NanobotClient;
@ -25,9 +34,63 @@ type BootState =
}; };
const SIDEBAR_STORAGE_KEY = "nanobot-webui.sidebar"; const SIDEBAR_STORAGE_KEY = "nanobot-webui.sidebar";
const SIDEBAR_WIDTH = 279; const SIDEBAR_WIDTH = 272;
type ShellView = "chat" | "settings"; type ShellView = "chat" | "settings";
function AuthForm({
failed,
onSecret,
}: {
failed: boolean;
onSecret: (secret: string) => void;
}) {
const { t } = useTranslation();
const [value, setValue] = useState("");
const [submitting, setSubmitting] = useState(false);
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
const secret = value.trim();
if (!secret) return;
setSubmitting(true);
onSecret(secret);
};
return (
<div className="flex h-full w-full items-center justify-center px-6">
<form
onSubmit={handleSubmit}
className="flex w-full max-w-sm flex-col gap-4"
>
<div className="flex flex-col items-center gap-1 text-center">
<p className="text-lg font-semibold">{t("app.auth.title")}</p>
<p className="text-sm text-muted-foreground">{t("app.auth.hint")}</p>
</div>
{failed && (
<p className="text-center text-sm text-destructive">
{t("app.auth.invalid")}
</p>
)}
<Input
type="password"
placeholder={t("app.auth.placeholder")}
value={value}
onChange={(e) => setValue(e.target.value)}
disabled={submitting}
autoFocus
/>
<Button
type="submit"
className="w-full"
disabled={!value.trim() || submitting}
>
{t("app.auth.submit")}
</Button>
</form>
</div>
);
}
function readSidebarOpen(): boolean { function readSidebarOpen(): boolean {
if (typeof window === "undefined") return true; if (typeof window === "undefined") return true;
try { try {
@ -43,40 +106,55 @@ export default function App() {
const { t } = useTranslation(); const { t } = useTranslation();
const [state, setState] = useState<BootState>({ status: "loading" }); const [state, setState] = useState<BootState>({ status: "loading" });
const bootstrapWithSecret = useCallback(
(secret: string) => {
let cancelled = false;
(async () => {
setState({ status: "loading" });
try {
const boot = await fetchBootstrap("", secret);
if (cancelled) return;
if (secret) saveSecret(secret);
const url = deriveWsUrl(boot.ws_path, boot.token);
const client = new NanobotClient({
url,
onReauth: async () => {
try {
const refreshed = await fetchBootstrap("", secret);
return deriveWsUrl(refreshed.ws_path, refreshed.token);
} catch {
return null;
}
},
});
client.connect();
setState({
status: "ready",
client,
token: boot.token,
modelName: boot.model_name ?? null,
});
} catch (e) {
if (cancelled) return;
const msg = (e as Error).message;
if (msg.includes("HTTP 401") || msg.includes("HTTP 403")) {
setState({ status: "auth", failed: true });
} else {
setState({ status: "error", message: msg });
}
}
})();
return () => {
cancelled = true;
};
},
[],
);
useEffect(() => { useEffect(() => {
let cancelled = false; const saved = loadSavedSecret();
(async () => { return bootstrapWithSecret(saved);
try { }, [bootstrapWithSecret]);
const boot = await fetchBootstrap();
if (cancelled) return;
const url = deriveWsUrl(boot.ws_path, boot.token);
const client = new NanobotClient({
url,
onReauth: async () => {
try {
const refreshed = await fetchBootstrap();
return deriveWsUrl(refreshed.ws_path, refreshed.token);
} catch {
return null;
}
},
});
client.connect();
setState({
status: "ready",
client,
token: boot.token,
modelName: boot.model_name ?? null,
});
} catch (e) {
if (cancelled) return;
setState({ status: "error", message: (e as Error).message });
}
})();
return () => {
cancelled = true;
};
}, []);
useEffect(() => { useEffect(() => {
const warm = () => preloadMarkdownText(); const warm = () => preloadMarkdownText();
@ -99,13 +177,6 @@ export default function App() {
return ( return (
<div className="flex h-full w-full items-center justify-center"> <div className="flex h-full w-full items-center justify-center">
<div className="flex flex-col items-center gap-3 animate-in fade-in-0 duration-300"> <div className="flex flex-col items-center gap-3 animate-in fade-in-0 duration-300">
<img
src="/brand/nanobot_icon.png"
alt=""
className="h-10 w-10 animate-pulse select-none"
aria-hidden
draggable={false}
/>
<div className="flex items-center gap-2 text-sm text-muted-foreground"> <div className="flex items-center gap-2 text-sm text-muted-foreground">
<span className="relative flex h-2 w-2"> <span className="relative flex h-2 w-2">
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-foreground/40" /> <span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-foreground/40" />
@ -117,17 +188,18 @@ export default function App() {
</div> </div>
); );
} }
if (state.status === "auth") {
return (
<AuthForm
failed={!!state.failed}
onSecret={(s) => bootstrapWithSecret(s)}
/>
);
}
if (state.status === "error") { if (state.status === "error") {
return ( return (
<div className="flex h-full w-full items-center justify-center px-4 text-center"> <div className="flex h-full w-full items-center justify-center px-4 text-center">
<div className="flex max-w-md flex-col items-center gap-3"> <div className="flex max-w-md flex-col items-center gap-3">
<img
src="/brand/nanobot_icon.png"
alt=""
className="h-10 w-10 opacity-60 grayscale select-none"
aria-hidden
draggable={false}
/>
<p className="text-lg font-semibold">{t("app.error.title")}</p> <p className="text-lg font-semibold">{t("app.error.title")}</p>
<p className="text-sm text-muted-foreground">{state.message}</p> <p className="text-sm text-muted-foreground">{state.message}</p>
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
@ -144,18 +216,26 @@ export default function App() {
); );
}; };
const handleLogout = () => {
if (state.status === "ready") {
state.client.close();
}
clearSavedSecret();
setState({ status: "auth" });
};
return ( return (
<ClientProvider <ClientProvider
client={state.client} client={state.client}
token={state.token} token={state.token}
modelName={state.modelName} modelName={state.modelName}
> >
<Shell onModelNameChange={handleModelNameChange} /> <Shell onModelNameChange={handleModelNameChange} onLogout={handleLogout} />
</ClientProvider> </ClientProvider>
); );
} }
function Shell({ onModelNameChange }: { onModelNameChange: (modelName: string | null) => void }) { function Shell({ onModelNameChange, onLogout }: { onModelNameChange: (modelName: string | null) => void; onLogout: () => void }) {
const { t, i18n } = useTranslation(); const { t, i18n } = useTranslation();
const { theme, toggle } = useTheme(); const { theme, toggle } = useTheme();
const { sessions, loading, refresh, createChat, deleteChat } = useSessions(); const { sessions, loading, refresh, createChat, deleteChat } = useSessions();
@ -213,7 +293,7 @@ function Shell({ onModelNameChange }: { onModelNameChange: (modelName: string |
} }
}, []); }, []);
const onNewChat = useCallback(async () => { const onCreateChat = useCallback(async () => {
try { try {
const chatId = await createChat(); const chatId = await createChat();
setActiveKey(`websocket:${chatId}`); setActiveKey(`websocket:${chatId}`);
@ -226,6 +306,12 @@ function Shell({ onModelNameChange }: { onModelNameChange: (modelName: string |
} }
}, [createChat]); }, [createChat]);
const onNewChat = useCallback(() => {
setActiveKey(null);
setView("chat");
setMobileSidebarOpen(false);
}, []);
const onSelectChat = useCallback( const onSelectChat = useCallback(
(key: string) => { (key: string) => {
setActiveKey(key); setActiveKey(key);
@ -235,6 +321,15 @@ function Shell({ onModelNameChange }: { onModelNameChange: (modelName: string |
[], [],
); );
const onOpenSettings = useCallback(() => {
setView("settings");
setMobileSidebarOpen(false);
}, []);
const onTurnEnd = useCallback(() => {
void refresh();
}, [refresh]);
const onConfirmDelete = useCallback(async () => { const onConfirmDelete = useCallback(async () => {
if (!pendingDelete) return; if (!pendingDelete) return;
const key = pendingDelete.key; const key = pendingDelete.key;
@ -254,7 +349,8 @@ function Shell({ onModelNameChange }: { onModelNameChange: (modelName: string |
}, [pendingDelete, deleteChat, activeKey, sessions]); }, [pendingDelete, deleteChat, activeKey, sessions]);
const headerTitle = activeSession const headerTitle = activeSession
? activeSession.preview || ? activeSession.title ||
activeSession.preview ||
t("chat.fallbackTitle", { id: activeSession.chatId.slice(0, 6) }) t("chat.fallbackTitle", { id: activeSession.chatId.slice(0, 6) })
: t("app.brand"); : t("app.brand");
@ -268,20 +364,10 @@ function Shell({ onModelNameChange }: { onModelNameChange: (modelName: string |
sessions, sessions,
activeKey, activeKey,
loading, loading,
theme, onNewChat,
onToggleTheme: toggle,
onNewChat: () => {
void onNewChat();
},
onSelect: onSelectChat, onSelect: onSelectChat,
onRefresh: () => void refresh(),
onRequestDelete: (key: string, label: string) => onRequestDelete: (key: string, label: string) =>
setPendingDelete({ key, label }), setPendingDelete({ key, label }),
activeView: view,
onOpenSettings: () => {
setView("settings" as const);
setMobileSidebarOpen(false);
},
}; };
return ( return (
@ -296,10 +382,11 @@ function Shell({ onModelNameChange }: { onModelNameChange: (modelName: string |
> >
<div <div
className={cn( className={cn(
"absolute inset-y-0 left-0 h-full w-[279px] overflow-hidden bg-sidebar shadow-inner-right", "absolute inset-y-0 left-0 h-full overflow-hidden bg-sidebar shadow-inner-right",
"transition-transform duration-300 ease-out", "transition-transform duration-300 ease-out",
desktopSidebarOpen ? "translate-x-0" : "-translate-x-full", desktopSidebarOpen ? "translate-x-0" : "-translate-x-full",
)} )}
style={{ width: SIDEBAR_WIDTH }}
> >
<Sidebar {...sidebarProps} onCollapse={closeDesktopSidebar} /> <Sidebar {...sidebarProps} onCollapse={closeDesktopSidebar} />
</div> </div>
@ -312,7 +399,8 @@ function Shell({ onModelNameChange }: { onModelNameChange: (modelName: string |
<SheetContent <SheetContent
side="left" side="left"
showCloseButton={false} showCloseButton={false}
className="w-[279px] p-0 sm:max-w-[279px] lg:hidden" className="p-0 lg:hidden"
style={{ width: SIDEBAR_WIDTH, maxWidth: SIDEBAR_WIDTH }}
> >
<Sidebar {...sidebarProps} onCollapse={closeMobileSidebar} /> <Sidebar {...sidebarProps} onCollapse={closeMobileSidebar} />
</SheetContent> </SheetContent>
@ -325,14 +413,19 @@ function Shell({ onModelNameChange }: { onModelNameChange: (modelName: string |
onToggleTheme={toggle} onToggleTheme={toggle}
onBackToChat={() => setView("chat")} onBackToChat={() => setView("chat")}
onModelNameChange={onModelNameChange} onModelNameChange={onModelNameChange}
onLogout={onLogout}
/> />
) : ( ) : (
<ThreadShell <ThreadShell
session={activeSession} session={activeSession}
title={headerTitle} title={headerTitle}
onToggleSidebar={toggleSidebar} onToggleSidebar={toggleSidebar}
onGoHome={() => setActiveKey(null)}
onNewChat={onNewChat} onNewChat={onNewChat}
onCreateChat={onCreateChat}
onTurnEnd={onTurnEnd}
theme={theme}
onToggleTheme={toggle}
onOpenSettings={onOpenSettings}
hideSidebarToggleOnDesktop={desktopSidebarOpen} hideSidebarToggleOnDesktop={desktopSidebarOpen}
/> />
)} )}

View File

@ -8,7 +8,6 @@ import {
DropdownMenuTrigger, DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"; } from "@/components/ui/dropdown-menu";
import { ScrollArea } from "@/components/ui/scroll-area"; import { ScrollArea } from "@/components/ui/scroll-area";
import { relativeTime } from "@/lib/format";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import type { ChatSummary } from "@/lib/types"; import type { ChatSummary } from "@/lib/types";
@ -18,10 +17,11 @@ interface ChatListProps {
onSelect: (key: string) => void; onSelect: (key: string) => void;
onRequestDelete: (key: string, label: string) => void; onRequestDelete: (key: string, label: string) => void;
loading?: boolean; loading?: boolean;
emptyLabel?: string;
} }
function titleFor(s: ChatSummary, fallbackTitle: string): string { function titleFor(s: ChatSummary, fallbackTitle: string): string {
const p = s.preview?.trim(); const p = (s.title || s.preview)?.trim();
if (p) return p.length > 48 ? `${p.slice(0, 45)}` : p; if (p) return p.length > 48 ? `${p.slice(0, 45)}` : p;
return fallbackTitle; return fallbackTitle;
} }
@ -32,6 +32,7 @@ export function ChatList({
onSelect, onSelect,
onRequestDelete, onRequestDelete,
loading, loading,
emptyLabel,
}: ChatListProps) { }: ChatListProps) {
const { t } = useTranslation(); const { t } = useTranslation();
if (loading && sessions.length === 0) { if (loading && sessions.length === 0) {
@ -44,73 +45,111 @@ export function ChatList({
if (sessions.length === 0) { if (sessions.length === 0) {
return ( return (
<div className="px-3 py-6 text-xs text-muted-foreground"> <div className="px-3 py-6 text-[12px] leading-5 text-muted-foreground/80">
{t("chat.noSessions")} {emptyLabel ?? t("chat.noSessions")}
</div> </div>
); );
} }
const groups = groupSessions(sessions, {
today: t("chat.groups.today"),
yesterday: t("chat.groups.yesterday"),
earlier: t("chat.groups.earlier"),
});
return ( return (
<ScrollArea className="h-full"> <ScrollArea className="h-full">
<ul className="space-y-0.5 px-2 py-1"> <div className="space-y-3 px-2 py-1.5">
{sessions.map((s) => { {groups.map((group) => (
const active = s.key === activeKey; <section key={group.label} aria-label={group.label}>
const title = titleFor( <div className="px-2 pb-1 text-[12px] font-medium text-muted-foreground/65">
s, {group.label}
t("chat.fallbackTitle", { id: s.chatId.slice(0, 6) }), </div>
); <ul className="space-y-0.5">
return ( {group.sessions.map((s) => {
<li key={s.key}> const active = s.key === activeKey;
<div const title = titleFor(
className={cn( s,
"group flex items-center gap-2 rounded-md px-2 py-1.5 text-[12.5px] transition-colors", t("chat.fallbackTitle", { id: s.chatId.slice(0, 6) }),
active );
? "bg-sidebar-accent/80 text-sidebar-accent-foreground shadow-[inset_0_0_0_1px_hsl(var(--border)/0.4)]" return (
: "text-sidebar-foreground/88 hover:bg-sidebar-accent/45", <li key={s.key}>
)} <div
> className={cn(
<button "group flex min-h-8 items-center gap-2 rounded-xl px-2 text-[13px] transition-colors",
type="button" active
onClick={() => onSelect(s.key)} ? "bg-sidebar-accent/70 text-sidebar-accent-foreground shadow-[inset_0_0_0_1px_hsl(var(--sidebar-border)/0.28)]"
className="flex min-w-0 flex-1 flex-col items-start text-left" : "text-sidebar-foreground/82 hover:bg-sidebar-accent/50 hover:text-sidebar-foreground",
> )}
<span className="w-full truncate font-medium leading-5">{title}</span>
<span className="text-[10.5px] text-muted-foreground/80">
{relativeTime(s.updatedAt ?? s.createdAt) || "—"}
</span>
</button>
<DropdownMenu modal={false}>
<DropdownMenuTrigger
className={cn(
"inline-flex h-6 w-6 items-center justify-center rounded-md text-muted-foreground opacity-0 transition-opacity",
"hover:bg-sidebar-accent hover:text-sidebar-foreground group-hover:opacity-100",
"focus-visible:opacity-100",
active && "opacity-100",
)}
aria-label={t("chat.actions", { title })}
>
<MoreHorizontal className="h-4 w-4" />
</DropdownMenuTrigger>
<DropdownMenuContent
align="end"
onCloseAutoFocus={(event) => event.preventDefault()}
>
<DropdownMenuItem
onSelect={() => {
window.setTimeout(() => onRequestDelete(s.key, title), 0);
}}
className="text-destructive focus:text-destructive"
> >
<Trash2 className="mr-2 h-4 w-4" /> <button
{t("chat.delete")} type="button"
</DropdownMenuItem> onClick={() => onSelect(s.key)}
</DropdownMenuContent> className="min-w-0 flex-1 py-1.5 text-left"
</DropdownMenu> >
</div> <span className="block w-full truncate font-medium leading-5">{title}</span>
</li> </button>
); <DropdownMenu modal={false}>
})} <DropdownMenuTrigger
</ul> className={cn(
"inline-flex h-6 w-6 items-center justify-center rounded-md text-muted-foreground/75 opacity-0 transition-opacity",
"hover:bg-sidebar-accent hover:text-sidebar-foreground group-hover:opacity-100",
"focus-visible:opacity-100",
active && "opacity-100",
)}
aria-label={t("chat.actions", { title })}
>
<MoreHorizontal className="h-3.5 w-3.5" />
</DropdownMenuTrigger>
<DropdownMenuContent
align="end"
onCloseAutoFocus={(event) => event.preventDefault()}
>
<DropdownMenuItem
onSelect={() => {
window.setTimeout(() => onRequestDelete(s.key, title), 0);
}}
className="text-destructive focus:text-destructive"
>
<Trash2 className="mr-2 h-4 w-4" />
{t("chat.delete")}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</li>
);
})}
</ul>
</section>
))}
</div>
</ScrollArea> </ScrollArea>
); );
} }
function groupSessions(
sessions: ChatSummary[],
labels: { today: string; yesterday: string; earlier: string },
): Array<{ label: string; sessions: ChatSummary[] }> {
const now = new Date();
const startOfToday = new Date(now.getFullYear(), now.getMonth(), now.getDate()).getTime();
const startOfYesterday = startOfToday - 24 * 60 * 60 * 1000;
const buckets = new Map<string, ChatSummary[]>();
for (const session of sessions) {
const timestamp = Date.parse(session.updatedAt ?? session.createdAt ?? "");
const label = Number.isFinite(timestamp) && timestamp >= startOfToday
? labels.today
: Number.isFinite(timestamp) && timestamp >= startOfYesterday
? labels.yesterday
: labels.earlier;
const bucket = buckets.get(label) ?? [];
bucket.push(session);
buckets.set(label, bucket);
}
return [labels.today, labels.yesterday, labels.earlier]
.map((label) => ({ label, sessions: buckets.get(label) ?? [] }))
.filter((group) => group.sessions.length > 0);
}

View File

@ -22,7 +22,7 @@ interface ChatPaneProps {
export function ChatPane({ session, onNewChat }: ChatPaneProps) { export function ChatPane({ session, onNewChat }: ChatPaneProps) {
const chatId = session?.chatId ?? null; const chatId = session?.chatId ?? null;
const historyKey = session?.key ?? null; const historyKey = session?.key ?? null;
const { messages: historical, loading } = useSessionHistory(historyKey); const { messages: historical, loading, hasPendingToolCalls } = useSessionHistory(historyKey);
const { client } = useClient(); const { client } = useClient();
const [booting, setBooting] = useState(false); const [booting, setBooting] = useState(false);
const pendingFirstRef = useRef<string | null>(null); const pendingFirstRef = useRef<string | null>(null);
@ -31,6 +31,7 @@ export function ChatPane({ session, onNewChat }: ChatPaneProps) {
const { messages, isStreaming, send, setMessages } = useNanobotStream( const { messages, isStreaming, send, setMessages } = useNanobotStream(
chatId, chatId,
initial, initial,
hasPendingToolCalls,
); );
useEffect(() => { useEffect(() => {
@ -78,20 +79,8 @@ export function ChatPane({ session, onNewChat }: ChatPaneProps) {
<section className="flex min-h-0 flex-1 flex-col"> <section className="flex min-h-0 flex-1 flex-col">
<div className="flex flex-1 flex-col items-center justify-center gap-8 px-4 pb-6"> <div className="flex flex-1 flex-col items-center justify-center gap-8 px-4 pb-6">
<div className="flex flex-col items-center gap-4 animate-in fade-in-0 slide-in-from-bottom-2 duration-500"> <div className="flex flex-col items-center gap-4 animate-in fade-in-0 slide-in-from-bottom-2 duration-500">
<picture>
<source
srcSet="/brand/nanobot_logo.webp"
type="image/webp"
/>
<img
src="/brand/nanobot_logo.png"
alt="nanobot"
className="h-12 w-auto select-none drop-shadow-sm"
draggable={false}
/>
</picture>
<h1 className="text-xl font-medium tracking-tight text-foreground/90"> <h1 className="text-xl font-medium tracking-tight text-foreground/90">
What's on your mind? What can I do for you?
</h1> </h1>
<p className="max-w-md text-center text-sm text-muted-foreground"> <p className="max-w-md text-center text-sm text-muted-foreground">
Your conversations are persisted locally under the nanobot Your conversations are persisted locally under the nanobot
@ -104,7 +93,7 @@ export function ChatPane({ session, onNewChat }: ChatPaneProps) {
disabled={booting} disabled={booting}
onSend={handleWelcomeSend} onSend={handleWelcomeSend}
placeholder={ placeholder={
booting ? "Opening a new chat…" : "Type your message…" booting ? "Opening a new chat…" : "Ask anything..."
} }
/> />
</div> </div>

View File

@ -6,21 +6,21 @@ import { useClient } from "@/providers/ClientProvider";
import type { ConnectionStatus } from "@/lib/types"; import type { ConnectionStatus } from "@/lib/types";
const COPY: Record<ConnectionStatus, { color: string }> = { const COPY: Record<ConnectionStatus, { color: string }> = {
idle: { color: "bg-card/40 text-muted-foreground" }, idle: { color: "text-muted-foreground" },
connecting: { connecting: {
color: "bg-amber-500/10 text-amber-700 dark:text-amber-300", color: "text-amber-700 dark:text-amber-300",
}, },
open: { open: {
color: "bg-emerald-500/10 text-emerald-700 dark:text-emerald-400", color: "text-emerald-700 dark:text-emerald-400",
}, },
reconnecting: { reconnecting: {
color: "bg-amber-500/10 text-amber-700 dark:text-amber-300", color: "text-amber-700 dark:text-amber-300",
}, },
closed: { closed: {
color: "bg-card/40 text-muted-foreground", color: "text-muted-foreground",
}, },
error: { error: {
color: "bg-destructive/10 text-destructive", color: "text-destructive",
}, },
}; };
@ -39,7 +39,7 @@ export function ConnectionBadge() {
return ( return (
<span <span
className={cn( className={cn(
"inline-flex items-center gap-1.5 rounded-md border border-border/60 px-2 py-1 text-[11px] font-medium transition-colors", "inline-flex min-w-0 items-center gap-1.5 rounded-md px-1.5 py-1 text-[11px] font-medium transition-colors",
meta.color, meta.color,
)} )}
aria-live="polite" aria-live="polite"

View File

@ -1,5 +1,5 @@
import { useState } from "react"; import { useCallback, useEffect, useRef, useState } from "react";
import { ChevronRight, FileIcon, ImageIcon, PlaySquare, Wrench } from "lucide-react"; import { Check, ChevronRight, Copy, FileIcon, ImageIcon, PlaySquare, Wrench } from "lucide-react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { ImageLightbox } from "@/components/ImageLightbox"; import { ImageLightbox } from "@/components/ImageLightbox";
@ -21,8 +21,33 @@ interface MessageBubbleProps {
* collapsible group so intermediate steps never masquerade as replies. * collapsible group so intermediate steps never masquerade as replies.
*/ */
export function MessageBubble({ message }: MessageBubbleProps) { export function MessageBubble({ message }: MessageBubbleProps) {
const { t } = useTranslation();
const [copied, setCopied] = useState(false);
const copyResetRef = useRef<number | null>(null);
const baseAnim = "animate-in fade-in-0 slide-in-from-bottom-1 duration-300"; const baseAnim = "animate-in fade-in-0 slide-in-from-bottom-1 duration-300";
useEffect(() => {
return () => {
if (copyResetRef.current !== null) {
window.clearTimeout(copyResetRef.current);
}
};
}, []);
const onCopyAssistantReply = useCallback(() => {
if (!navigator.clipboard) return;
void navigator.clipboard.writeText(message.content).then(() => {
setCopied(true);
if (copyResetRef.current !== null) {
window.clearTimeout(copyResetRef.current);
}
copyResetRef.current = window.setTimeout(() => {
setCopied(false);
copyResetRef.current = null;
}, 1_500);
});
}, [message.content]);
if (message.kind === "trace") { if (message.kind === "trace") {
return <TraceGroup message={message} animClass={baseAnim} />; return <TraceGroup message={message} animClass={baseAnim} />;
} }
@ -60,6 +85,7 @@ export function MessageBubble({ message }: MessageBubbleProps) {
const empty = message.content.trim().length === 0; const empty = message.content.trim().length === 0;
const media = message.media ?? []; const media = message.media ?? [];
const showAssistantActions = message.role === "assistant" && !message.isStreaming && !empty;
return ( return (
<div className={cn("w-full text-sm", baseAnim)} style={{ lineHeight: "var(--cjk-line-height)" }}> <div className={cn("w-full text-sm", baseAnim)} style={{ lineHeight: "var(--cjk-line-height)" }}>
{empty && message.isStreaming ? ( {empty && message.isStreaming ? (
@ -69,6 +95,27 @@ export function MessageBubble({ message }: MessageBubbleProps) {
<MarkdownText>{message.content}</MarkdownText> <MarkdownText>{message.content}</MarkdownText>
{message.isStreaming && <StreamCursor />} {message.isStreaming && <StreamCursor />}
{media.length > 0 ? <MessageMedia media={media} align="left" /> : null} {media.length > 0 ? <MessageMedia media={media} align="left" /> : null}
{showAssistantActions ? (
<div className="mt-2 flex items-center gap-1 text-muted-foreground">
<button
type="button"
onClick={onCopyAssistantReply}
aria-label={copied ? t("message.copiedReply") : t("message.copyReply")}
title={copied ? t("message.copiedReply") : t("message.copyReply")}
className={cn(
"inline-flex h-8 w-8 items-center justify-center rounded-full",
"transition-colors hover:bg-muted/55 hover:text-foreground",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
)}
>
{copied ? (
<Check className="h-4 w-4" aria-hidden />
) : (
<Copy className="h-4 w-4" aria-hidden />
)}
</button>
</div>
) : null}
</> </>
)} )}
</div> </div>

View File

@ -1,109 +1,121 @@
import { Moon, PanelLeftClose, RefreshCcw, Settings, SquarePen, Sun } from "lucide-react"; import { useMemo, useState } from "react";
import {
PanelLeftClose,
Search,
SquarePen,
} from "lucide-react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { ChatList } from "@/components/ChatList"; import { ChatList } from "@/components/ChatList";
import { ConnectionBadge } from "@/components/ConnectionBadge"; import { ConnectionBadge } from "@/components/ConnectionBadge";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Separator } from "@/components/ui/separator"; import { Separator } from "@/components/ui/separator";
import { cn } from "@/lib/utils";
import type { ChatSummary } from "@/lib/types"; import type { ChatSummary } from "@/lib/types";
interface SidebarProps { interface SidebarProps {
sessions: ChatSummary[]; sessions: ChatSummary[];
activeKey: string | null; activeKey: string | null;
loading: boolean; loading: boolean;
theme: "light" | "dark";
onToggleTheme: () => void;
onNewChat: () => void; onNewChat: () => void;
onSelect: (key: string) => void; onSelect: (key: string) => void;
onRefresh: () => void;
onRequestDelete: (key: string, label: string) => void; onRequestDelete: (key: string, label: string) => void;
onCollapse: () => void; onCollapse: () => void;
activeView?: "chat" | "settings";
onOpenSettings: () => void;
} }
export function Sidebar(props: SidebarProps) { export function Sidebar(props: SidebarProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const [query, setQuery] = useState("");
const normalizedQuery = query.trim().toLowerCase();
const filteredSessions = useMemo(() => {
if (!normalizedQuery) return props.sessions;
return props.sessions.filter((session) => {
const haystack = [
session.preview,
session.chatId,
session.channel,
session.key,
]
.filter(Boolean)
.join(" ")
.toLowerCase();
return haystack.includes(normalizedQuery);
});
}, [normalizedQuery, props.sessions]);
return ( return (
<aside className="flex h-full w-full flex-col border-r border-sidebar-border/70 bg-sidebar text-sidebar-foreground"> <nav
<div className="flex items-center justify-between px-3 pb-2 pt-3"> aria-label={t("sidebar.navigation")}
className="flex h-full w-full flex-col border-r border-sidebar-border/60 bg-sidebar text-sidebar-foreground"
>
<div className="flex items-center justify-between px-3 pb-2.5 pt-3">
<picture className="block min-w-0"> <picture className="block min-w-0">
<source srcSet="/brand/nanobot_logo.webp" type="image/webp" /> <source srcSet="/brand/nanobot_logo.webp" type="image/webp" />
<img <img
src="/brand/nanobot_logo.png" src="/brand/nanobot_logo.png"
alt="nanobot" alt="nanobot"
className="h-7 w-auto select-none object-contain" className="h-6 w-auto select-none object-contain opacity-95"
draggable={false} draggable={false}
/> />
</picture> </picture>
<div className="flex items-center gap-0.5"> <Button
<Button variant="ghost"
variant="ghost" size="icon"
size="icon" aria-label={t("sidebar.collapse")}
aria-label={t("sidebar.toggleTheme")} onClick={props.onCollapse}
onClick={props.onToggleTheme} className="h-7 w-7 rounded-lg text-muted-foreground/85 hover:bg-sidebar-accent/75 hover:text-sidebar-foreground"
className="h-7 w-7 rounded-lg text-muted-foreground hover:bg-sidebar-accent hover:text-sidebar-foreground" >
> <PanelLeftClose className="h-3.5 w-3.5" />
{props.theme === "dark" ? ( </Button>
<Sun className="h-3.5 w-3.5" />
) : (
<Moon className="h-3.5 w-3.5" />
)}
</Button>
<Button
variant="ghost"
size="icon"
aria-label={t("sidebar.collapse")}
onClick={props.onCollapse}
className="h-7 w-7 rounded-lg text-muted-foreground hover:bg-sidebar-accent hover:text-sidebar-foreground"
>
<PanelLeftClose className="h-3.5 w-3.5" />
</Button>
</div>
</div> </div>
<div className="px-2 pb-2">
<div className="space-y-1.5 px-2 pb-2">
<label className="relative block">
<span className="sr-only">{t("sidebar.searchAria")}</span>
<Search
className="pointer-events-none absolute left-3 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-muted-foreground/70"
aria-hidden
/>
<input
value={query}
onChange={(event) => setQuery(event.target.value)}
placeholder={t("sidebar.searchPlaceholder")}
aria-label={t("sidebar.searchAria")}
className={cn(
"h-8 w-full rounded-full border border-transparent bg-sidebar-accent/45",
"pl-8 pr-3 text-[12.5px] text-sidebar-foreground outline-none",
"placeholder:text-muted-foreground/75",
"transition-colors hover:bg-sidebar-accent/65",
"focus:border-sidebar-border/80 focus:bg-sidebar-accent/70",
"focus:ring-1 focus:ring-sidebar-border/70",
)}
/>
</label>
<Button <Button
onClick={props.onNewChat} onClick={props.onNewChat}
className="h-9 w-full justify-start gap-2 rounded-full px-3 text-[13px] font-medium text-sidebar-foreground/90 hover:bg-sidebar-accent hover:text-sidebar-foreground" className="h-8 w-full justify-start gap-2 rounded-full px-3 text-[12.5px] font-medium text-sidebar-foreground/92 hover:bg-sidebar-accent/75 hover:text-sidebar-foreground"
variant="ghost" variant="ghost"
> >
<SquarePen className="h-3.5 w-3.5" /> <SquarePen className="h-3.5 w-3.5" />
{t("sidebar.newChat")} {t("sidebar.newChat")}
</Button> </Button>
</div> </div>
<div className="flex items-center justify-between px-3 pb-1.5 pt-2.5 text-[11px] font-medium text-muted-foreground">
<span>{t("sidebar.recent")}</span>
<Button
variant="ghost"
size="icon"
className="h-6 w-6 rounded-md text-muted-foreground hover:bg-sidebar-accent hover:text-sidebar-foreground"
onClick={props.onRefresh}
aria-label={t("sidebar.refreshSessions")}
>
<RefreshCcw className="h-3.5 w-3.5" />
</Button>
</div>
<div className="flex-1 overflow-hidden"> <div className="flex-1 overflow-hidden">
<ChatList <ChatList
sessions={props.sessions} sessions={filteredSessions}
activeKey={props.activeKey} activeKey={props.activeKey}
loading={props.loading} loading={props.loading}
emptyLabel={
normalizedQuery ? t("sidebar.noSearchResults") : t("chat.noSessions")
}
onSelect={props.onSelect} onSelect={props.onSelect}
onRequestDelete={props.onRequestDelete} onRequestDelete={props.onRequestDelete}
/> />
</div> </div>
<Separator className="bg-sidebar-border/50" /> <Separator className="bg-sidebar-border/50" />
<div className="flex items-center justify-between gap-2 px-2.5 py-2 text-xs"> <div className="flex items-center px-2.5 py-2.5 text-xs">
<ConnectionBadge /> <ConnectionBadge />
<Button
onClick={props.onOpenSettings}
className="h-7 gap-1.5 rounded-md px-2 text-[11px] text-muted-foreground hover:bg-sidebar-accent hover:text-sidebar-foreground"
variant={props.activeView === "settings" ? "secondary" : "ghost"}
>
<Settings className="h-3.5 w-3.5" />
Settings
</Button>
</div> </div>
</aside> </nav>
); );
} }

View File

@ -1,5 +1,6 @@
import { useCallback, useEffect, useMemo, useState } from "react"; import { useCallback, useEffect, useMemo, useState } from "react";
import { ChevronLeft, Loader2 } from "lucide-react"; import { ChevronLeft, Loader2 } from "lucide-react";
import { useTranslation } from "react-i18next";
import { LanguageSwitcher } from "@/components/LanguageSwitcher"; import { LanguageSwitcher } from "@/components/LanguageSwitcher";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
@ -14,11 +15,13 @@ interface SettingsViewProps {
onToggleTheme: () => void; onToggleTheme: () => void;
onBackToChat: () => void; onBackToChat: () => void;
onModelNameChange: (modelName: string | null) => void; onModelNameChange: (modelName: string | null) => void;
onLogout?: () => void;
} }
export function SettingsView({ export function SettingsView({
onBackToChat, onBackToChat,
onModelNameChange, onModelNameChange,
onLogout,
}: SettingsViewProps) { }: SettingsViewProps) {
const { token } = useClient(); const { token } = useClient();
const [settings, setSettings] = useState<SettingsPayload | null>(null); const [settings, setSettings] = useState<SettingsPayload | null>(null);
@ -115,6 +118,7 @@ export function SettingsView({
dirty={dirty} dirty={dirty}
saving={saving} saving={saving}
onSave={save} onSave={save}
onLogout={onLogout}
/> />
) : null} ) : null}
</main> </main>
@ -129,6 +133,7 @@ function SettingsSection({
dirty, dirty,
saving, saving,
onSave, onSave,
onLogout,
}: { }: {
form: { form: {
model: string; model: string;
@ -142,7 +147,9 @@ function SettingsSection({
dirty: boolean; dirty: boolean;
saving: boolean; saving: boolean;
onSave: () => void; onSave: () => void;
onLogout?: () => void;
}) { }) {
const { t } = useTranslation();
return ( return (
<div className="space-y-7"> <div className="space-y-7">
<section> <section>
@ -192,6 +199,19 @@ function SettingsSection({
</SettingsRow> </SettingsRow>
</SettingsGroup> </SettingsGroup>
</section> </section>
{onLogout && (
<section>
<h2 className="mb-2 px-2 text-xs font-medium text-muted-foreground">{t("app.account.section")}</h2>
<SettingsGroup>
<SettingsRow title={t("app.account.logoutHint")}>
<Button size="sm" variant="outline" onClick={onLogout}>
{t("app.account.logout")}
</Button>
</SettingsRow>
</SettingsGroup>
</section>
)}
</div> </div>
); );
} }

View File

@ -7,11 +7,21 @@ import {
type KeyboardEvent as ReactKeyboardEvent, type KeyboardEvent as ReactKeyboardEvent,
} from "react"; } from "react";
import { import {
Activity,
ArrowUp, ArrowUp,
BookOpen,
CircleHelp,
History,
ImageIcon, ImageIcon,
Loader2, Loader2,
Paperclip, Plus,
RotateCw,
Sparkles,
Square,
SquarePen,
Undo2,
X, X,
type LucideIcon,
} from "lucide-react"; } from "lucide-react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
@ -24,6 +34,7 @@ import {
} from "@/hooks/useAttachedImages"; } from "@/hooks/useAttachedImages";
import { useClipboardAndDrop } from "@/hooks/useClipboardAndDrop"; import { useClipboardAndDrop } from "@/hooks/useClipboardAndDrop";
import type { SendImage } from "@/hooks/useNanobotStream"; import type { SendImage } from "@/hooks/useNanobotStream";
import type { SlashCommand } from "@/lib/types";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
/** ``<input accept>``: aligned with the server's MIME whitelist. SVG is /** ``<input accept>``: aligned with the server's MIME whitelist. SVG is
@ -40,26 +51,49 @@ interface ThreadComposerProps {
onSend: (content: string, images?: SendImage[]) => void; onSend: (content: string, images?: SendImage[]) => void;
disabled?: boolean; disabled?: boolean;
placeholder?: string; placeholder?: string;
isStreaming?: boolean;
modelLabel?: string | null; modelLabel?: string | null;
variant?: "thread" | "hero"; variant?: "thread" | "hero";
slashCommands?: SlashCommand[];
}
const COMMAND_ICONS: Record<string, LucideIcon> = {
activity: Activity,
"book-open": BookOpen,
"circle-help": CircleHelp,
history: History,
"rotate-cw": RotateCw,
sparkles: Sparkles,
square: Square,
"square-pen": SquarePen,
"undo-2": Undo2,
};
function slashCommandI18nKey(command: string): string {
return command.replace(/^\//, "").replace(/-/g, "_");
} }
export function ThreadComposer({ export function ThreadComposer({
onSend, onSend,
disabled, disabled,
placeholder, placeholder,
isStreaming = false,
modelLabel = null, modelLabel = null,
variant = "thread", variant = "thread",
slashCommands = [],
}: ThreadComposerProps) { }: ThreadComposerProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const [value, setValue] = useState(""); const [value, setValue] = useState("");
const [inlineError, setInlineError] = useState<string | null>(null); const [inlineError, setInlineError] = useState<string | null>(null);
const [slashMenuDismissed, setSlashMenuDismissed] = useState(false);
const [selectedCommandIndex, setSelectedCommandIndex] = useState(0);
const textareaRef = useRef<HTMLTextAreaElement>(null); const textareaRef = useRef<HTMLTextAreaElement>(null);
const fileInputRef = useRef<HTMLInputElement>(null); const fileInputRef = useRef<HTMLInputElement>(null);
const chipRefs = useRef(new Map<string, HTMLButtonElement>()); const chipRefs = useRef(new Map<string, HTMLButtonElement>());
const isHero = variant === "hero"; const isHero = variant === "hero";
const resolvedPlaceholder = const resolvedPlaceholder = isStreaming
placeholder ?? t("thread.composer.placeholderThread"); ? t("thread.composer.placeholderStreaming")
: placeholder ?? t("thread.composer.placeholderThread");
const { images, enqueue, remove, clear, encoding, full } = const { images, enqueue, remove, clear, encoding, full } =
useAttachedImages(); useAttachedImages();
@ -116,6 +150,66 @@ export function ThreadComposer({
&& !hasErrors && !hasErrors
&& (value.trim().length > 0 || readyImages.length > 0); && (value.trim().length > 0 || readyImages.length > 0);
const slashQuery = useMemo(() => {
if (disabled || slashMenuDismissed || !value.startsWith("/")) return null;
const commandToken = value.slice(1);
if (/\s/.test(commandToken)) return null;
return commandToken.toLowerCase();
}, [disabled, slashMenuDismissed, value]);
const filteredSlashCommands = useMemo(() => {
if (slashQuery === null) return [];
return slashCommands
.filter((command) => {
const haystack = [
command.command,
command.title,
command.description,
command.argHint ?? "",
t(`thread.composer.slash.commands.${slashCommandI18nKey(command.command)}.title`, {
defaultValue: "",
}),
t(`thread.composer.slash.commands.${slashCommandI18nKey(command.command)}.description`, {
defaultValue: "",
}),
].join(" ").toLowerCase();
return haystack.includes(slashQuery);
})
.slice(0, 8);
}, [slashCommands, slashQuery, t]);
const showSlashMenu = filteredSlashCommands.length > 0;
useEffect(() => {
setSelectedCommandIndex(0);
}, [slashQuery]);
useEffect(() => {
if (selectedCommandIndex >= filteredSlashCommands.length) {
setSelectedCommandIndex(0);
}
}, [filteredSlashCommands.length, selectedCommandIndex]);
const resizeTextarea = useCallback(() => {
requestAnimationFrame(() => {
const el = textareaRef.current;
if (!el) return;
el.style.height = "auto";
el.style.height = `${Math.min(el.scrollHeight, 260)}px`;
el.focus();
});
}, []);
const chooseSlashCommand = useCallback(
(command: SlashCommand) => {
setValue(command.argHint ? `${command.command} ` : command.command);
setSlashMenuDismissed(true);
setInlineError(null);
resizeTextarea();
},
[resizeTextarea],
);
const submit = useCallback(() => { const submit = useCallback(() => {
if (!canSend) return; if (!canSend) return;
const trimmed = value.trim(); const trimmed = value.trim();
@ -139,16 +233,35 @@ export function ThreadComposer({
// Bubble owns the data URL copy; safe to revoke every staged blob // Bubble owns the data URL copy; safe to revoke every staged blob
// preview here without affecting the rendered message. // preview here without affecting the rendered message.
clear(); clear();
requestAnimationFrame(() => { setSlashMenuDismissed(false);
const el = textareaRef.current; resizeTextarea();
if (el) { }, [canSend, clear, onSend, readyImages, resizeTextarea, value]);
el.style.height = "auto";
el.focus();
}
});
}, [canSend, clear, onSend, readyImages, value]);
const onKeyDown = (e: ReactKeyboardEvent<HTMLTextAreaElement>) => { const onKeyDown = (e: ReactKeyboardEvent<HTMLTextAreaElement>) => {
if (showSlashMenu) {
if (e.key === "ArrowDown") {
e.preventDefault();
setSelectedCommandIndex((idx) => (idx + 1) % filteredSlashCommands.length);
return;
}
if (e.key === "ArrowUp") {
e.preventDefault();
setSelectedCommandIndex(
(idx) => (idx - 1 + filteredSlashCommands.length) % filteredSlashCommands.length,
);
return;
}
if (e.key === "Tab" || (e.key === "Enter" && !e.shiftKey)) {
e.preventDefault();
chooseSlashCommand(filteredSlashCommands[selectedCommandIndex]);
return;
}
if (e.key === "Escape") {
e.preventDefault();
setSlashMenuDismissed(true);
return;
}
}
if (e.key === "Enter" && !e.shiftKey && !e.nativeEvent.isComposing) { if (e.key === "Enter" && !e.shiftKey && !e.nativeEvent.isComposing) {
e.preventDefault(); e.preventDefault();
submit(); submit();
@ -210,14 +323,23 @@ export function ThreadComposer({
onDragOver={onDragOver} onDragOver={onDragOver}
onDragLeave={onDragLeave} onDragLeave={onDragLeave}
onDrop={onDrop} onDrop={onDrop}
className={cn("w-full", isHero ? "px-0" : "px-1 pb-1.5 pt-1 sm:px-0")} className={cn("relative w-full", isHero ? "px-0" : "px-1 pb-1.5 pt-1 sm:px-0")}
> >
{showSlashMenu ? (
<SlashCommandPalette
commands={filteredSlashCommands}
selectedIndex={selectedCommandIndex}
isHero={isHero}
onHover={setSelectedCommandIndex}
onChoose={chooseSlashCommand}
/>
) : null}
<div <div
className={cn( className={cn(
"relative mx-auto flex w-full flex-col overflow-hidden transition-all duration-200", "relative mx-auto flex w-full flex-col overflow-hidden transition-all duration-200",
isHero isHero
? "max-w-[40rem] rounded-[24px] border border-border/75 bg-card shadow-[0_10px_30px_rgba(0,0,0,0.10)]" ? "max-w-[58rem] rounded-[28px] border border-black/[0.035] bg-card shadow-[0_20px_55px_rgba(15,23,42,0.08)] dark:border-white/[0.06] dark:shadow-[0_24px_55px_rgba(0,0,0,0.34)]"
: "max-w-[49.5rem] rounded-[16px] border border-border/70 bg-card", : "max-w-[49.5rem] rounded-[22px] border border-black/[0.035] bg-card shadow-[0_12px_30px_rgba(15,23,42,0.07)] dark:border-white/[0.06] dark:shadow-[0_16px_34px_rgba(0,0,0,0.28)]",
"focus-within:ring-1 focus-within:ring-foreground/8", "focus-within:ring-1 focus-within:ring-foreground/8",
disabled && "opacity-60", disabled && "opacity-60",
isDragging && "ring-2 ring-primary/40 motion-reduce:ring-0 motion-reduce:border-primary", isDragging && "ring-2 ring-primary/40 motion-reduce:ring-0 motion-reduce:border-primary",
@ -254,7 +376,10 @@ export function ThreadComposer({
<textarea <textarea
ref={textareaRef} ref={textareaRef}
value={value} value={value}
onChange={(e) => setValue(e.target.value)} onChange={(e) => {
setValue(e.target.value);
setSlashMenuDismissed(false);
}}
onInput={onInput} onInput={onInput}
onKeyDown={onKeyDown} onKeyDown={onKeyDown}
onPaste={onPaste} onPaste={onPaste}
@ -265,9 +390,9 @@ export function ThreadComposer({
className={cn( className={cn(
"w-full resize-none bg-transparent", "w-full resize-none bg-transparent",
isHero isHero
? "min-h-[96px] px-4 pb-2 pt-4 text-[15px] leading-6" ? "min-h-[78px] px-5 pb-2 pt-5 text-[16px] leading-6"
: "min-h-[50px] px-4 pb-1.5 pt-3 text-sm", : "min-h-[50px] px-4 pb-1.5 pt-3 text-sm",
"placeholder:text-muted-foreground", "placeholder:text-muted-foreground/70",
"focus:outline-none focus-visible:outline-none", "focus:outline-none focus-visible:outline-none",
"disabled:cursor-not-allowed", "disabled:cursor-not-allowed",
)} )}
@ -286,7 +411,7 @@ export function ThreadComposer({
<div <div
className={cn( className={cn(
"flex items-center justify-between gap-2", "flex items-center justify-between gap-2",
isHero ? "px-3.5 pb-3.5" : "px-3 pb-2", isHero ? "px-4 pb-4" : "px-3 pb-2",
)} )}
> >
<div className="flex min-w-0 items-center gap-2"> <div className="flex min-w-0 items-center gap-2">
@ -307,10 +432,12 @@ export function ThreadComposer({
onClick={() => fileInputRef.current?.click()} onClick={() => fileInputRef.current?.click()}
className={cn( className={cn(
"rounded-full text-muted-foreground hover:text-foreground", "rounded-full text-muted-foreground hover:text-foreground",
isHero ? "h-8.5 w-8.5" : "h-7.5 w-7.5", isHero
? "h-9 w-9 border border-border/55 bg-card shadow-[0_2px_8px_rgba(15,23,42,0.05)] hover:bg-card"
: "h-7.5 w-7.5 border border-border/55 bg-card shadow-[0_2px_8px_rgba(15,23,42,0.05)] hover:bg-card",
)} )}
> >
<Paperclip className={cn(isHero ? "h-4 w-4" : "h-3.5 w-3.5")} /> <Plus className={cn(isHero ? "h-5 w-5" : "h-4 w-4")} />
</Button> </Button>
{modelLabel ? ( {modelLabel ? (
<span <span
@ -318,7 +445,9 @@ export function ThreadComposer({
className={cn( className={cn(
"inline-flex min-w-0 items-center gap-1.5 rounded-full border px-2.5 py-1", "inline-flex min-w-0 items-center gap-1.5 rounded-full border px-2.5 py-1",
"border-foreground/10 bg-foreground/[0.035] font-medium text-foreground/80", "border-foreground/10 bg-foreground/[0.035] font-medium text-foreground/80",
isHero ? "text-[11px]" : "text-[10.5px]", isHero
? "max-w-[13rem] text-[12px] shadow-[0_2px_8px_rgba(15,23,42,0.04)]"
: "max-w-[10rem] text-[10.5px] shadow-[0_2px_8px_rgba(15,23,42,0.035)]",
)} )}
> >
<span <span
@ -328,23 +457,31 @@ export function ThreadComposer({
<span className="truncate">{modelLabel}</span> <span className="truncate">{modelLabel}</span>
</span> </span>
) : null} ) : null}
<span className="hidden select-none text-[10.5px] text-muted-foreground/60 sm:inline"> {!isHero ? (
{t("thread.composer.sendHint")} <span className="hidden select-none text-[10.5px] text-muted-foreground/60 sm:inline">
</span> {t("thread.composer.sendHint")}
</span>
) : null}
</div> </div>
<span className="sm:hidden" aria-hidden /> <span className={cn(isHero ? "hidden" : "sm:hidden")} aria-hidden />
<Button <Button
type="submit" type="submit"
size="icon" size="icon"
disabled={!canSend} disabled={!canSend}
aria-label={t("thread.composer.send")} aria-label={t("thread.composer.send")}
className={cn( className={cn(
"rounded-full border border-border/70 bg-secondary/85 text-secondary-foreground shadow-none transition-transform hover:bg-accent", isHero
isHero ? "h-8.5 w-8.5" : "h-7.5 w-7.5", ? "h-9 w-9 rounded-full border border-foreground bg-foreground text-background shadow-[0_4px_12px_rgba(15,23,42,0.20)] hover:bg-foreground/90 disabled:border-foreground/35 disabled:bg-foreground/35 disabled:text-background/80"
: "rounded-full border border-foreground bg-foreground text-background shadow-[0_3px_10px_rgba(15,23,42,0.18)] transition-transform hover:bg-foreground/90 disabled:border-foreground/35 disabled:bg-foreground/35 disabled:text-background/80",
isHero ? "" : "h-7.5 w-7.5",
canSend && "hover:scale-[1.03] active:scale-95", canSend && "hover:scale-[1.03] active:scale-95",
)} )}
> >
<ArrowUp className={cn(isHero ? "h-4.5 w-4.5" : "h-4 w-4")} /> {isStreaming ? (
<Loader2 className={cn(isHero ? "h-4.5 w-4.5" : "h-4 w-4", "animate-spin")} />
) : (
<ArrowUp className={cn(isHero ? "h-4.5 w-4.5" : "h-4 w-4")} />
)}
</Button> </Button>
</div> </div>
</div> </div>
@ -352,6 +489,106 @@ export function ThreadComposer({
); );
} }
interface SlashCommandPaletteProps {
commands: SlashCommand[];
selectedIndex: number;
isHero: boolean;
onHover: (index: number) => void;
onChoose: (command: SlashCommand) => void;
}
function SlashCommandPalette({
commands,
selectedIndex,
isHero,
onHover,
onChoose,
}: SlashCommandPaletteProps) {
const { t } = useTranslation();
return (
<div
role="listbox"
aria-label={t("thread.composer.slash.ariaLabel")}
className={cn(
"absolute bottom-full left-1/2 z-30 mb-2 max-h-[22rem] w-[calc(100%-0.5rem)] -translate-x-1/2 overflow-hidden rounded-[18px] border",
"border-border/65 bg-popover/98 p-1.5 text-popover-foreground shadow-[0_18px_55px_rgba(15,23,42,0.18)] backdrop-blur",
"dark:border-white/10 dark:shadow-[0_22px_55px_rgba(0,0,0,0.45)]",
isHero ? "max-w-[58rem]" : "max-w-[49.5rem]",
)}
>
<div className="px-2 pb-1 pt-1 text-[11px] font-medium tracking-[0.08em] text-muted-foreground/70">
{t("thread.composer.slash.label")}
</div>
<div className="max-h-[18rem] overflow-y-auto pr-0.5">
{commands.map((command, index) => {
const Icon = COMMAND_ICONS[command.icon] ?? CircleHelp;
const selected = index === selectedIndex;
const commandKey = slashCommandI18nKey(command.command);
const title = t(`thread.composer.slash.commands.${commandKey}.title`, {
defaultValue: command.title,
});
const description = t(`thread.composer.slash.commands.${commandKey}.description`, {
defaultValue: command.description,
});
return (
<button
key={command.command}
type="button"
role="option"
aria-selected={selected}
onMouseEnter={() => onHover(index)}
onMouseDown={(e) => {
e.preventDefault();
onChoose(command);
}}
className={cn(
"flex w-full items-center gap-3 rounded-[13px] px-3 py-2.5 text-left transition-colors",
selected
? "bg-primary/10 text-foreground"
: "text-foreground/86 hover:bg-accent/55",
)}
>
<span
className={cn(
"flex h-8 w-8 shrink-0 items-center justify-center rounded-[10px] border",
selected
? "border-primary/25 bg-primary/12 text-primary"
: "border-border/65 bg-muted/45 text-muted-foreground",
)}
>
<Icon className="h-4 w-4" />
</span>
<span className="min-w-0 flex-1">
<span className="flex min-w-0 items-baseline gap-2">
<span className="font-mono text-[13px] font-semibold text-foreground">
{command.command}
</span>
{command.argHint ? (
<span className="font-mono text-[12px] text-muted-foreground">
{command.argHint}
</span>
) : null}
<span className="truncate text-[13px] font-medium">
{title}
</span>
</span>
<span className="mt-0.5 block truncate text-[12px] text-muted-foreground">
{description}
</span>
</span>
</button>
);
})}
</div>
<div className="flex items-center gap-2 px-2 pt-1.5 text-[10.5px] text-muted-foreground/70">
<span>{t("thread.composer.slash.navigateHint")}</span>
<span>{t("thread.composer.slash.selectHint")}</span>
<span>{t("thread.composer.slash.closeHint")}</span>
</div>
</div>
);
}
interface AttachmentChipProps { interface AttachmentChipProps {
image: AttachedImage; image: AttachedImage;
labelRemove: string; labelRemove: string;

View File

@ -1,4 +1,4 @@
import { PanelLeftOpen } from "lucide-react"; import { Menu, Moon, PanelLeftOpen, Settings, Sun } from "lucide-react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
@ -7,17 +7,66 @@ import { cn } from "@/lib/utils";
interface ThreadHeaderProps { interface ThreadHeaderProps {
title: string; title: string;
onToggleSidebar: () => void; onToggleSidebar: () => void;
onGoHome: () => void; theme: "light" | "dark";
onToggleTheme: () => void;
onOpenSettings: () => void;
hideSidebarToggleOnDesktop?: boolean; hideSidebarToggleOnDesktop?: boolean;
minimal?: boolean;
} }
export function ThreadHeader({ export function ThreadHeader({
title, title,
onToggleSidebar, onToggleSidebar,
onGoHome, theme,
onToggleTheme,
onOpenSettings,
hideSidebarToggleOnDesktop = false, hideSidebarToggleOnDesktop = false,
minimal = false,
}: ThreadHeaderProps) { }: ThreadHeaderProps) {
const { t } = useTranslation(); const { t } = useTranslation();
if (minimal) {
return (
<div className="relative z-10 flex h-11 items-center justify-between gap-3 px-3 py-2">
<Button
variant="ghost"
size="icon"
aria-label={t("thread.header.toggleSidebar")}
onClick={onToggleSidebar}
className={cn(
"h-7 w-7 rounded-md text-muted-foreground hover:bg-accent/35 hover:text-foreground",
hideSidebarToggleOnDesktop && "lg:pointer-events-none lg:opacity-0",
)}
>
<Menu className="h-3.5 w-3.5" />
</Button>
<div className="flex items-center gap-0.5">
<Button
variant="ghost"
size="icon"
aria-label={t("thread.header.toggleTheme")}
onClick={onToggleTheme}
className="h-8 w-8 rounded-full text-muted-foreground/85 hover:bg-accent/40 hover:text-foreground"
>
{theme === "dark" ? (
<Sun className="h-4 w-4" />
) : (
<Moon className="h-4 w-4" />
)}
</Button>
<Button
variant="ghost"
size="icon"
aria-label={t("thread.header.settings")}
onClick={onOpenSettings}
className="h-8 w-8 rounded-full text-muted-foreground/85 hover:bg-accent/40 hover:text-foreground"
>
<Settings className="h-4 w-4" />
</Button>
</div>
</div>
);
}
return ( return (
<div className="relative z-10 flex items-center justify-between gap-3 px-3 py-2"> <div className="relative z-10 flex items-center justify-between gap-3 px-3 py-2">
<div className="relative flex min-w-0 items-center gap-2"> <div className="relative flex min-w-0 items-center gap-2">
@ -33,19 +82,34 @@ export function ThreadHeader({
> >
<PanelLeftOpen className="h-3.5 w-3.5" /> <PanelLeftOpen className="h-3.5 w-3.5" />
</Button> </Button>
<button <div className="flex min-w-0 items-center rounded-md px-1.5 py-1 text-[12px] font-medium text-muted-foreground">
type="button"
onClick={onGoHome}
className="flex min-w-0 items-center gap-2 rounded-md px-1.5 py-1 text-[12px] font-medium text-muted-foreground transition-colors hover:bg-accent/35 hover:text-foreground"
>
<img
src="/brand/nanobot_icon.png"
alt=""
className="h-4 w-4 rounded-[5px] opacity-85"
aria-hidden
/>
<span className="max-w-[min(60vw,32rem)] truncate">{title}</span> <span className="max-w-[min(60vw,32rem)] truncate">{title}</span>
</button> </div>
</div>
<div className="flex items-center gap-0.5">
<Button
variant="ghost"
size="icon"
aria-label={t("thread.header.toggleTheme")}
onClick={onToggleTheme}
className="h-8 w-8 rounded-full text-muted-foreground/85 hover:bg-accent/40 hover:text-foreground"
>
{theme === "dark" ? (
<Sun className="h-4 w-4" />
) : (
<Moon className="h-4 w-4" />
)}
</Button>
<Button
variant="ghost"
size="icon"
aria-label={t("thread.header.settings")}
onClick={onOpenSettings}
className="h-8 w-8 rounded-full text-muted-foreground/85 hover:bg-accent/40 hover:text-foreground"
>
<Settings className="h-4 w-4" />
</Button>
</div> </div>
<div aria-hidden className="pointer-events-none absolute inset-x-0 top-full h-4" /> <div aria-hidden className="pointer-events-none absolute inset-x-0 top-full h-4" />

View File

@ -1,4 +1,13 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
import {
BarChart3,
BookOpen,
ChevronRight,
Code2,
LayoutGrid,
Lightbulb,
MoreHorizontal,
} from "lucide-react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { AskUserPrompt } from "@/components/thread/AskUserPrompt"; import { AskUserPrompt } from "@/components/thread/AskUserPrompt";
@ -8,15 +17,21 @@ import { StreamErrorNotice } from "@/components/thread/StreamErrorNotice";
import { ThreadViewport } from "@/components/thread/ThreadViewport"; import { ThreadViewport } from "@/components/thread/ThreadViewport";
import { useNanobotStream } from "@/hooks/useNanobotStream"; import { useNanobotStream } from "@/hooks/useNanobotStream";
import { useSessionHistory } from "@/hooks/useSessions"; import { useSessionHistory } from "@/hooks/useSessions";
import type { ChatSummary, UIMessage } from "@/lib/types"; import { listSlashCommands } from "@/lib/api";
import type { ChatSummary, SlashCommand, UIMessage } from "@/lib/types";
import { useClient } from "@/providers/ClientProvider"; import { useClient } from "@/providers/ClientProvider";
interface ThreadShellProps { interface ThreadShellProps {
session: ChatSummary | null; session: ChatSummary | null;
title: string; title: string;
onToggleSidebar: () => void; onToggleSidebar: () => void;
onGoHome: () => void; onGoHome?: () => void;
onNewChat: () => Promise<string | null>; onNewChat?: () => void;
onCreateChat?: () => Promise<string | null>;
onTurnEnd?: () => void;
theme?: "light" | "dark";
onToggleTheme?: () => void;
onOpenSettings?: () => void;
hideSidebarToggleOnDesktop?: boolean; hideSidebarToggleOnDesktop?: boolean;
} }
@ -28,22 +43,36 @@ function toModelBadgeLabel(modelName: string | null): string | null {
return leaf || trimmed; return leaf || trimmed;
} }
const QUICK_ACTION_KEYS = [
{ key: "plan", icon: LayoutGrid, tone: "text-[#f25b8f]" },
{ key: "analyze", icon: BarChart3, tone: "text-[#4f9de8]" },
{ key: "brainstorm", icon: Lightbulb, tone: "text-[#53c59d]" },
{ key: "code", icon: Code2, tone: "text-[#eba45d]" },
{ key: "summarize", icon: BookOpen, tone: "text-[#a877e7]" },
{ key: "more", icon: MoreHorizontal, tone: "text-muted-foreground/65" },
] as const;
export function ThreadShell({ export function ThreadShell({
session, session,
title, title,
onToggleSidebar, onToggleSidebar,
onGoHome, onCreateChat,
onNewChat, onTurnEnd,
theme = "light",
onToggleTheme = () => {},
onOpenSettings = () => {},
hideSidebarToggleOnDesktop = false, hideSidebarToggleOnDesktop = false,
}: ThreadShellProps) { }: ThreadShellProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const chatId = session?.chatId ?? null; const chatId = session?.chatId ?? null;
const historyKey = session?.key ?? null; const historyKey = session?.key ?? null;
const { messages: historical, loading } = useSessionHistory(historyKey); const { messages: historical, loading, hasPendingToolCalls } = useSessionHistory(historyKey);
const { client, modelName } = useClient(); const { client, modelName, token } = useClient();
const [booting, setBooting] = useState(false); const [booting, setBooting] = useState(false);
const [slashCommands, setSlashCommands] = useState<SlashCommand[]>([]);
const pendingFirstRef = useRef<string | null>(null); const pendingFirstRef = useRef<string | null>(null);
const messageCacheRef = useRef<Map<string, UIMessage[]>>(new Map()); const messageCacheRef = useRef<Map<string, UIMessage[]>>(new Map());
const lastCachedChatIdRef = useRef<string | null>(null);
const initial = useMemo(() => { const initial = useMemo(() => {
if (!chatId) return historical; if (!chatId) return historical;
@ -56,7 +85,7 @@ export function ThreadShell({
setMessages, setMessages,
streamError, streamError,
dismissStreamError, dismissStreamError,
} = useNanobotStream(chatId, initial); } = useNanobotStream(chatId, initial, hasPendingToolCalls, onTurnEnd);
const showHeroComposer = messages.length === 0 && !loading; const showHeroComposer = messages.length === 0 && !loading;
const pendingAsk = useMemo(() => { const pendingAsk = useMemo(() => {
for (let index = messages.length - 1; index >= 0; index -= 1) { for (let index = messages.length - 1; index >= 0; index -= 1) {
@ -89,10 +118,24 @@ export function ThreadShell({
setMessages(historical); setMessages(historical);
}, [chatId, historical, setMessages]); }, [chatId, historical, setMessages]);
useEffect(() => { useLayoutEffect(() => {
if (!chatId) return; if (!chatId) {
lastCachedChatIdRef.current = null;
return;
}
if (loading) return;
// Skip the first cache write after a chat switch. During that render,
// `messages` can still belong to the previous chat until the stream hook
// resets its local state for the new session.
if (lastCachedChatIdRef.current !== chatId) {
lastCachedChatIdRef.current = chatId;
if (messages.length > 0) {
messageCacheRef.current.set(chatId, messages);
}
return;
}
messageCacheRef.current.set(chatId, messages); messageCacheRef.current.set(chatId, messages);
}, [chatId, messages]); }, [chatId, loading, messages]);
useEffect(() => { useEffect(() => {
if (!chatId) return; if (!chatId) return;
@ -112,18 +155,115 @@ export function ThreadShell({
setBooting(false); setBooting(false);
}, [chatId, client, setMessages]); }, [chatId, client, setMessages]);
useEffect(() => {
let cancelled = false;
(async () => {
try {
const commands = await listSlashCommands(token);
if (!cancelled) setSlashCommands(commands);
} catch {
if (!cancelled) setSlashCommands([]);
}
})();
return () => {
cancelled = true;
};
}, [token]);
const handleWelcomeSend = useCallback( const handleWelcomeSend = useCallback(
async (content: string) => { async (content: string) => {
if (booting) return; if (booting) return;
setBooting(true); setBooting(true);
pendingFirstRef.current = content; pendingFirstRef.current = content;
const newId = await onNewChat(); const newId = await onCreateChat?.();
if (!newId) { if (!newId) {
pendingFirstRef.current = null; pendingFirstRef.current = null;
setBooting(false); setBooting(false);
} }
}, },
[booting, onNewChat], [booting, onCreateChat],
);
const handleQuickAction = useCallback(
(prompt: string) => {
if (session) {
send(prompt);
return;
}
void handleWelcomeSend(prompt);
},
[handleWelcomeSend, send, session],
);
const quickActions = (
<div className="mx-auto grid w-full max-w-[58rem] grid-cols-2 gap-3 pt-4 sm:grid-cols-3 lg:grid-cols-6 lg:gap-4">
{QUICK_ACTION_KEYS.map(({ key, icon: Icon, tone }) => {
const title = t(`thread.empty.quickActions.${key}.title`);
const prompt = t(`thread.empty.quickActions.${key}.prompt`);
return (
<button
key={key}
type="button"
onClick={() => handleQuickAction(prompt)}
disabled={booting || isStreaming}
className="group flex min-h-[136px] flex-col justify-between rounded-[20px] border border-black/[0.035] bg-card px-5 py-5 text-left shadow-[0_14px_34px_rgba(15,23,42,0.07)] transition-all hover:-translate-y-0.5 hover:shadow-[0_18px_42px_rgba(15,23,42,0.10)] disabled:pointer-events-none disabled:opacity-60 dark:border-white/[0.06] dark:shadow-[0_16px_34px_rgba(0,0,0,0.28)]"
>
<Icon className={`h-[18px] w-[18px] ${tone}`} strokeWidth={2} />
<span className="max-w-[7.5rem] text-[15px] font-medium leading-[1.28] tracking-[-0.01em] text-foreground/82">
{title}
</span>
<ChevronRight className="h-4 w-4 self-end text-muted-foreground/45 transition-colors group-hover:text-muted-foreground" />
</button>
);
})}
</div>
);
const composer = (
<>
{streamError ? (
<StreamErrorNotice
error={streamError}
onDismiss={dismissStreamError}
/>
) : null}
{pendingAsk ? (
<AskUserPrompt
question={pendingAsk.question}
buttons={pendingAsk.buttons}
onAnswer={send}
/>
) : null}
{session ? (
<ThreadComposer
onSend={send}
disabled={!chatId}
isStreaming={isStreaming}
placeholder={
showHeroComposer
? t("thread.composer.placeholderHero")
: t("thread.composer.placeholderThread")
}
modelLabel={toModelBadgeLabel(modelName)}
variant={showHeroComposer ? "hero" : "thread"}
slashCommands={slashCommands}
/>
) : (
<ThreadComposer
onSend={handleWelcomeSend}
disabled={booting}
isStreaming={isStreaming}
placeholder={
booting
? t("thread.composer.placeholderOpening")
: t("thread.composer.placeholderHero")
}
modelLabel={toModelBadgeLabel(modelName)}
variant="hero"
/>
)}
{showHeroComposer ? quickActions : null}
</>
); );
const emptyState = loading ? ( const emptyState = loading ? (
@ -131,20 +271,10 @@ export function ThreadShell({
{t("thread.loadingConversation")} {t("thread.loadingConversation")}
</div> </div>
) : ( ) : (
<div className="flex w-full max-w-[40rem] flex-col gap-2 text-left animate-in fade-in-0 slide-in-from-bottom-2 duration-500"> <div className="flex w-full flex-col items-center text-center animate-in fade-in-0 slide-in-from-bottom-2 duration-500">
<div className="inline-flex items-center gap-2 text-[11px] font-medium text-muted-foreground"> <h1 className="text-balance text-[40px] font-normal leading-tight tracking-[-0.045em] text-foreground sm:text-[48px]">
<img {t("thread.empty.greeting")}
src="/brand/nanobot_icon.png" </h1>
alt=""
aria-hidden
draggable={false}
className="h-4 w-4 rounded-sm opacity-90"
/>
<span className="text-foreground/82">nanobot</span>
</div>
<p className="max-w-[28rem] text-[13px] leading-6 text-muted-foreground">
{t("thread.empty.description")}
</p>
</div> </div>
); );
@ -153,55 +283,17 @@ export function ThreadShell({
<ThreadHeader <ThreadHeader
title={title} title={title}
onToggleSidebar={onToggleSidebar} onToggleSidebar={onToggleSidebar}
onGoHome={onGoHome} theme={theme}
onToggleTheme={onToggleTheme}
onOpenSettings={onOpenSettings}
hideSidebarToggleOnDesktop={hideSidebarToggleOnDesktop} hideSidebarToggleOnDesktop={hideSidebarToggleOnDesktop}
minimal={!session && !loading}
/> />
<ThreadViewport <ThreadViewport
messages={messages} messages={messages}
isStreaming={isStreaming} isStreaming={isStreaming}
emptyState={emptyState} emptyState={emptyState}
composer={ composer={composer}
<>
{streamError ? (
<StreamErrorNotice
error={streamError}
onDismiss={dismissStreamError}
/>
) : null}
{pendingAsk ? (
<AskUserPrompt
question={pendingAsk.question}
buttons={pendingAsk.buttons}
onAnswer={send}
/>
) : null}
{session ? (
<ThreadComposer
onSend={send}
disabled={!chatId}
placeholder={
showHeroComposer
? t("thread.composer.placeholderHero")
: t("thread.composer.placeholderThread")
}
modelLabel={toModelBadgeLabel(modelName)}
variant={showHeroComposer ? "hero" : "thread"}
/>
) : (
<ThreadComposer
onSend={handleWelcomeSend}
disabled={booting}
placeholder={
booting
? t("thread.composer.placeholderOpening")
: t("thread.composer.placeholderHero")
}
modelLabel={toModelBadgeLabel(modelName)}
variant="hero"
/>
)}
</>
}
/> />
</section> </section>
); );

View File

@ -82,9 +82,9 @@ export function ThreadViewport({
</div> </div>
</div> </div>
) : ( ) : (
<div className="mx-auto flex min-h-full w-full max-w-[64rem] flex-col px-4"> <div className="mx-auto flex min-h-full w-full max-w-[72rem] flex-col px-4">
<div className="flex w-full flex-1 justify-center pb-16 pt-14 md:pt-[3.5rem]"> <div className="flex w-full flex-1 items-center justify-center pb-[7vh] pt-8">
<div className="flex w-full max-w-[40rem] flex-col gap-5"> <div className="flex w-full max-w-[58rem] flex-col gap-6">
{emptyState} {emptyState}
<div className="w-full">{composer}</div> <div className="w-full">{composer}</div>
</div> </div>

View File

@ -25,9 +25,9 @@
--input: 0 0% 89.8%; --input: 0 0% 89.8%;
--ring: 0 0% 3.9%; --ring: 0 0% 3.9%;
--radius: 0.4375rem; --radius: 0.4375rem;
--sidebar: 0 0% 98%; --sidebar: 0 0% 98.5%;
--sidebar-foreground: 0 0% 3.9%; --sidebar-foreground: 0 0% 3.9%;
--sidebar-accent: 0 0% 96.1%; --sidebar-accent: 0 0% 95.8%;
--sidebar-accent-foreground: 0 0% 9%; --sidebar-accent-foreground: 0 0% 9%;
--sidebar-border: 0 0% 89.8%; --sidebar-border: 0 0% 89.8%;
} }
@ -52,9 +52,9 @@
--border: 0 0% 18%; --border: 0 0% 18%;
--input: 0 0% 18%; --input: 0 0% 18%;
--ring: 0 0% 83.1%; --ring: 0 0% 83.1%;
--sidebar: 0 0% 12%; --sidebar: 0 0% 11.5%;
--sidebar-foreground: 0 0% 98%; --sidebar-foreground: 0 0% 98%;
--sidebar-accent: 0 0% 16%; --sidebar-accent: 0 0% 15.5%;
--sidebar-accent-foreground: 0 0% 98%; --sidebar-accent-foreground: 0 0% 98%;
--sidebar-border: 0 0% 18%; --sidebar-border: 0 0% 18%;
} }

View File

@ -37,6 +37,8 @@ export interface SendImage {
export function useNanobotStream( export function useNanobotStream(
chatId: string | null, chatId: string | null,
initialMessages: UIMessage[] = [], initialMessages: UIMessage[] = [],
hasPendingToolCalls = false,
onTurnEnd?: () => void,
): { ): {
messages: UIMessage[]; messages: UIMessage[];
isStreaming: boolean; isStreaming: boolean;
@ -51,9 +53,23 @@ export function useNanobotStream(
} { } {
const { client } = useClient(); const { client } = useClient();
const [messages, setMessages] = useState<UIMessage[]>(initialMessages); const [messages, setMessages] = useState<UIMessage[]>(initialMessages);
const [isStreaming, setIsStreaming] = useState(false); /** If the last loaded message is a trace row (e.g. "Using 2 tools"),
* the model was still processing when the page loaded keep the
* loading spinner alive so the user sees the model is active. */
const initialStreaming = initialMessages.length > 0
? initialMessages[initialMessages.length - 1].kind === "trace"
: false;
const [isStreaming, setIsStreaming] = useState(initialStreaming || hasPendingToolCalls);
const [streamError, setStreamError] = useState<StreamError | null>(null); const [streamError, setStreamError] = useState<StreamError | null>(null);
const buffer = useRef<StreamBuffer | null>(null); const buffer = useRef<StreamBuffer | null>(null);
/** Timer that defers ``isStreaming = false`` after ``stream_end``.
*
* When the model finishes a text segment and calls a tool, the server
* sends ``stream_end`` but the agent is still "thinking" while the tool
* executes. By deferring the flag reset by a short window (1 s) we keep
* the loading spinner alive across tool-call boundaries without needing
* backend changes. */
const streamEndTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(() => { useEffect(() => {
return client.onError((err) => setStreamError(err)); return client.onError((err) => setStreamError(err));
@ -62,21 +78,43 @@ export function useNanobotStream(
const dismissStreamError = useCallback(() => setStreamError(null), []); const dismissStreamError = useCallback(() => setStreamError(null), []);
// Reset local state when switching chats. ``streamError`` is scoped to the // Reset local state when switching chats. ``streamError`` is scoped to the
// send that triggered it, so a chat swap should wipe it out: a stale // send that triggered it, so a chat swap should wipe it out: a stale
// "Message too large" banner on a freshly-opened chat-B would confuse the // "Message too large" banner on a freshly-opened chat-B would confuse the
// user about which send actually failed (and in which chat). // user about which send actually failed (and in which chat).
useEffect(() => { useEffect(() => {
setMessages(initialMessages); setMessages(initialMessages);
setIsStreaming(false); // Check if the new chat's last message is a trace row — if so, the
setStreamError(null); // model may still be processing.
buffer.current = null; setIsStreaming(
// eslint-disable-next-line react-hooks/exhaustive-deps initialMessages.length > 0
}, [chatId]); ? initialMessages[initialMessages.length - 1].kind === "trace"
: false,
);
// Also consider hasPendingToolCalls from session history.
if (hasPendingToolCalls) {
setIsStreaming(true);
}
setStreamError(null);
buffer.current = null;
if (streamEndTimerRef.current !== null) {
clearTimeout(streamEndTimerRef.current);
streamEndTimerRef.current = null;
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [chatId, initialMessages, hasPendingToolCalls]);
useEffect(() => { useEffect(() => {
if (!chatId) return; if (!chatId) return;
const handle = (ev: InboundEvent) => { const handle = (ev: InboundEvent) => {
// Any incoming event while the debounce timer is alive means the model
// is still working (e.g. tool result arrived, more text to stream).
// Cancel the pending "stream ended" timer so we don't hide the spinner.
if (streamEndTimerRef.current !== null) {
clearTimeout(streamEndTimerRef.current);
streamEndTimerRef.current = null;
}
if (ev.event === "delta") { if (ev.event === "delta") {
const id = buffer.current?.messageId ?? crypto.randomUUID(); const id = buffer.current?.messageId ?? crypto.randomUUID();
if (!buffer.current) { if (!buffer.current) {
@ -103,18 +141,31 @@ export function useNanobotStream(
} }
if (ev.event === "stream_end") { if (ev.event === "stream_end") {
if (!buffer.current) { // stream_end only means the text segment finished — the model may
setIsStreaming(false); // still be executing tools. Do NOT reset isStreaming here; the
return; // definitive "turn is complete" signal is ``turn_end``.
} if (!buffer.current) return;
const finalId = buffer.current.messageId;
buffer.current = null; buffer.current = null;
return;
}
if (ev.event === "turn_end") {
// Definitive signal that the turn is fully complete. Cancel any
// pending debounce timer and stop the loading indicator immediately.
if (streamEndTimerRef.current !== null) {
clearTimeout(streamEndTimerRef.current);
streamEndTimerRef.current = null;
}
setIsStreaming(false); setIsStreaming(false);
setMessages((prev) => setMessages((prev) =>
prev.map((m) => prev.map((m) => (m.isStreaming ? { ...m, isStreaming: false } : m)),
m.id === finalId ? { ...m, isStreaming: false } : m,
),
); );
onTurnEnd?.();
return;
}
if (ev.event === "session_updated") {
onTurnEnd?.();
return; return;
} }
@ -157,7 +208,8 @@ export function useNanobotStream(
// flight, drop the placeholder so we don't render the text twice. // flight, drop the placeholder so we don't render the text twice.
const activeId = buffer.current?.messageId; const activeId = buffer.current?.messageId;
buffer.current = null; buffer.current = null;
setIsStreaming(false); // Do NOT reset isStreaming here — only ``turn_end`` signals that
// the full turn (all tool calls + final text) is complete.
setMessages((prev) => { setMessages((prev) => {
const filtered = activeId ? prev.filter((m) => m.id !== activeId) : prev; const filtered = activeId ? prev.filter((m) => m.id !== activeId) : prev;
const content = ev.buttons?.length ? (ev.button_prompt ?? ev.text) : ev.text; const content = ev.buttons?.length ? (ev.button_prompt ?? ev.text) : ev.text;
@ -183,8 +235,12 @@ export function useNanobotStream(
return () => { return () => {
unsub(); unsub();
buffer.current = null; buffer.current = null;
if (streamEndTimerRef.current !== null) {
clearTimeout(streamEndTimerRef.current);
streamEndTimerRef.current = null;
}
}; };
}, [chatId, client]); }, [chatId, client, onTurnEnd]);
const send = useCallback( const send = useCallback(
(content: string, images?: SendImage[]) => { (content: string, images?: SendImage[]) => {
@ -205,6 +261,9 @@ export function useNanobotStream(
...(previews ? { images: previews } : {}), ...(previews ? { images: previews } : {}),
}, },
]); ]);
// Mark streaming immediately so the UI shows the loading indicator
// right away, before the first delta arrives from the server.
setIsStreaming(true);
const wireMedia = hasImages ? images!.map((i) => i.media) : undefined; const wireMedia = hasImages ? images!.map((i) => i.media) : undefined;
client.sendMessage(chatId, content, wireMedia); client.sendMessage(chatId, content, wireMedia);
}, },

View File

@ -61,6 +61,7 @@ export function useSessions(): {
chatId, chatId,
createdAt: new Date().toISOString(), createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(), updatedAt: new Date().toISOString(),
title: "",
preview: "", preview: "",
}, },
...prev.filter((s) => s.key !== key), ...prev.filter((s) => s.key !== key),
@ -84,6 +85,9 @@ export function useSessionHistory(key: string | null): {
messages: UIMessage[]; messages: UIMessage[];
loading: boolean; loading: boolean;
error: string | null; error: string | null;
/** ``true`` when the last persisted assistant turn has ``tool_calls`` but no
* final text yet the model was still processing when the page loaded. */
hasPendingToolCalls: boolean;
} { } {
const { token } = useClient(); const { token } = useClient();
const [state, setState] = useState<{ const [state, setState] = useState<{
@ -91,11 +95,13 @@ export function useSessionHistory(key: string | null): {
messages: UIMessage[]; messages: UIMessage[];
loading: boolean; loading: boolean;
error: string | null; error: string | null;
hasPendingToolCalls: boolean;
}>({ }>({
key: null, key: null,
messages: [], messages: [],
loading: false, loading: false,
error: null, error: null,
hasPendingToolCalls: false,
}); });
useEffect(() => { useEffect(() => {
@ -105,6 +111,7 @@ export function useSessionHistory(key: string | null): {
messages: [], messages: [],
loading: false, loading: false,
error: null, error: null,
hasPendingToolCalls: false,
}); });
return; return;
} }
@ -116,6 +123,7 @@ export function useSessionHistory(key: string | null): {
messages: [], messages: [],
loading: true, loading: true,
error: null, error: null,
hasPendingToolCalls: false,
}); });
(async () => { (async () => {
try { try {
@ -146,11 +154,21 @@ export function useSessionHistory(key: string | null): {
}, },
]; ];
}); });
// Tool result rows can trail the assistant tool-call row while the turn
// is still running, so check the last conversational row.
const lastRaw = [...body.messages]
.reverse()
.find((m) => m.role === "user" || m.role === "assistant");
const hasPending =
lastRaw?.role === "assistant" &&
Array.isArray(lastRaw.tool_calls) &&
lastRaw.tool_calls.length > 0;
setState({ setState({
key, key,
messages: ui, messages: ui,
loading: false, loading: false,
error: null, error: null,
hasPendingToolCalls: hasPending,
}); });
} catch (e) { } catch (e) {
if (cancelled) return; if (cancelled) return;
@ -162,6 +180,7 @@ export function useSessionHistory(key: string | null): {
messages: [], messages: [],
loading: false, loading: false,
error: null, error: null,
hasPendingToolCalls: false,
}); });
} else { } else {
setState({ setState({
@ -169,6 +188,7 @@ export function useSessionHistory(key: string | null): {
messages: [], messages: [],
loading: false, loading: false,
error: (e as Error).message, error: (e as Error).message,
hasPendingToolCalls: false,
}); });
} }
} }
@ -179,19 +199,20 @@ export function useSessionHistory(key: string | null): {
}, [key, token]); }, [key, token]);
if (!key) { if (!key) {
return { messages: EMPTY_MESSAGES, loading: false, error: null }; return { messages: EMPTY_MESSAGES, loading: false, error: null, hasPendingToolCalls: false };
} }
// Even before the effect above commits its loading state, never surface the // Even before the effect above commits its loading state, never surface the
// previous session's payload for a brand-new key. // previous session's payload for a brand-new key.
if (state.key !== key) { if (state.key !== key) {
return { messages: EMPTY_MESSAGES, loading: true, error: null }; return { messages: EMPTY_MESSAGES, loading: true, error: null, hasPendingToolCalls: false };
} }
return { return {
messages: state.messages, messages: state.messages,
loading: state.loading, loading: state.loading,
error: state.error, error: state.error,
hasPendingToolCalls: state.hasPendingToolCalls,
}; };
} }
@ -201,7 +222,7 @@ export function sessionTitle(
firstUserMessage?: string, firstUserMessage?: string,
): string { ): string {
return deriveTitle( return deriveTitle(
firstUserMessage || session.preview, session.title || firstUserMessage || session.preview,
i18n.t("chat.newChat"), i18n.t("chat.newChat"),
); );
} }

View File

@ -9,6 +9,18 @@
"title": "Couldn't reach nanobot", "title": "Couldn't reach nanobot",
"gatewayHint": "Make sure the gateway is running (`nanobot gateway`) and that this page is open on the same machine." "gatewayHint": "Make sure the gateway is running (`nanobot gateway`) and that this page is open on the same machine."
}, },
"auth": {
"title": "Authentication required",
"hint": "Enter the secret configured as tokenIssueSecret in your gateway config.",
"placeholder": "Password",
"submit": "Connect",
"invalid": "Invalid password. Try again."
},
"account": {
"section": "Account",
"logoutHint": "Disconnect this browser from the gateway.",
"logout": "Sign out"
},
"documentTitle": { "documentTitle": {
"base": "nanobot", "base": "nanobot",
"chat": "{{title}} · nanobot" "chat": "{{title}} · nanobot"
@ -18,11 +30,19 @@
} }
}, },
"sidebar": { "sidebar": {
"navigation": "Sidebar navigation",
"globalActions": "Global actions",
"collapse": "Collapse sidebar", "collapse": "Collapse sidebar",
"toggleTheme": "Toggle theme", "toggleTheme": "Toggle theme",
"home": "Home",
"newChat": "New chat", "newChat": "New chat",
"searchAria": "Search chats",
"searchPlaceholder": "Search chats",
"searchResults": "Results",
"noSearchResults": "No matching chats.",
"recent": "Recent", "recent": "Recent",
"refreshSessions": "Refresh sessions", "refreshSessions": "Refresh sessions",
"settings": "Settings",
"language": { "language": {
"label": "Language", "label": "Language",
"ariaLabel": "Change language" "ariaLabel": "Change language"
@ -34,7 +54,12 @@
"noSessions": "No sessions yet.", "noSessions": "No sessions yet.",
"actions": "Chat actions for {{title}}", "actions": "Chat actions for {{title}}",
"delete": "Delete", "delete": "Delete",
"newChat": "New chat" "newChat": "New chat",
"groups": {
"today": "Today",
"yesterday": "Yesterday",
"earlier": "Earlier"
}
}, },
"deleteConfirm": { "deleteConfirm": {
"title": "Delete “{{title}}”?", "title": "Delete “{{title}}”?",
@ -53,19 +78,100 @@
"thread": { "thread": {
"loadingConversation": "Loading conversation…", "loadingConversation": "Loading conversation…",
"empty": { "empty": {
"description": "Ask questions, continue local work, or start a new thread." "greeting": "What can I do for you?",
"quickActions": {
"plan": {
"title": "Create a project plan",
"prompt": "Create a concise project plan for what I should build next."
},
"analyze": {
"title": "Analyze this data",
"prompt": "Help me analyze this data and call out the most important patterns."
},
"brainstorm": {
"title": "Brainstorm ideas",
"prompt": "Brainstorm a few practical ideas and tradeoffs for this problem."
},
"code": {
"title": "Write code",
"prompt": "Help me write the code for this task, starting with the smallest useful change."
},
"summarize": {
"title": "Summarize this document",
"prompt": "Summarize this document and list the key takeaways."
},
"more": {
"title": "More",
"prompt": "Show me a few useful ways you can help in this workspace."
}
}
}, },
"header": { "header": {
"toggleSidebar": "Toggle sidebar" "toggleSidebar": "Toggle sidebar",
"newChat": "Start a new chat",
"toggleTheme": "Toggle theme from header",
"settings": "Open settings"
}, },
"composer": { "composer": {
"placeholderThread": "Type your message…", "placeholderThread": "Type your message…",
"placeholderHero": "What's on your mind?", "placeholderHero": "Ask anything...",
"placeholderOpening": "Opening a new chat…", "placeholderOpening": "Opening a new chat…",
"placeholderStreaming": "Model is responding…",
"inputAria": "Message input", "inputAria": "Message input",
"sendHint": "Enter to send · Shift+Enter for newline", "sendHint": "Enter to send · Shift+Enter for newline",
"send": "Send message", "send": "Send message",
"attachImage": "Attach image", "attachImage": "Attach image",
"tools": {
"search": "Search",
"reason": "Reason",
"deepResearch": "Deep research",
"voice": "Voice input"
},
"slash": {
"ariaLabel": "Slash commands",
"label": "commands",
"navigateHint": "↑↓ Navigate",
"selectHint": "Enter/Tab Select",
"closeHint": "Esc Close",
"commands": {
"new": {
"title": "New chat",
"description": "Stop the current task and start a fresh conversation."
},
"stop": {
"title": "Stop current task",
"description": "Cancel the active agent turn for this chat."
},
"restart": {
"title": "Restart nanobot",
"description": "Restart the bot process in place."
},
"status": {
"title": "Show status",
"description": "Display runtime, provider, and channel status."
},
"history": {
"title": "Show conversation history",
"description": "Print the last N persisted conversation messages."
},
"dream": {
"title": "Run Dream",
"description": "Manually trigger memory consolidation."
},
"dream_log": {
"title": "Show Dream log",
"description": "Show what the last Dream consolidation changed."
},
"dream_restore": {
"title": "Restore memory",
"description": "Revert memory to a previous Dream snapshot."
},
"help": {
"title": "Show help",
"description": "List available slash commands."
}
}
},
"encoding": "Encoding…", "encoding": "Encoding…",
"remove": "Remove attachment", "remove": "Remove attachment",
"normalizedSizeHint": "{{orig}} → {{current}} (auto)", "normalizedSizeHint": "{{orig}} → {{current}} (auto)",
@ -85,7 +191,9 @@
"assistantTyping": "Assistant is typing", "assistantTyping": "Assistant is typing",
"toolSingle": "Using a tool", "toolSingle": "Using a tool",
"toolMany": "Used {{count}} tools", "toolMany": "Used {{count}} tools",
"imageAttachment": "Image attachment" "imageAttachment": "Image attachment",
"copyReply": "Copy reply",
"copiedReply": "Copied reply"
}, },
"lightbox": { "lightbox": {
"title": "Image preview", "title": "Image preview",

View File

@ -53,7 +53,34 @@
"thread": { "thread": {
"loadingConversation": "Cargando conversación…", "loadingConversation": "Cargando conversación…",
"empty": { "empty": {
"description": "Haz preguntas, continúa tu trabajo local o inicia un nuevo hilo." "description": "Haz preguntas, continúa tu trabajo local o inicia un nuevo hilo.",
"greeting": "¿Qué puedo hacer por ti?",
"quickActions": {
"plan": {
"title": "Crear un plan de proyecto",
"prompt": "Crea un plan de proyecto conciso para lo que debería construir después."
},
"analyze": {
"title": "Analizar estos datos",
"prompt": "Ayúdame a analizar estos datos y destaca los patrones más importantes."
},
"brainstorm": {
"title": "Lluvia de ideas",
"prompt": "Propón algunas ideas prácticas y sus compensaciones para este problema."
},
"code": {
"title": "Escribir código",
"prompt": "Ayúdame a escribir el código para esta tarea, empezando por el cambio útil más pequeño."
},
"summarize": {
"title": "Resumir este documento",
"prompt": "Resume este documento y enumera las conclusiones clave."
},
"more": {
"title": "Más",
"prompt": "Muéstrame algunas formas útiles en las que puedes ayudar en este workspace."
}
}
}, },
"header": { "header": {
"toggleSidebar": "Mostrar u ocultar la barra lateral" "toggleSidebar": "Mostrar u ocultar la barra lateral"
@ -62,6 +89,7 @@
"placeholderThread": "Escribe tu mensaje…", "placeholderThread": "Escribe tu mensaje…",
"placeholderHero": "¿Qué tienes en mente?", "placeholderHero": "¿Qué tienes en mente?",
"placeholderOpening": "Abriendo un nuevo chat…", "placeholderOpening": "Abriendo un nuevo chat…",
"placeholderStreaming": "El modelo está respondiendo…",
"inputAria": "Entrada de mensaje", "inputAria": "Entrada de mensaje",
"sendHint": "Enter para enviar · Shift+Enter para nueva línea", "sendHint": "Enter para enviar · Shift+Enter para nueva línea",
"send": "Enviar mensaje", "send": "Enviar mensaje",
@ -76,6 +104,51 @@
"decode_failed": "No se pudo decodificar esta imagen", "decode_failed": "No se pudo decodificar esta imagen",
"too_large": "Imagen demasiado grande — prueba una más pequeña", "too_large": "Imagen demasiado grande — prueba una más pequeña",
"io": "No se pudo leer este archivo" "io": "No se pudo leer este archivo"
},
"slash": {
"ariaLabel": "Comandos slash",
"label": "comandos",
"navigateHint": "↑↓ Navegar",
"selectHint": "Enter/Tab Insertar",
"closeHint": "Esc Cerrar",
"commands": {
"new": {
"title": "Nuevo chat",
"description": "Detiene la tarea actual e inicia una conversación nueva."
},
"stop": {
"title": "Detener tarea actual",
"description": "Cancela el turno activo del agent en este chat."
},
"restart": {
"title": "Reiniciar nanobot",
"description": "Reinicia el proceso del bot en el mismo lugar."
},
"status": {
"title": "Mostrar estado",
"description": "Muestra el estado del runtime, provider y channels."
},
"history": {
"title": "Mostrar historial",
"description": "Imprime los últimos N mensajes persistidos de la conversación."
},
"dream": {
"title": "Ejecutar Dream",
"description": "Activa manualmente la consolidación de memoria."
},
"dream_log": {
"title": "Mostrar registro de Dream",
"description": "Muestra qué cambió la última consolidación Dream."
},
"dream_restore": {
"title": "Restaurar memoria",
"description": "Revierte la memoria a una instantánea Dream anterior."
},
"help": {
"title": "Mostrar ayuda",
"description": "Lista los comandos slash disponibles."
}
}
} }
}, },
"scrollToBottom": "Desplazarse al final" "scrollToBottom": "Desplazarse al final"

View File

@ -53,7 +53,34 @@
"thread": { "thread": {
"loadingConversation": "Chargement de la conversation…", "loadingConversation": "Chargement de la conversation…",
"empty": { "empty": {
"description": "Posez des questions, poursuivez votre travail local ou démarrez un nouveau fil." "description": "Posez des questions, poursuivez votre travail local ou démarrez un nouveau fil.",
"greeting": "Que puis-je faire pour vous ?",
"quickActions": {
"plan": {
"title": "Créer un plan de projet",
"prompt": "Créez un plan de projet concis pour ce que je devrais construire ensuite."
},
"analyze": {
"title": "Analyser ces données",
"prompt": "Aidez-moi à analyser ces données et à faire ressortir les tendances les plus importantes."
},
"brainstorm": {
"title": "Trouver des idées",
"prompt": "Proposez quelques idées pratiques et leurs compromis pour ce problème."
},
"code": {
"title": "Écrire du code",
"prompt": "Aidez-moi à écrire le code pour cette tâche, en commençant par le plus petit changement utile."
},
"summarize": {
"title": "Résumer ce document",
"prompt": "Résumez ce document et listez les points clés à retenir."
},
"more": {
"title": "Plus",
"prompt": "Montrez-moi quelques façons utiles dont vous pouvez maider dans cet espace de travail."
}
}
}, },
"header": { "header": {
"toggleSidebar": "Afficher ou masquer la barre latérale" "toggleSidebar": "Afficher ou masquer la barre latérale"
@ -62,6 +89,7 @@
"placeholderThread": "Saisissez votre message…", "placeholderThread": "Saisissez votre message…",
"placeholderHero": "Quavez-vous en tête ?", "placeholderHero": "Quavez-vous en tête ?",
"placeholderOpening": "Ouverture dune nouvelle discussion…", "placeholderOpening": "Ouverture dune nouvelle discussion…",
"placeholderStreaming": "Le modèle est en train de répondre…",
"inputAria": "Champ de message", "inputAria": "Champ de message",
"sendHint": "Entrée pour envoyer · Maj+Entrée pour un retour à la ligne", "sendHint": "Entrée pour envoyer · Maj+Entrée pour un retour à la ligne",
"send": "Envoyer le message", "send": "Envoyer le message",
@ -76,6 +104,51 @@
"decode_failed": "Impossible de décoder cette image", "decode_failed": "Impossible de décoder cette image",
"too_large": "Image trop grande — essayez-en une plus petite", "too_large": "Image trop grande — essayez-en une plus petite",
"io": "Impossible de lire ce fichier" "io": "Impossible de lire ce fichier"
},
"slash": {
"ariaLabel": "Commandes slash",
"label": "commandes",
"navigateHint": "↑↓ Naviguer",
"selectHint": "Entrée/Tab Insérer",
"closeHint": "Échap Fermer",
"commands": {
"new": {
"title": "Nouvelle discussion",
"description": "Arrêter la tâche en cours et démarrer une nouvelle conversation."
},
"stop": {
"title": "Arrêter la tâche en cours",
"description": "Annuler le tour agent actif pour cette discussion."
},
"restart": {
"title": "Redémarrer nanobot",
"description": "Redémarrer le processus du bot sur place."
},
"status": {
"title": "Afficher létat",
"description": "Afficher létat du runtime, du provider et des channels."
},
"history": {
"title": "Afficher lhistorique",
"description": "Afficher les N derniers messages persistés de la conversation."
},
"dream": {
"title": "Lancer Dream",
"description": "Déclencher manuellement la consolidation de la mémoire."
},
"dream_log": {
"title": "Afficher le journal Dream",
"description": "Afficher ce que la dernière consolidation Dream a changé."
},
"dream_restore": {
"title": "Restaurer la mémoire",
"description": "Revenir à un instantané Dream précédent."
},
"help": {
"title": "Afficher laide",
"description": "Lister les commandes slash disponibles."
}
}
} }
}, },
"scrollToBottom": "Faire défiler vers le bas" "scrollToBottom": "Faire défiler vers le bas"

View File

@ -53,7 +53,34 @@
"thread": { "thread": {
"loadingConversation": "Memuat percakapan…", "loadingConversation": "Memuat percakapan…",
"empty": { "empty": {
"description": "Ajukan pertanyaan, lanjutkan pekerjaan lokal, atau mulai thread baru." "description": "Ajukan pertanyaan, lanjutkan pekerjaan lokal, atau mulai thread baru.",
"greeting": "Apa yang bisa saya bantu?",
"quickActions": {
"plan": {
"title": "Buat rencana proyek",
"prompt": "Buat rencana proyek ringkas untuk apa yang sebaiknya saya bangun berikutnya."
},
"analyze": {
"title": "Analisis data ini",
"prompt": "Bantu saya menganalisis data ini dan soroti pola yang paling penting."
},
"brainstorm": {
"title": "Brainstorm ide",
"prompt": "Brainstorm beberapa ide praktis dan tradeoff untuk masalah ini."
},
"code": {
"title": "Tulis kode",
"prompt": "Bantu saya menulis kode untuk tugas ini, mulai dari perubahan berguna yang paling kecil."
},
"summarize": {
"title": "Ringkas dokumen ini",
"prompt": "Ringkas dokumen ini dan daftar poin-poin utamanya."
},
"more": {
"title": "Lainnya",
"prompt": "Tunjukkan beberapa cara berguna Anda dapat membantu di workspace ini."
}
}
}, },
"header": { "header": {
"toggleSidebar": "Tampilkan atau sembunyikan sidebar" "toggleSidebar": "Tampilkan atau sembunyikan sidebar"
@ -62,6 +89,7 @@
"placeholderThread": "Ketik pesan Anda…", "placeholderThread": "Ketik pesan Anda…",
"placeholderHero": "Apa yang sedang Anda pikirkan?", "placeholderHero": "Apa yang sedang Anda pikirkan?",
"placeholderOpening": "Membuka obrolan baru…", "placeholderOpening": "Membuka obrolan baru…",
"placeholderStreaming": "Model sedang merespons…",
"inputAria": "Input pesan", "inputAria": "Input pesan",
"sendHint": "Enter untuk kirim · Shift+Enter untuk baris baru", "sendHint": "Enter untuk kirim · Shift+Enter untuk baris baru",
"send": "Kirim pesan", "send": "Kirim pesan",
@ -76,6 +104,51 @@
"decode_failed": "Tidak dapat mendekode gambar ini", "decode_failed": "Tidak dapat mendekode gambar ini",
"too_large": "Gambar terlalu besar — coba yang lebih kecil", "too_large": "Gambar terlalu besar — coba yang lebih kecil",
"io": "Tidak dapat membaca file ini" "io": "Tidak dapat membaca file ini"
},
"slash": {
"ariaLabel": "Perintah slash",
"label": "perintah",
"navigateHint": "↑↓ Pilih",
"selectHint": "Enter/Tab Sisipkan",
"closeHint": "Esc Tutup",
"commands": {
"new": {
"title": "Obrolan baru",
"description": "Hentikan tugas saat ini dan mulai percakapan baru."
},
"stop": {
"title": "Hentikan tugas saat ini",
"description": "Batalkan giliran agent yang sedang aktif di chat ini."
},
"restart": {
"title": "Mulai ulang nanobot",
"description": "Mulai ulang proses bot di tempat."
},
"status": {
"title": "Tampilkan status",
"description": "Tampilkan status runtime, provider, dan channel."
},
"history": {
"title": "Tampilkan riwayat",
"description": "Cetak N pesan percakapan tersimpan terbaru."
},
"dream": {
"title": "Jalankan Dream",
"description": "Picu konsolidasi memori secara manual."
},
"dream_log": {
"title": "Tampilkan log Dream",
"description": "Tampilkan perubahan dari konsolidasi Dream terakhir."
},
"dream_restore": {
"title": "Pulihkan memori",
"description": "Kembalikan memori ke snapshot Dream sebelumnya."
},
"help": {
"title": "Tampilkan bantuan",
"description": "Daftar perintah slash yang tersedia."
}
}
} }
}, },
"scrollToBottom": "Gulir ke bawah" "scrollToBottom": "Gulir ke bawah"

View File

@ -53,7 +53,34 @@
"thread": { "thread": {
"loadingConversation": "会話を読み込み中…", "loadingConversation": "会話を読み込み中…",
"empty": { "empty": {
"description": "質問したり、ローカル作業を続けたり、新しいスレッドを始めたりできます。" "description": "質問したり、ローカル作業を続けたり、新しいスレッドを始めたりできます。",
"greeting": "何をお手伝いしましょうか?",
"quickActions": {
"plan": {
"title": "プロジェクト計画を作成",
"prompt": "次に作るものについて、簡潔なプロジェクト計画を作成してください。"
},
"analyze": {
"title": "このデータを分析",
"prompt": "このデータを分析し、最も重要なパターンを指摘してください。"
},
"brainstorm": {
"title": "アイデアを出す",
"prompt": "この問題について、実用的なアイデアとトレードオフをいくつか出してください。"
},
"code": {
"title": "コードを書く",
"prompt": "このタスクのコードを書くのを手伝ってください。まず最小限の有用な変更から始めてください。"
},
"summarize": {
"title": "この文書を要約",
"prompt": "この文書を要約し、重要なポイントを列挙してください。"
},
"more": {
"title": "その他",
"prompt": "このワークスペースであなたが手伝える便利な方法をいくつか見せてください。"
}
}
}, },
"header": { "header": {
"toggleSidebar": "サイドバーを切り替える" "toggleSidebar": "サイドバーを切り替える"
@ -62,6 +89,7 @@
"placeholderThread": "メッセージを入力…", "placeholderThread": "メッセージを入力…",
"placeholderHero": "何を考えていますか?", "placeholderHero": "何を考えていますか?",
"placeholderOpening": "新しいチャットを開いています…", "placeholderOpening": "新しいチャットを開いています…",
"placeholderStreaming": "モデルが応答しています…",
"inputAria": "メッセージ入力欄", "inputAria": "メッセージ入力欄",
"sendHint": "Enter で送信 · Shift+Enter で改行", "sendHint": "Enter で送信 · Shift+Enter で改行",
"send": "メッセージを送信", "send": "メッセージを送信",
@ -76,6 +104,51 @@
"decode_failed": "この画像をデコードできません", "decode_failed": "この画像をデコードできません",
"too_large": "画像が大きすぎます。小さいものを選んでください", "too_large": "画像が大きすぎます。小さいものを選んでください",
"io": "このファイルを読み込めません" "io": "このファイルを読み込めません"
},
"slash": {
"ariaLabel": "スラッシュコマンド",
"label": "コマンド",
"navigateHint": "↑↓ 選択",
"selectHint": "Enter/Tab 入力",
"closeHint": "Esc 閉じる",
"commands": {
"new": {
"title": "新しいチャット",
"description": "現在のタスクを停止して、新しい会話を開始します。"
},
"stop": {
"title": "現在のタスクを停止",
"description": "このチャットで実行中の agent ターンをキャンセルします。"
},
"restart": {
"title": "nanobot を再起動",
"description": "bot プロセスをその場で再起動します。"
},
"status": {
"title": "ステータスを表示",
"description": "ランタイム、provider、channel の状態を表示します。"
},
"history": {
"title": "会話履歴を表示",
"description": "保存済みの直近 N 件の会話メッセージを表示します。"
},
"dream": {
"title": "Dream を実行",
"description": "メモリ統合を手動で開始します。"
},
"dream_log": {
"title": "Dream ログを表示",
"description": "直近の Dream 統合で変更された内容を表示します。"
},
"dream_restore": {
"title": "メモリを復元",
"description": "以前の Dream スナップショットへメモリを戻します。"
},
"help": {
"title": "ヘルプを表示",
"description": "利用可能なスラッシュコマンドを一覧表示します。"
}
}
} }
}, },
"scrollToBottom": "一番下へスクロール" "scrollToBottom": "一番下へスクロール"

View File

@ -53,7 +53,34 @@
"thread": { "thread": {
"loadingConversation": "대화 불러오는 중…", "loadingConversation": "대화 불러오는 중…",
"empty": { "empty": {
"description": "질문을 하거나, 로컬 작업을 이어가거나, 새 스레드를 시작할 수 있습니다." "description": "질문을 하거나, 로컬 작업을 이어가거나, 새 스레드를 시작할 수 있습니다.",
"greeting": "무엇을 도와드릴까요?",
"quickActions": {
"plan": {
"title": "프로젝트 계획 만들기",
"prompt": "다음에 만들 것에 대한 간결한 프로젝트 계획을 작성해 주세요."
},
"analyze": {
"title": "이 데이터 분석하기",
"prompt": "이 데이터를 분석하고 가장 중요한 패턴을 짚어 주세요."
},
"brainstorm": {
"title": "아이디어 브레인스토밍",
"prompt": "이 문제에 대한 실용적인 아이디어와 트레이드오프를 몇 가지 제안해 주세요."
},
"code": {
"title": "코드 작성하기",
"prompt": "이 작업을 위한 코드를 작성해 주세요. 가장 작은 유용한 변경부터 시작해 주세요."
},
"summarize": {
"title": "문서 요약하기",
"prompt": "이 문서를 요약하고 핵심 내용을 정리해 주세요."
},
"more": {
"title": "더 보기",
"prompt": "이 워크스페이스에서 도와줄 수 있는 유용한 방법을 몇 가지 보여 주세요."
}
}
}, },
"header": { "header": {
"toggleSidebar": "사이드바 전환" "toggleSidebar": "사이드바 전환"
@ -62,6 +89,7 @@
"placeholderThread": "메시지를 입력하세요…", "placeholderThread": "메시지를 입력하세요…",
"placeholderHero": "무슨 생각을 하고 있나요?", "placeholderHero": "무슨 생각을 하고 있나요?",
"placeholderOpening": "새 채팅을 여는 중…", "placeholderOpening": "새 채팅을 여는 중…",
"placeholderStreaming": "모델이 응답 중입니다…",
"inputAria": "메시지 입력", "inputAria": "메시지 입력",
"sendHint": "Enter로 전송 · Shift+Enter로 줄바꿈", "sendHint": "Enter로 전송 · Shift+Enter로 줄바꿈",
"send": "메시지 보내기", "send": "메시지 보내기",
@ -76,6 +104,51 @@
"decode_failed": "이 이미지를 디코딩할 수 없습니다", "decode_failed": "이 이미지를 디코딩할 수 없습니다",
"too_large": "이미지가 너무 큽니다. 더 작은 걸로 시도해 주세요", "too_large": "이미지가 너무 큽니다. 더 작은 걸로 시도해 주세요",
"io": "이 파일을 읽을 수 없습니다" "io": "이 파일을 읽을 수 없습니다"
},
"slash": {
"ariaLabel": "슬래시 명령",
"label": "명령",
"navigateHint": "↑↓ 선택",
"selectHint": "Enter/Tab 입력",
"closeHint": "Esc 닫기",
"commands": {
"new": {
"title": "새 채팅",
"description": "현재 작업을 중지하고 새 대화를 시작합니다."
},
"stop": {
"title": "현재 작업 중지",
"description": "이 채팅에서 실행 중인 agent 턴을 취소합니다."
},
"restart": {
"title": "nanobot 재시작",
"description": "bot 프로세스를 제자리에서 재시작합니다."
},
"status": {
"title": "상태 보기",
"description": "런타임, provider, channel 상태를 표시합니다."
},
"history": {
"title": "대화 기록 보기",
"description": "저장된 최근 N개의 대화 메시지를 출력합니다."
},
"dream": {
"title": "Dream 실행",
"description": "메모리 정리를 수동으로 트리거합니다."
},
"dream_log": {
"title": "Dream 로그 보기",
"description": "마지막 Dream 정리에서 변경된 내용을 표시합니다."
},
"dream_restore": {
"title": "메모리 복원",
"description": "이전 Dream 스냅샷으로 메모리를 되돌립니다."
},
"help": {
"title": "도움말 보기",
"description": "사용 가능한 슬래시 명령을 나열합니다."
}
}
} }
}, },
"scrollToBottom": "맨 아래로 스크롤" "scrollToBottom": "맨 아래로 스크롤"

View File

@ -53,7 +53,34 @@
"thread": { "thread": {
"loadingConversation": "Đang tải cuộc trò chuyện…", "loadingConversation": "Đang tải cuộc trò chuyện…",
"empty": { "empty": {
"description": "Hãy đặt câu hỏi, tiếp tục công việc cục bộ hoặc bắt đầu một luồng mới." "description": "Hãy đặt câu hỏi, tiếp tục công việc cục bộ hoặc bắt đầu một luồng mới.",
"greeting": "Tôi có thể giúp gì cho bạn?",
"quickActions": {
"plan": {
"title": "Tạo kế hoạch dự án",
"prompt": "Tạo một kế hoạch dự án ngắn gọn cho việc tôi nên xây dựng tiếp theo."
},
"analyze": {
"title": "Phân tích dữ liệu này",
"prompt": "Giúp tôi phân tích dữ liệu này và chỉ ra các mẫu quan trọng nhất."
},
"brainstorm": {
"title": "Động não ý tưởng",
"prompt": "Động não vài ý tưởng thực tế và các đánh đổi cho vấn đề này."
},
"code": {
"title": "Viết mã",
"prompt": "Giúp tôi viết mã cho nhiệm vụ này, bắt đầu từ thay đổi hữu ích nhỏ nhất."
},
"summarize": {
"title": "Tóm tắt tài liệu này",
"prompt": "Tóm tắt tài liệu này và liệt kê các ý chính."
},
"more": {
"title": "Thêm",
"prompt": "Cho tôi xem vài cách hữu ích mà bạn có thể giúp trong workspace này."
}
}
}, },
"header": { "header": {
"toggleSidebar": "Bật/tắt thanh bên" "toggleSidebar": "Bật/tắt thanh bên"
@ -62,6 +89,7 @@
"placeholderThread": "Nhập tin nhắn…", "placeholderThread": "Nhập tin nhắn…",
"placeholderHero": "Bạn đang nghĩ gì?", "placeholderHero": "Bạn đang nghĩ gì?",
"placeholderOpening": "Đang mở cuộc trò chuyện mới…", "placeholderOpening": "Đang mở cuộc trò chuyện mới…",
"placeholderStreaming": "Mô hình đang trả lời…",
"inputAria": "Ô nhập tin nhắn", "inputAria": "Ô nhập tin nhắn",
"sendHint": "Enter để gửi · Shift+Enter để xuống dòng", "sendHint": "Enter để gửi · Shift+Enter để xuống dòng",
"send": "Gửi tin nhắn", "send": "Gửi tin nhắn",
@ -76,6 +104,51 @@
"decode_failed": "Không thể giải mã ảnh này", "decode_failed": "Không thể giải mã ảnh này",
"too_large": "Ảnh quá lớn — hãy thử ảnh nhỏ hơn", "too_large": "Ảnh quá lớn — hãy thử ảnh nhỏ hơn",
"io": "Không thể đọc tệp này" "io": "Không thể đọc tệp này"
},
"slash": {
"ariaLabel": "Lệnh slash",
"label": "lệnh",
"navigateHint": "↑↓ Chọn",
"selectHint": "Enter/Tab Chèn",
"closeHint": "Esc Đóng",
"commands": {
"new": {
"title": "Cuộc trò chuyện mới",
"description": "Dừng tác vụ hiện tại và bắt đầu một cuộc trò chuyện mới."
},
"stop": {
"title": "Dừng tác vụ hiện tại",
"description": "Hủy lượt agent đang chạy trong cuộc trò chuyện này."
},
"restart": {
"title": "Khởi động lại nanobot",
"description": "Khởi động lại tiến trình bot tại chỗ."
},
"status": {
"title": "Hiển thị trạng thái",
"description": "Hiển thị trạng thái runtime, provider và channel."
},
"history": {
"title": "Hiển thị lịch sử",
"description": "In N tin nhắn hội thoại đã lưu gần nhất."
},
"dream": {
"title": "Chạy Dream",
"description": "Kích hoạt thủ công quá trình hợp nhất bộ nhớ."
},
"dream_log": {
"title": "Hiển thị nhật ký Dream",
"description": "Hiển thị những gì lần hợp nhất Dream gần nhất đã thay đổi."
},
"dream_restore": {
"title": "Khôi phục bộ nhớ",
"description": "Đưa bộ nhớ về một snapshot Dream trước đó."
},
"help": {
"title": "Hiển thị trợ giúp",
"description": "Liệt kê các lệnh slash có sẵn."
}
}
} }
}, },
"scrollToBottom": "Cuộn xuống cuối" "scrollToBottom": "Cuộn xuống cuối"

View File

@ -18,11 +18,19 @@
} }
}, },
"sidebar": { "sidebar": {
"navigation": "侧边栏导航",
"globalActions": "全局操作",
"collapse": "收起侧边栏", "collapse": "收起侧边栏",
"toggleTheme": "切换主题", "toggleTheme": "切换主题",
"home": "首页",
"newChat": "新建对话", "newChat": "新建对话",
"searchAria": "搜索会话",
"searchPlaceholder": "搜索会话",
"searchResults": "搜索结果",
"noSearchResults": "没有匹配的会话。",
"recent": "最近对话", "recent": "最近对话",
"refreshSessions": "刷新会话", "refreshSessions": "刷新会话",
"settings": "设置",
"language": { "language": {
"label": "语言", "label": "语言",
"ariaLabel": "切换语言" "ariaLabel": "切换语言"
@ -34,7 +42,12 @@
"noSessions": "还没有会话。", "noSessions": "还没有会话。",
"actions": "“{{title}}” 的会话操作", "actions": "“{{title}}” 的会话操作",
"delete": "删除", "delete": "删除",
"newChat": "新建对话" "newChat": "新建对话",
"groups": {
"today": "今天",
"yesterday": "昨天",
"earlier": "更早"
}
}, },
"deleteConfirm": { "deleteConfirm": {
"title": "删除“{{title}}”?", "title": "删除“{{title}}”?",
@ -53,19 +66,100 @@
"thread": { "thread": {
"loadingConversation": "正在加载对话…", "loadingConversation": "正在加载对话…",
"empty": { "empty": {
"description": "可以提问、继续本地工作,或者开启一个新线程。" "greeting": "我可以帮你做什么?",
"quickActions": {
"plan": {
"title": "创建项目计划",
"prompt": "帮我为接下来要做的事情写一份简洁的项目计划。"
},
"analyze": {
"title": "分析这些数据",
"prompt": "帮我分析这些数据,并指出最重要的模式。"
},
"brainstorm": {
"title": "头脑风暴想法",
"prompt": "围绕这个问题头脑风暴几个实用方案,并说明取舍。"
},
"code": {
"title": "编写代码",
"prompt": "帮我为这个任务写代码,先从最小可用改动开始。"
},
"summarize": {
"title": "总结这份文档",
"prompt": "帮我总结这份文档,并列出关键要点。"
},
"more": {
"title": "更多",
"prompt": "展示几个你在这个工作区里可以帮我的实用方式。"
}
}
}, },
"header": { "header": {
"toggleSidebar": "切换侧边栏" "toggleSidebar": "切换侧边栏",
"newChat": "从顶部新建对话",
"toggleTheme": "从顶部切换主题",
"settings": "打开设置"
}, },
"composer": { "composer": {
"placeholderThread": "输入消息…", "placeholderThread": "输入消息…",
"placeholderHero": "你在想什么?", "placeholderHero": "问任何问题...",
"placeholderOpening": "正在打开新对话…", "placeholderOpening": "正在打开新对话…",
"placeholderStreaming": "模型正在回复…",
"inputAria": "消息输入框", "inputAria": "消息输入框",
"sendHint": "Enter 发送 · Shift+Enter 换行", "sendHint": "Enter 发送 · Shift+Enter 换行",
"send": "发送消息", "send": "发送消息",
"attachImage": "添加图片", "attachImage": "添加图片",
"tools": {
"search": "搜索",
"reason": "推理",
"deepResearch": "深度研究",
"voice": "语音输入"
},
"slash": {
"ariaLabel": "斜杠命令",
"label": "命令",
"navigateHint": "↑↓ 选择",
"selectHint": "Enter/Tab 填入",
"closeHint": "Esc 关闭",
"commands": {
"new": {
"title": "新建对话",
"description": "停止当前任务,并开始一个新的对话。"
},
"stop": {
"title": "停止当前任务",
"description": "取消这个对话中正在运行的 agent 回合。"
},
"restart": {
"title": "重启 nanobot",
"description": "原地重启 bot 进程。"
},
"status": {
"title": "查看状态",
"description": "显示运行时、provider 和 channel 状态。"
},
"history": {
"title": "查看对话历史",
"description": "打印最近 N 条已持久化的对话消息。"
},
"dream": {
"title": "运行 Dream",
"description": "手动触发记忆整理。"
},
"dream_log": {
"title": "查看 Dream 日志",
"description": "查看上一次 Dream 整理改变了什么。"
},
"dream_restore": {
"title": "恢复记忆",
"description": "将记忆恢复到之前的 Dream 快照。"
},
"help": {
"title": "查看帮助",
"description": "列出可用的斜杠命令。"
}
}
},
"encoding": "处理中…", "encoding": "处理中…",
"remove": "移除附件", "remove": "移除附件",
"normalizedSizeHint": "{{orig}} → {{current}}(已自动压缩)", "normalizedSizeHint": "{{orig}} → {{current}}(已自动压缩)",
@ -85,7 +179,9 @@
"assistantTyping": "助手正在输入", "assistantTyping": "助手正在输入",
"toolSingle": "正在使用工具", "toolSingle": "正在使用工具",
"toolMany": "已使用 {{count}} 个工具", "toolMany": "已使用 {{count}} 个工具",
"imageAttachment": "图片附件" "imageAttachment": "图片附件",
"copyReply": "复制回复",
"copiedReply": "已复制回复"
}, },
"lightbox": { "lightbox": {
"title": "图片预览", "title": "图片预览",

View File

@ -53,7 +53,34 @@
"thread": { "thread": {
"loadingConversation": "正在載入對話…", "loadingConversation": "正在載入對話…",
"empty": { "empty": {
"description": "你可以提問、延續本地工作,或是開始新的執行緒。" "description": "你可以提問、延續本地工作,或是開始新的執行緒。",
"greeting": "我可以幫你做什麼?",
"quickActions": {
"plan": {
"title": "建立專案計畫",
"prompt": "幫我為接下來要做的事情寫一份簡潔的專案計畫。"
},
"analyze": {
"title": "分析這些資料",
"prompt": "幫我分析這些資料,並指出最重要的模式。"
},
"brainstorm": {
"title": "腦力激盪想法",
"prompt": "圍繞這個問題腦力激盪幾個實用方案,並說明取捨。"
},
"code": {
"title": "撰寫程式碼",
"prompt": "幫我為這個任務撰寫程式碼,先從最小可用改動開始。"
},
"summarize": {
"title": "總結這份文件",
"prompt": "幫我總結這份文件,並列出關鍵重點。"
},
"more": {
"title": "更多",
"prompt": "展示幾個你在這個工作區裡可以幫我的實用方式。"
}
}
}, },
"header": { "header": {
"toggleSidebar": "切換側邊欄" "toggleSidebar": "切換側邊欄"
@ -62,6 +89,7 @@
"placeholderThread": "輸入訊息…", "placeholderThread": "輸入訊息…",
"placeholderHero": "你在想什麼?", "placeholderHero": "你在想什麼?",
"placeholderOpening": "正在開啟新對話…", "placeholderOpening": "正在開啟新對話…",
"placeholderStreaming": "模型正在回覆…",
"inputAria": "訊息輸入框", "inputAria": "訊息輸入框",
"sendHint": "Enter 送出 · Shift+Enter 換行", "sendHint": "Enter 送出 · Shift+Enter 換行",
"send": "送出訊息", "send": "送出訊息",
@ -76,6 +104,51 @@
"decode_failed": "無法解碼這張圖片", "decode_failed": "無法解碼這張圖片",
"too_large": "圖片太大,請換一張小一點的", "too_large": "圖片太大,請換一張小一點的",
"io": "無法讀取這個檔案" "io": "無法讀取這個檔案"
},
"slash": {
"ariaLabel": "斜線命令",
"label": "命令",
"navigateHint": "↑↓ 選擇",
"selectHint": "Enter/Tab 填入",
"closeHint": "Esc 關閉",
"commands": {
"new": {
"title": "新增對話",
"description": "停止目前任務,並開始新的對話。"
},
"stop": {
"title": "停止目前任務",
"description": "取消這個對話中正在執行的 agent 回合。"
},
"restart": {
"title": "重新啟動 nanobot",
"description": "原地重新啟動 bot 進程。"
},
"status": {
"title": "查看狀態",
"description": "顯示執行環境、provider 和 channel 狀態。"
},
"history": {
"title": "查看對話歷史",
"description": "列印最近 N 則已持久化的對話訊息。"
},
"dream": {
"title": "執行 Dream",
"description": "手動觸發記憶整理。"
},
"dream_log": {
"title": "查看 Dream 日誌",
"description": "查看上一次 Dream 整理變更了什麼。"
},
"dream_restore": {
"title": "恢復記憶",
"description": "將記憶恢復到之前的 Dream 快照。"
},
"help": {
"title": "查看說明",
"description": "列出可用的斜線命令。"
}
}
} }
}, },
"scrollToBottom": "捲動到底部" "scrollToBottom": "捲動到底部"

View File

@ -1,4 +1,4 @@
import type { ChatSummary, SettingsPayload, SettingsUpdate } from "./types"; import type { ChatSummary, SettingsPayload, SettingsUpdate, SlashCommand } from "./types";
export class ApiError extends Error { export class ApiError extends Error {
status: number; status: number;
@ -42,6 +42,7 @@ export async function listSessions(
key: string; key: string;
created_at: string | null; created_at: string | null;
updated_at: string | null; updated_at: string | null;
title?: string;
preview?: string; preview?: string;
}; };
const body = await request<{ sessions: Row[] }>( const body = await request<{ sessions: Row[] }>(
@ -53,6 +54,7 @@ export async function listSessions(
...splitKey(s.key), ...splitKey(s.key),
createdAt: s.created_at, createdAt: s.created_at,
updatedAt: s.updated_at, updatedAt: s.updated_at,
title: s.title ?? "",
preview: s.preview ?? "", preview: s.preview ?? "",
})); }));
} }
@ -112,6 +114,27 @@ export async function fetchSettings(
return request<SettingsPayload>(`${base}/api/settings`, token); return request<SettingsPayload>(`${base}/api/settings`, token);
} }
export async function listSlashCommands(
token: string,
base: string = "",
): Promise<SlashCommand[]> {
type Row = {
command: string;
title: string;
description: string;
icon: string;
arg_hint?: string;
};
const body = await request<{ commands: Row[] }>(`${base}/api/commands`, token);
return body.commands.map((command) => ({
command: command.command,
title: command.title,
description: command.description,
icon: command.icon,
argHint: command.arg_hint ?? "",
}));
}
export async function updateSettings( export async function updateSettings(
token: string, token: string,
update: SettingsUpdate, update: SettingsUpdate,

View File

@ -1,15 +1,51 @@
import type { BootstrapResponse } from "./types"; import type { BootstrapResponse } from "./types";
const SECRET_STORAGE_KEY = "nanobot-webui.bootstrap-secret";
/** Read a previously saved bootstrap secret from localStorage. */
export function loadSavedSecret(): string {
if (typeof window === "undefined") return "";
try {
return window.localStorage.getItem(SECRET_STORAGE_KEY) ?? "";
} catch {
return "";
}
}
/** Persist the bootstrap secret so page reloads don't re-prompt. */
export function saveSecret(secret: string): void {
try {
window.localStorage.setItem(SECRET_STORAGE_KEY, secret);
} catch {
// ignore storage errors (private mode, etc.)
}
}
/** Clear the saved bootstrap secret (sign out). */
export function clearSavedSecret(): void {
try {
window.localStorage.removeItem(SECRET_STORAGE_KEY);
} catch {
// ignore
}
}
/** /**
* Fetch a short-lived token + the WebSocket path from the gateway's * Fetch a short-lived token + the WebSocket path from the gateway's
* ``/webui/bootstrap`` endpoint. Localhost-only on the server side. * ``/webui/bootstrap`` endpoint.
*/ */
export async function fetchBootstrap( export async function fetchBootstrap(
baseUrl: string = "", baseUrl: string = "",
secret: string = "",
): Promise<BootstrapResponse> { ): Promise<BootstrapResponse> {
const headers: Record<string, string> = {};
if (secret) {
headers["X-Nanobot-Auth"] = secret;
}
const res = await fetch(`${baseUrl}/webui/bootstrap`, { const res = await fetch(`${baseUrl}/webui/bootstrap`, {
method: "GET", method: "GET",
credentials: "same-origin", credentials: "same-origin",
headers,
}); });
if (!res.ok) { if (!res.ok) {
throw new Error(`bootstrap failed: HTTP ${res.status}`); throw new Error(`bootstrap failed: HTTP ${res.status}`);

View File

@ -185,8 +185,8 @@ export class NanobotClient {
this.knownChats.add(chatId); this.knownChats.add(chatId);
const frame: Outbound = const frame: Outbound =
media && media.length > 0 media && media.length > 0
? { type: "message", chat_id: chatId, content, media } ? { type: "message", chat_id: chatId, content, media, webui: true }
: { type: "message", chat_id: chatId, content }; : { type: "message", chat_id: chatId, content, webui: true };
this.queueSend(frame); this.queueSend(frame);
} }

View File

@ -56,6 +56,7 @@ export interface ChatSummary {
chatId: string; chatId: string;
createdAt: string | null; createdAt: string | null;
updatedAt: string | null; updatedAt: string | null;
title?: string;
preview: string; preview: string;
} }
@ -88,6 +89,14 @@ export interface SettingsUpdate {
provider?: string; provider?: string;
} }
export interface SlashCommand {
command: string;
title: string;
description: string;
icon: string;
argHint?: string;
}
export type ConnectionStatus = export type ConnectionStatus =
| "idle" | "idle"
| "connecting" | "connecting"
@ -124,6 +133,8 @@ export type InboundEvent =
chat_id: string; chat_id: string;
stream_id?: string; stream_id?: string;
} }
| { event: "turn_end"; chat_id: string }
| { event: "session_updated"; chat_id: string }
| { event: "error"; chat_id?: string; detail?: string }; | { event: "error"; chat_id?: string; detail?: string };
/** Base64-encoded image attached to an outbound ``message`` envelope. /** Base64-encoded image attached to an outbound ``message`` envelope.
@ -147,4 +158,7 @@ export type Outbound =
chat_id: string; chat_id: string;
content: string; content: string;
media?: OutboundMedia[]; media?: OutboundMedia[];
/** Marks messages sent by the embedded WebUI, without changing the
* generic websocket protocol for other clients. */
webui?: true;
}; };

View File

@ -1,6 +1,12 @@
import { beforeEach, describe, expect, it, vi } from "vitest"; import { beforeEach, describe, expect, it, vi } from "vitest";
import { deleteSession, fetchSessionMessages, updateSettings } from "@/lib/api"; import {
deleteSession,
fetchSessionMessages,
listSessions,
listSlashCommands,
updateSettings,
} from "@/lib/api";
describe("webui API helpers", () => { describe("webui API helpers", () => {
beforeEach(() => { beforeEach(() => {
@ -48,4 +54,61 @@ describe("webui API helpers", () => {
}), }),
); );
}); });
it("maps generated session titles from the sessions list", async () => {
vi.mocked(fetch).mockResolvedValueOnce({
ok: true,
json: async () => ({
sessions: [
{
key: "websocket:chat-1",
created_at: "2026-05-01T10:00:00",
updated_at: "2026-05-01T10:01:00",
title: "优化 WebUI 标题",
},
],
}),
} as Response);
await expect(listSessions("tok")).resolves.toMatchObject([
{
key: "websocket:chat-1",
title: "优化 WebUI 标题",
preview: "",
},
]);
});
it("maps slash command metadata from the commands endpoint", async () => {
vi.mocked(fetch).mockResolvedValueOnce({
ok: true,
json: async () => ({
commands: [
{
command: "/history",
title: "Show conversation history",
description: "Print the last N messages.",
icon: "history",
arg_hint: "[n]",
},
],
}),
} as Response);
await expect(listSlashCommands("tok")).resolves.toEqual([
{
command: "/history",
title: "Show conversation history",
description: "Print the last N messages.",
icon: "history",
argHint: "[n]",
},
]);
expect(fetch).toHaveBeenCalledWith(
"/api/commands",
expect.objectContaining({
headers: { Authorization: "Bearer tok" },
}),
);
});
}); });

View File

@ -1,4 +1,4 @@
import { fireEvent, render, screen, waitFor } from "@testing-library/react"; import { fireEvent, render, screen, waitFor, within } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest"; import { beforeEach, describe, expect, it, vi } from "vitest";
import type { ChatSummary } from "@/lib/types"; import type { ChatSummary } from "@/lib/types";
@ -7,6 +7,7 @@ const connectSpy = vi.fn();
const refreshSpy = vi.fn(); const refreshSpy = vi.fn();
const createChatSpy = vi.fn().mockResolvedValue("chat-1"); const createChatSpy = vi.fn().mockResolvedValue("chat-1");
const deleteChatSpy = vi.fn(); const deleteChatSpy = vi.fn();
const toggleThemeSpy = vi.fn();
let mockSessions: ChatSummary[] = []; let mockSessions: ChatSummary[] = [];
vi.mock("@/hooks/useSessions", async (importOriginal) => { vi.mock("@/hooks/useSessions", async (importOriginal) => {
@ -34,7 +35,7 @@ vi.mock("@/hooks/useSessions", async (importOriginal) => {
vi.mock("@/hooks/useTheme", () => ({ vi.mock("@/hooks/useTheme", () => ({
useTheme: () => ({ useTheme: () => ({
theme: "light" as const, theme: "light" as const,
toggle: vi.fn(), toggle: toggleThemeSpy,
}), }),
})); }));
@ -45,6 +46,9 @@ vi.mock("@/lib/bootstrap", () => ({
expires_in: 300, expires_in: 300,
}), }),
deriveWsUrl: vi.fn(() => "ws://test"), deriveWsUrl: vi.fn(() => "ws://test"),
loadSavedSecret: vi.fn(() => ""),
saveSecret: vi.fn(),
clearSavedSecret: vi.fn(),
})); }));
vi.mock("@/lib/nanobot-client", () => { vi.mock("@/lib/nanobot-client", () => {
@ -74,6 +78,7 @@ describe("App layout", () => {
refreshSpy.mockReset(); refreshSpy.mockReset();
createChatSpy.mockClear(); createChatSpy.mockClear();
deleteChatSpy.mockReset(); deleteChatSpy.mockReset();
toggleThemeSpy.mockReset();
vi.stubGlobal( vi.stubGlobal(
"fetch", "fetch",
vi.fn().mockResolvedValue({ vi.fn().mockResolvedValue({
@ -121,8 +126,11 @@ describe("App layout", () => {
render(<App />); render(<App />);
await waitFor(() => expect(connectSpy).toHaveBeenCalled()); await waitFor(() => expect(connectSpy).toHaveBeenCalled());
const sidebar = screen.getByRole("navigation", { name: "Sidebar navigation" });
await waitFor(() => await waitFor(() =>
expect(screen.getByRole("button", { name: /^First chat$/ })).toBeInTheDocument(), expect(
within(sidebar).getByRole("button", { name: /^First chat$/ }),
).toBeInTheDocument(),
); );
fireEvent.pointerDown(screen.getByLabelText("Chat actions for First chat"), { fireEvent.pointerDown(screen.getByLabelText("Chat actions for First chat"), {
@ -140,14 +148,24 @@ describe("App layout", () => {
); );
await waitFor(() => await waitFor(() =>
expect( expect(
screen.getByRole("button", { name: /^Second chat$/ }), within(sidebar).getByRole("button", { name: /^Second chat$/ }),
).toBeInTheDocument(), ).toBeInTheDocument(),
); );
expect(screen.queryByText('Delete “First chat”?')).not.toBeInTheDocument(); expect(screen.queryByText('Delete “First chat”?')).not.toBeInTheDocument();
expect(document.body.style.pointerEvents).not.toBe("none"); expect(document.body.style.pointerEvents).not.toBe("none");
}, 15_000); }, 15_000);
it("opens the Cursor-style settings view from the sidebar", async () => { it("opens the Cursor-style settings view from the header", async () => {
mockSessions = [
{
key: "websocket:chat-a",
channel: "websocket",
chatId: "chat-a",
createdAt: "2026-04-16T10:00:00Z",
updatedAt: "2026-04-16T10:00:00Z",
preview: "Existing chat",
},
];
vi.stubGlobal( vi.stubGlobal(
"fetch", "fetch",
vi.fn(async (input: RequestInfo | URL) => { vi.fn(async (input: RequestInfo | URL) => {
@ -180,10 +198,95 @@ describe("App layout", () => {
render(<App />); render(<App />);
await waitFor(() => expect(connectSpy).toHaveBeenCalled()); await waitFor(() => expect(connectSpy).toHaveBeenCalled());
fireEvent.click(screen.getByRole("button", { name: "Settings" })); fireEvent.click(screen.getByRole("button", { name: "Open settings" }));
expect(await screen.findByRole("heading", { name: "General" })).toBeInTheDocument(); expect(await screen.findByRole("heading", { name: "General" })).toBeInTheDocument();
expect(screen.getByText("AI")).toBeInTheDocument(); expect(screen.getByText("AI")).toBeInTheDocument();
expect(screen.getByDisplayValue("openai/gpt-4o")).toBeInTheDocument(); expect(screen.getByDisplayValue("openai/gpt-4o")).toBeInTheDocument();
}); });
it("filters sidebar sessions through the lightweight search row", async () => {
mockSessions = [
{
key: "websocket:chat-alpha",
channel: "websocket",
chatId: "chat-alpha",
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
preview: "Project planning notes",
},
{
key: "websocket:chat-beta",
channel: "websocket",
chatId: "chat-beta",
createdAt: "2026-04-15T10:00:00Z",
updatedAt: "2026-04-15T10:00:00Z",
preview: "Travel ideas",
},
];
render(<App />);
await waitFor(() => expect(connectSpy).toHaveBeenCalled());
const sidebar = screen.getByRole("navigation", { name: "Sidebar navigation" });
expect(within(sidebar).getByText("Project planning notes")).toBeInTheDocument();
expect(within(sidebar).getByText("Travel ideas")).toBeInTheDocument();
fireEvent.change(screen.getByRole("textbox", { name: "Search chats" }), {
target: { value: "travel" },
});
expect(within(sidebar).queryByText("Project planning notes")).not.toBeInTheDocument();
expect(within(sidebar).getByText("Travel ideas")).toBeInTheDocument();
});
it("opens a blank start page without creating an empty chat", async () => {
mockSessions = [
{
key: "websocket:chat-a",
channel: "websocket",
chatId: "chat-a",
createdAt: "2026-04-16T10:00:00Z",
updatedAt: "2026-04-16T10:00:00Z",
preview: "Existing chat",
},
];
const matchMedia = vi.fn().mockImplementation((query: string) => ({
matches: query.includes("1024px"),
media: query,
onchange: null,
addListener: vi.fn(),
removeListener: vi.fn(),
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
dispatchEvent: vi.fn(),
}));
vi.stubGlobal("matchMedia", matchMedia);
const { container } = render(<App />);
await waitFor(() => expect(connectSpy).toHaveBeenCalled());
fireEvent.click(screen.getByRole("button", { name: "Toggle theme from header" }));
expect(toggleThemeSpy).toHaveBeenCalledTimes(1);
fireEvent.click(screen.getByRole("button", { name: "Collapse sidebar" }));
const desktopAside = container.querySelector("aside.lg\\:block") as HTMLElement;
await waitFor(() => expect(desktopAside.style.width).toBe("0px"));
expect(screen.queryByRole("button", { name: "Start a new chat" })).not.toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: "Toggle sidebar" }));
await waitFor(() => expect(desktopAside.style.width).toBe("272px"));
const sidebar = screen.getByRole("navigation", { name: "Sidebar navigation" });
fireEvent.click(within(sidebar).getByRole("button", { name: "New chat" }));
expect(createChatSpy).not.toHaveBeenCalled();
expect(screen.getByText("What can I do for you?")).toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Start a new chat" })).not.toBeInTheDocument();
expect(screen.getByRole("button", { name: "Toggle theme from header" })).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Open settings" })).toBeInTheDocument();
expect(within(sidebar).getByText("Existing chat")).toBeInTheDocument();
});
}); });

View File

@ -4,6 +4,9 @@ import { describe, expect, it, vi } from "vitest";
import { LanguageSwitcher } from "@/components/LanguageSwitcher"; import { LanguageSwitcher } from "@/components/LanguageSwitcher";
import { ThreadComposer } from "@/components/thread/ThreadComposer"; import { ThreadComposer } from "@/components/thread/ThreadComposer";
import { resources } from "@/i18n";
const QUICK_ACTION_KEYS = ["plan", "analyze", "brainstorm", "code", "summarize", "more"];
describe("webui i18n", () => { describe("webui i18n", () => {
it("switches UI copy and document locale through the language switcher", async () => { it("switches UI copy and document locale through the language switcher", async () => {
@ -41,4 +44,16 @@ describe("webui i18n", () => {
expect(screen.getByLabelText("メッセージ入力欄")).toBeInTheDocument(); expect(screen.getByLabelText("メッセージ入力欄")).toBeInTheDocument();
}); });
it("keeps welcome quick actions localized for every registered locale", () => {
for (const resource of Object.values(resources)) {
const empty = resource.common.thread.empty;
expect(empty.greeting).toBeTruthy();
for (const key of QUICK_ACTION_KEYS) {
const action = empty.quickActions[key as keyof typeof empty.quickActions];
expect(action.title).toBeTruthy();
expect(action.prompt).toBeTruthy();
}
}
});
}); });

View File

@ -1,5 +1,5 @@
import { fireEvent, render, screen } from "@testing-library/react"; import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { describe, expect, it } from "vitest"; import { describe, expect, it, vi } from "vitest";
import { MessageBubble } from "@/components/MessageBubble"; import { MessageBubble } from "@/components/MessageBubble";
import type { UIMessage } from "@/lib/types"; import type { UIMessage } from "@/lib/types";
@ -19,6 +19,44 @@ describe("MessageBubble", () => {
expect(row).toHaveClass("ml-auto", "flex"); expect(row).toHaveClass("ml-auto", "flex");
expect(pill).toHaveClass("ml-auto", "w-fit", "rounded-[18px]"); expect(pill).toHaveClass("ml-auto", "w-fit", "rounded-[18px]");
expect(screen.queryByRole("button", { name: "Copy reply" })).not.toBeInTheDocument();
});
it("copies completed assistant replies from the action row", async () => {
const writeText = vi.fn().mockResolvedValue(undefined);
Object.defineProperty(navigator, "clipboard", {
configurable: true,
value: { writeText },
});
const message: UIMessage = {
id: "a-copy",
role: "assistant",
content: "I can help with the next step.",
createdAt: Date.now(),
};
render(<MessageBubble message={message} />);
fireEvent.click(screen.getByRole("button", { name: "Copy reply" }));
expect(writeText).toHaveBeenCalledWith("I can help with the next step.");
await waitFor(() =>
expect(screen.getByRole("button", { name: "Copied reply" })).toBeInTheDocument(),
);
});
it("does not show copy actions for streaming placeholders", () => {
const message: UIMessage = {
id: "a-streaming",
role: "assistant",
content: "",
isStreaming: true,
createdAt: Date.now(),
};
render(<MessageBubble message={message} />);
expect(screen.queryByRole("button", { name: "Copy reply" })).not.toBeInTheDocument();
}); });
it("renders trace messages as collapsible tool groups", () => { it("renders trace messages as collapsible tool groups", () => {

View File

@ -116,7 +116,7 @@ describe("NanobotClient", () => {
// Attach is sent first because sendMessage adds to knownChats, which // Attach is sent first because sendMessage adds to knownChats, which
// handleOpen re-attaches; then the queued message follows. // handleOpen re-attaches; then the queued message follows.
expect(lastSocket().sent).toContain( expect(lastSocket().sent).toContain(
JSON.stringify({ type: "message", chat_id: "chat-x", content: "hello" }), JSON.stringify({ type: "message", chat_id: "chat-x", content: "hello", webui: true }),
); );
}); });
@ -196,6 +196,7 @@ describe("NanobotClient", () => {
chat_id: "chat-x", chat_id: "chat-x",
content: "look", content: "look",
media: [{ data_url: "data:image/png;base64,AAAA", name: "shot.png" }], media: [{ data_url: "data:image/png;base64,AAAA", name: "shot.png" }],
webui: true,
}); });
}); });
@ -214,6 +215,7 @@ describe("NanobotClient", () => {
type: "message", type: "message",
chat_id: "chat-x", chat_id: "chat-x",
content: "hello", content: "hello",
webui: true,
}); });
}); });

View File

@ -1,7 +1,24 @@
import { render, screen } from "@testing-library/react"; import { fireEvent, render, screen } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest"; import { describe, expect, it, vi } from "vitest";
import { ThreadComposer } from "@/components/thread/ThreadComposer"; import { ThreadComposer } from "@/components/thread/ThreadComposer";
import type { SlashCommand } from "@/lib/types";
const COMMANDS: SlashCommand[] = [
{
command: "/stop",
title: "Stop current task",
description: "Cancel the active agent turn.",
icon: "square",
},
{
command: "/history",
title: "Show conversation history",
description: "Print the last N persisted messages.",
icon: "history",
argHint: "[n]",
},
];
describe("ThreadComposer", () => { describe("ThreadComposer", () => {
it("renders a readonly hero model composer when provided", () => { it("renders a readonly hero model composer when provided", () => {
@ -9,15 +26,69 @@ describe("ThreadComposer", () => {
<ThreadComposer <ThreadComposer
onSend={vi.fn()} onSend={vi.fn()}
modelLabel="claude-opus-4-5" modelLabel="claude-opus-4-5"
placeholder="What's on your mind?" placeholder="Ask anything..."
variant="hero" variant="hero"
/>, />,
); );
expect(screen.getByText("claude-opus-4-5")).toBeInTheDocument(); expect(screen.getByText("claude-opus-4-5")).toBeInTheDocument();
const input = screen.getByPlaceholderText("What's on your mind?"); expect(screen.queryByRole("button", { name: "Search" })).not.toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Reason" })).not.toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Deep research" })).not.toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Voice input" })).not.toBeInTheDocument();
const input = screen.getByPlaceholderText("Ask anything...");
expect(input).toBeInTheDocument(); expect(input).toBeInTheDocument();
expect(input.className).toContain("min-h-[96px]"); expect(input.className).toContain("min-h-[78px]");
expect(input.parentElement?.className).toContain("max-w-[40rem]"); expect(input.parentElement?.className).toContain("max-w-[58rem]");
});
it("keeps the thread composer compact while matching the hero style", () => {
render(
<ThreadComposer
onSend={vi.fn()}
modelLabel="gpt-4o"
placeholder="Type your message..."
/>,
);
expect(screen.getByText("gpt-4o")).toBeInTheDocument();
const input = screen.getByPlaceholderText("Type your message...");
expect(input.className).toContain("min-h-[50px]");
expect(input.parentElement?.className).toContain("max-w-[49.5rem]");
expect(input.parentElement?.className).toContain("rounded-[22px]");
expect(input.parentElement?.className).toContain("shadow-[0_12px_30px_rgba(15,23,42,0.07)]");
expect(screen.getByRole("button", { name: "Attach image" }).className).toContain("bg-card");
expect(screen.getByRole("button", { name: "Send message" }).className).toContain("bg-foreground");
});
it("opens a slash command palette and inserts the selected command", () => {
const onSend = vi.fn();
render(
<ThreadComposer
onSend={onSend}
placeholder="Type your message..."
slashCommands={COMMANDS}
/>,
);
const input = screen.getByLabelText("Message input");
fireEvent.change(input, { target: { value: "/" } });
expect(screen.getByRole("listbox", { name: "Slash commands" })).toBeInTheDocument();
expect(screen.getByRole("option", { name: /\/stop/i })).toHaveAttribute(
"aria-selected",
"true",
);
fireEvent.keyDown(input, { key: "ArrowDown" });
expect(screen.getByRole("option", { name: /\/history/i })).toHaveAttribute(
"aria-selected",
"true",
);
fireEvent.keyDown(input, { key: "Enter" });
expect(input).toHaveValue("/history ");
expect(onSend).not.toHaveBeenCalled();
expect(screen.queryByRole("listbox", { name: "Slash commands" })).not.toBeInTheDocument();
}); });
}); });

View File

@ -86,6 +86,26 @@ describe("ThreadShell", () => {
); );
}); });
it("does not navigate away when clicking the chat title", async () => {
const client = makeClient();
const onGoHome = vi.fn();
render(wrap(
client,
<ThreadShell
session={session("chat-title")}
title="Important conversation"
onToggleSidebar={() => {}}
onGoHome={onGoHome}
onNewChat={() => {}}
/>,
));
await waitFor(() => expect(screen.getByText("Important conversation")).toBeInTheDocument());
fireEvent.click(screen.getByText("Important conversation"));
expect(onGoHome).not.toHaveBeenCalled();
});
it("restores in-memory messages when switching away and back to a session", async () => { it("restores in-memory messages when switching away and back to a session", async () => {
const client = makeClient(); const client = makeClient();
const onNewChat = vi.fn().mockResolvedValue("chat-a"); const onNewChat = vi.fn().mockResolvedValue("chat-a");
@ -199,7 +219,67 @@ describe("ThreadShell", () => {
await waitFor(() => { await waitFor(() => {
expect(screen.queryByText("delete me cleanly")).not.toBeInTheDocument(); expect(screen.queryByText("delete me cleanly")).not.toBeInTheDocument();
}); });
expect(screen.getByPlaceholderText("What's on your mind?")).toBeInTheDocument(); expect(screen.getByPlaceholderText("Ask anything...")).toBeInTheDocument();
});
it("creates a chat only when the blank landing sends a first message", async () => {
const client = makeClient();
const onNewChat = vi.fn();
const onCreateChat = vi.fn().mockResolvedValue("chat-new");
render(
wrap(
client,
<ThreadShell
session={null}
title="nanobot"
onToggleSidebar={() => {}}
onGoHome={() => {}}
onNewChat={onNewChat}
onCreateChat={onCreateChat}
/>,
),
);
fireEvent.change(screen.getByLabelText("Message input"), {
target: { value: "start for real" },
});
fireEvent.click(screen.getByRole("button", { name: "Send message" }));
await waitFor(() => expect(onCreateChat).toHaveBeenCalledTimes(1));
expect(onNewChat).not.toHaveBeenCalled();
});
it("sends quick action prompts from the empty thread landing", async () => {
const client = makeClient();
const onNewChat = vi.fn().mockResolvedValue("chat-a");
render(
wrap(
client,
<ThreadShell
session={session("chat-a")}
title="Chat chat-a"
onToggleSidebar={() => {}}
onGoHome={() => {}}
onNewChat={onNewChat}
/>,
),
);
await waitFor(() => {
expect(screen.getByRole("button", { name: "Write code" })).toBeInTheDocument();
});
fireEvent.click(screen.getByRole("button", { name: "Write code" }));
await waitFor(() =>
expect(client.sendMessage).toHaveBeenCalledWith(
"chat-a",
"Help me write the code for this task, starting with the smallest useful change.",
undefined,
),
);
}); });
it("does not leak the previous thread when opening a brand-new chat", async () => { it("does not leak the previous thread when opening a brand-new chat", async () => {
@ -260,13 +340,232 @@ describe("ThreadShell", () => {
expect(screen.queryByText("old answer")).not.toBeInTheDocument(); expect(screen.queryByText("old answer")).not.toBeInTheDocument();
await waitFor(() => await waitFor(() =>
expect(screen.getByPlaceholderText("What's on your mind?")).toBeInTheDocument(), expect(screen.getByPlaceholderText("Ask anything...")).toBeInTheDocument(),
); );
const input = screen.getByPlaceholderText("What's on your mind?"); const input = screen.getByPlaceholderText("Ask anything...");
expect(input.className).toContain("min-h-[96px]"); expect(input.className).toContain("min-h-[78px]");
expect(screen.queryByText("old answer")).not.toBeInTheDocument(); expect(screen.queryByText("old answer")).not.toBeInTheDocument();
}); });
it("does not cache optimistic messages under the next chat during a session switch", async () => {
const client = makeClient();
const onNewChat = vi.fn().mockResolvedValue("chat-b");
const { rerender } = render(
wrap(
client,
<ThreadShell
session={session("chat-a")}
title="Chat chat-a"
onToggleSidebar={() => {}}
onGoHome={() => {}}
onNewChat={onNewChat}
/>,
),
);
fireEvent.change(screen.getByLabelText("Message input"), {
target: { value: "only in chat a" },
});
fireEvent.click(screen.getByRole("button", { name: "Send message" }));
await waitFor(() =>
expect(client.sendMessage).toHaveBeenCalledWith(
"chat-a",
"only in chat a",
undefined,
),
);
expect(screen.getByText("only in chat a")).toBeInTheDocument();
await act(async () => {
rerender(
wrap(
client,
<ThreadShell
session={session("chat-b")}
title="Chat chat-b"
onToggleSidebar={() => {}}
onGoHome={() => {}}
onNewChat={onNewChat}
/>,
),
);
});
await waitFor(() => {
expect(screen.queryByText("only in chat a")).not.toBeInTheDocument();
});
await act(async () => {
rerender(
wrap(
client,
<ThreadShell
session={session("chat-a")}
title="Chat chat-a"
onToggleSidebar={() => {}}
onGoHome={() => {}}
onNewChat={onNewChat}
/>,
),
);
});
expect(screen.getByText("only in chat a")).toBeInTheDocument();
await act(async () => {
rerender(
wrap(
client,
<ThreadShell
session={session("chat-b")}
title="Chat chat-b"
onToggleSidebar={() => {}}
onGoHome={() => {}}
onNewChat={onNewChat}
/>,
),
);
});
await waitFor(() => {
expect(screen.queryByText("only in chat a")).not.toBeInTheDocument();
});
});
it("keeps live assistant replies after visiting the blank new-chat page", async () => {
const client = makeClient();
vi.stubGlobal(
"fetch",
vi.fn(async (input: RequestInfo | URL) => {
const url = String(input);
if (url.includes("websocket%3Achat-a/messages")) {
return httpJson({
key: "websocket:chat-a",
created_at: null,
updated_at: null,
// Simulate a stale history response that has not persisted the
// just-received assistant reply yet.
messages: [{ role: "user", content: "hello" }],
});
}
return {
ok: false,
status: 404,
json: async () => ({}),
};
}),
);
const { rerender } = render(
wrap(
client,
<ThreadShell
session={session("chat-a")}
title="Chat chat-a"
onToggleSidebar={() => {}}
onNewChat={() => {}}
/>,
),
);
await waitFor(() => expect(screen.getByText("hello")).toBeInTheDocument());
await act(async () => {
client._emitChat("chat-a", {
event: "message",
chat_id: "chat-a",
text: "live assistant reply",
});
});
expect(screen.getByText("live assistant reply")).toBeInTheDocument();
await act(async () => {
rerender(
wrap(
client,
<ThreadShell
session={null}
title="nanobot"
onToggleSidebar={() => {}}
onNewChat={() => {}}
/>,
),
);
});
expect(screen.queryByText("live assistant reply")).not.toBeInTheDocument();
expect(screen.getByText("What can I do for you?")).toBeInTheDocument();
await act(async () => {
rerender(
wrap(
client,
<ThreadShell
session={session("chat-a")}
title="Chat chat-a"
onToggleSidebar={() => {}}
onNewChat={() => {}}
/>,
),
);
});
await waitFor(() => expect(screen.getByText("live assistant reply")).toBeInTheDocument());
});
it("does not open slash commands on the blank welcome page", async () => {
const client = makeClient();
vi.stubGlobal(
"fetch",
vi.fn(async (input: RequestInfo | URL) => {
const url = String(input);
if (url.endsWith("/api/commands")) {
return httpJson({
commands: [
{
command: "/stop",
title: "Stop current task",
description: "Cancel the active agent turn.",
icon: "square",
},
],
});
}
return {
ok: false,
status: 404,
json: async () => ({}),
};
}),
);
render(
wrap(
client,
<ThreadShell
session={null}
title="nanobot"
onToggleSidebar={() => {}}
onNewChat={() => {}}
/>,
),
);
await waitFor(() => expect(fetch).toHaveBeenCalledWith(
"/api/commands",
expect.objectContaining({
headers: { Authorization: "Bearer tok" },
}),
));
fireEvent.change(screen.getByLabelText("Message input"), {
target: { value: "/" },
});
expect(screen.queryByRole("listbox", { name: "Slash commands" })).not.toBeInTheDocument();
});
it("surfaces a dismissible banner when the stream reports message_too_big", async () => { it("surfaces a dismissible banner when the stream reports message_too_big", async () => {
const client = makeClient(); const client = makeClient();
const onNewChat = vi.fn().mockResolvedValue("chat-a"); const onNewChat = vi.fn().mockResolvedValue("chat-a");
@ -287,6 +586,7 @@ describe("ThreadShell", () => {
// No banner yet: only appears once the client emits a matching error. // No banner yet: only appears once the client emits a matching error.
expect(screen.queryByRole("alert")).not.toBeInTheDocument(); expect(screen.queryByRole("alert")).not.toBeInTheDocument();
await act(async () => {});
await act(async () => { await act(async () => {
client._emitError({ kind: "message_too_big" }); client._emitError({ kind: "message_too_big" });
}); });
@ -318,6 +618,7 @@ describe("ThreadShell", () => {
), ),
); );
await act(async () => {});
await act(async () => { await act(async () => {
client._emitError({ kind: "message_too_big" }); client._emitError({ kind: "message_too_big" });
}); });

View File

@ -6,6 +6,8 @@ import { useNanobotStream } from "@/hooks/useNanobotStream";
import type { InboundEvent } from "@/lib/types"; import type { InboundEvent } from "@/lib/types";
import { ClientProvider } from "@/providers/ClientProvider"; import { ClientProvider } from "@/providers/ClientProvider";
const EMPTY_MESSAGES: import("@/lib/types").UIMessage[] = [];
function fakeClient() { function fakeClient() {
const handlers = new Map<string, Set<(ev: InboundEvent) => void>>(); const handlers = new Map<string, Set<(ev: InboundEvent) => void>>();
return { return {
@ -51,9 +53,27 @@ function wrap(client: ReturnType<typeof fakeClient>["client"]) {
} }
describe("useNanobotStream", () => { describe("useNanobotStream", () => {
it("starts in streaming mode when history shows pending tool calls", () => {
const fake = fakeClient();
const initialMessages = [{
id: "m1",
role: "assistant" as const,
content: "Using tools",
createdAt: Date.now(),
}];
const { result } = renderHook(
() => useNanobotStream("chat-p", initialMessages, true),
{
wrapper: wrap(fake.client),
},
);
expect(result.current.isStreaming).toBe(true);
});
it("collapses consecutive tool_hint frames into one trace row", () => { it("collapses consecutive tool_hint frames into one trace row", () => {
const fake = fakeClient(); const fake = fakeClient();
const { result } = renderHook(() => useNanobotStream("chat-t", []), { const { result } = renderHook(() => useNanobotStream("chat-t", EMPTY_MESSAGES), {
wrapper: wrap(fake.client), wrapper: wrap(fake.client),
}); });
@ -95,7 +115,7 @@ describe("useNanobotStream", () => {
it("attaches assistant media_urls to complete messages", () => { it("attaches assistant media_urls to complete messages", () => {
const fake = fakeClient(); const fake = fakeClient();
const { result } = renderHook(() => useNanobotStream("chat-m", []), { const { result } = renderHook(() => useNanobotStream("chat-m", EMPTY_MESSAGES), {
wrapper: wrap(fake.client), wrapper: wrap(fake.client),
}); });
@ -116,7 +136,7 @@ describe("useNanobotStream", () => {
it("keeps assistant buttons on complete messages", () => { it("keeps assistant buttons on complete messages", () => {
const fake = fakeClient(); const fake = fakeClient();
const { result } = renderHook(() => useNanobotStream("chat-q", []), { const { result } = renderHook(() => useNanobotStream("chat-q", EMPTY_MESSAGES), {
wrapper: wrap(fake.client), wrapper: wrap(fake.client),
}); });
@ -136,4 +156,79 @@ describe("useNanobotStream", () => {
["Short answer", "Detailed answer"], ["Short answer", "Detailed answer"],
]); ]);
}); });
it("keeps streaming alive across stream_end and completes on turn_end", () => {
const fake = fakeClient();
const onTurnEnd = vi.fn();
const { result } = renderHook(() => useNanobotStream("chat-s", EMPTY_MESSAGES, false, onTurnEnd), {
wrapper: wrap(fake.client),
});
act(() => {
fake.emit("chat-s", {
event: "delta",
chat_id: "chat-s",
text: "Hello",
});
});
expect(result.current.isStreaming).toBe(true);
expect(result.current.messages[0]).toMatchObject({
role: "assistant",
content: "Hello",
isStreaming: true,
});
act(() => {
fake.emit("chat-s", {
event: "stream_end",
chat_id: "chat-s",
});
});
expect(result.current.isStreaming).toBe(true);
expect(result.current.messages[0].isStreaming).toBe(true);
act(() => {
fake.emit("chat-s", {
event: "message",
chat_id: "chat-s",
text: "Hello world",
});
});
expect(result.current.isStreaming).toBe(true);
expect(result.current.messages.at(-1)).toMatchObject({
role: "assistant",
content: "Hello world",
});
act(() => {
fake.emit("chat-s", {
event: "turn_end",
chat_id: "chat-s",
});
});
expect(result.current.isStreaming).toBe(false);
expect(result.current.messages.every((message) => !message.isStreaming)).toBe(true);
expect(onTurnEnd).toHaveBeenCalledTimes(1);
});
it("refreshes session metadata when the server reports a session update", () => {
const fake = fakeClient();
const onTurnEnd = vi.fn();
renderHook(() => useNanobotStream("chat-title", EMPTY_MESSAGES, false, onTurnEnd), {
wrapper: wrap(fake.client),
});
act(() => {
fake.emit("chat-title", {
event: "session_updated",
chat_id: "chat-title",
});
});
expect(onTurnEnd).toHaveBeenCalledTimes(1);
});
}); });

View File

@ -170,6 +170,83 @@ describe("useSessions", () => {
]); ]);
}); });
it("flags history with trailing assistant tool calls as still pending", async () => {
vi.mocked(api.fetchSessionMessages).mockResolvedValue({
key: "websocket:chat-pending",
created_at: "2026-04-20T10:00:00Z",
updated_at: "2026-04-20T10:05:00Z",
messages: [
{
role: "assistant",
content: "Using 2 tools",
timestamp: "2026-04-20T10:00:01Z",
tool_calls: [{ id: "call-1" }],
},
],
});
const { result } = renderHook(() => useSessionHistory("websocket:chat-pending"), {
wrapper: wrap(fakeClient()),
});
await waitFor(() => expect(result.current.loading).toBe(false));
expect(result.current.hasPendingToolCalls).toBe(true);
});
it("keeps pending when tool result rows trail assistant tool calls", async () => {
vi.mocked(api.fetchSessionMessages).mockResolvedValue({
key: "websocket:chat-pending-tool-result",
created_at: "2026-04-20T10:00:00Z",
updated_at: "2026-04-20T10:05:00Z",
messages: [
{
role: "assistant",
content: "Using 1 tool",
timestamp: "2026-04-20T10:00:01Z",
tool_calls: [{ id: "call-1" }],
},
{
role: "tool",
content: "tool output",
timestamp: "2026-04-20T10:00:02Z",
tool_call_id: "call-1",
},
],
});
const { result } = renderHook(() => useSessionHistory("websocket:chat-pending-tool-result"), {
wrapper: wrap(fakeClient()),
});
await waitFor(() => expect(result.current.loading).toBe(false));
expect(result.current.hasPendingToolCalls).toBe(true);
});
it("does not flag history as pending once the assistant turn has no tool calls", async () => {
vi.mocked(api.fetchSessionMessages).mockResolvedValue({
key: "websocket:chat-done",
created_at: "2026-04-20T10:00:00Z",
updated_at: "2026-04-20T10:05:00Z",
messages: [
{
role: "assistant",
content: "All done",
timestamp: "2026-04-20T10:00:01Z",
},
],
});
const { result } = renderHook(() => useSessionHistory("websocket:chat-done"), {
wrapper: wrap(fakeClient()),
});
await waitFor(() => expect(result.current.loading).toBe(false));
expect(result.current.hasPendingToolCalls).toBe(false);
});
it("keeps the session in the list when delete fails", async () => { it("keeps the session in the list when delete fails", async () => {
vi.mocked(api.listSessions).mockResolvedValue([ vi.mocked(api.listSessions).mockResolvedValue([
{ {