diff --git a/docs/plans/core-decomposition-completed.md b/docs/plans/core-decomposition-completed.md index f0be785f37..de3c7c7d3f 100644 --- a/docs/plans/core-decomposition-completed.md +++ b/docs/plans/core-decomposition-completed.md @@ -19,7 +19,7 @@ - `services-core` 已承接 workspace-runtime legacy session-store merge、metadata 冲突选择、index rebuild 和 legacy path copy/move fallback;core workspace-runtime 只保留路径计算、runtime layout ensure 和错误兼容映射。 - `runtime-services` 已承接 typed runtime service assembly、capability availability、provider registry、capability validation、无副作用 capability marker ports 和 backend event delivery;core backend event system 只保留兼容 re-export。 - `bitfun-events` 已承接 backend event DTO、agentic event DTO 和 platform-neutral `EventEmitter` trait。 -- `services-integrations` 已承接 remote-connect primitives、wire command routing / response assembly、workspace search concrete owner、remote SSH/SFTP/PTY owner、DeepResearch report IO / display-map sidecar、MiniApp host dispatch / storage / worker / import IO。 +- `services-integrations` 已承接 remote-connect primitives、wire command routing / response assembly、IM bot provider-neutral config / persistence / file auto-push / locale / menu / state / command parsing、workspace search concrete owner、remote SSH/SFTP/PTY owner、DeepResearch report IO / display-map sidecar、MiniApp host dispatch / storage / worker / import IO。 - `tool-contracts` 已承接 provider-neutral tool DTO、manifest/catalog/admission/result presentation、Computer Use DTO/input parser/screenshot payload、confirmation facts、truncation recovery presentation、runtime restriction policy 和 provider-entry materialization;core 只保留 Computer Use 旧 public path re-export / compatibility shim 与产品执行入口。 - `tool-execution` 已承接 local / remote IO helper、Bash shell helper、batching plan、retry policy、state counting、tool state event payload shaping / result redaction、cancellation-state/token-store policy、background exec output capture、部分 result rendering,以及 File tool 的 provider-neutral 结果展示、写入 mode/status/line-count 规则、Edit guardrail 分类和 Delete success 文本;core 只保留 ToolResult 包装、权限、checkpoint、read-state adapter、remote shell/FS 调用和旧工具入口。 - `agent-runtime` 已承接 scheduler/background delivery 纯决策、dialog lifecycle port contracts、runtime event queue/router、session management/cancellation port contracts、thread-goal facts、prompt markup / prompt / prompt-cache facts 与持久化写入决策、remote file delivery prompt facts、turn skill/agent snapshot DTO/diff/render/store、file-read session state / prior-read guardrail / freshness 决策、session evidence ledger 与 compression-contract projection、dialog-turn cancellation token store、tool confirmation / user-question wait channel state、custom agent / mode / subagent schema、默认值、discovery/loading、markdown IO、validation、review 工具过滤、skill catalog/root specs、mode policy、selection/shadow/mode-info 规则、assistant payload rendering、post-call hook routing、DeepReview provider-neutral policy/queue/retry/diagnostics shaping 与 queue event payload shaping、DeepResearch citation renumber 与 report post-process gate,并建立不暴露 `bitfun-core` / `product-full` / concrete manager 的内部 SDK facade。SDK facade 已支持注入 fake runtime services、tool registry、harness registry、hook registry 和 agent registry。 diff --git a/docs/plans/core-decomposition-plan.md b/docs/plans/core-decomposition-plan.md index 03062ef4ed..10998c133d 100644 --- a/docs/plans/core-decomposition-plan.md +++ b/docs/plans/core-decomposition-plan.md @@ -26,6 +26,7 @@ - File tool 的 provider-neutral 结果展示、写入 mode/status/line-count 规则、Edit guardrail 分类和 Delete success 文本已迁入 `tool-execution`;file-read state 的 provider-neutral guardrail / freshness 语义已迁入 `agent-runtime`;core 保留 ToolResult 包装、权限、checkpoint、read-state adapter、remote shell/FS 调用和旧工具入口。 - PR-C 已收口 Harness / product workflow 的低风险 owner:MiniApp AI / Agent permission、rate-limit、model/message/session/workspace/turn-text 规则迁入 `product-domains`;DeepResearch 后处理 gate 迁入 `agent-runtime`,report IO 继续由 `services-integrations` 持有;function-agent AI concrete acquisition 收拢为 core port adapter,旧 `runtime_services` 路径删除。 - H2 concrete adapter 收口已完成:MiniApp AI / Agent 请求计划、stream payload、runtime event payload、worker restart / draft key / workspace input 规则迁入 `product-domains`;DeepReview concrete Task launch、session metadata cache persistence 和 MiniApp concrete AI factory / scheduler / worker pool 调用已复核为 adapter 边界,不在下层 owner crate 中实现。 +- Remote Connect IM bot 的 provider-neutral 支撑已迁入 `services-integrations`:bot config / persistence / form-state、file auto-push helper、locale / menu rendering、chat state / interaction DTO 和 command parsing。`bitfun-core` 仍保留 command router 与 Telegram / Feishu / Weixin adapters,因为它们还依赖 coordinator、session manager、image context 和具体平台 I/O。 ## 3. 已完成但仍需保持的边界 @@ -70,6 +71,7 @@ | Workspace layout / Cargo path | `cargo metadata --no-deps --format-version 1` | | Runtime Services / backend events | `cargo test -p bitfun-runtime-services`,`cargo check -p bitfun-core --no-default-features` | | Services Core session migration | `cargo test -p bitfun-services-core merge_legacy_session_store`,core workspace-runtime focused tests | +| Remote Connect / IM bot support | `cargo test -p bitfun-services-integrations --features remote-connect --lib remote_connect::bot::`,`cargo test -p bitfun-core --features product-full remote_connect::bot::command_router` | | Agent lifecycle / scheduler | `cargo test -p bitfun-agent-runtime`,core scheduler / session focused tests | | Tool / terminal | `cargo test -p bitfun-agent-tools`,`cargo test -p tool-runtime`,terminal / exec-command focused tests | | Harness / Product Domains | `cargo test -p bitfun-harness`,`cargo test -p bitfun-product-domains`,DeepReview / MiniApp focused tests | diff --git a/src/crates/assembly/core/src/agentic/tools/pipeline/tool_pipeline.rs b/src/crates/assembly/core/src/agentic/tools/pipeline/tool_pipeline.rs index 331d88fb40..ff73b3d090 100644 --- a/src/crates/assembly/core/src/agentic/tools/pipeline/tool_pipeline.rs +++ b/src/crates/assembly/core/src/agentic/tools/pipeline/tool_pipeline.rs @@ -1337,8 +1337,9 @@ mod tests { use crate::agentic::round_preempt::{ DialogRoundInjectionInterrupt, SessionRoundInjectionBuffer, }; - use crate::agentic::tools::framework::{Tool, ToolResult, ToolUseContext, ValidationResult}; + use crate::agentic::tools::framework::{Tool, ToolResult, ValidationResult}; use crate::agentic::tools::implementations::task_tool::TaskTool; + use crate::agentic::tools::tool_context_runtime::ToolUseContext; use crate::agentic::tools::ToolRuntimeRestrictions; use async_trait::async_trait; use bitfun_runtime_ports::{ diff --git a/src/crates/assembly/core/src/service/remote_connect/bot/command_router.rs b/src/crates/assembly/core/src/service/remote_connect/bot/command_router.rs index bba6ec293f..6c986162a9 100644 --- a/src/crates/assembly/core/src/service/remote_connect/bot/command_router.rs +++ b/src/crates/assembly/core/src/service/remote_connect/bot/command_router.rs @@ -15,229 +15,24 @@ //! `execute_forwarded_turn`, `apply_interactive_request`. use log::{error, info}; -use serde::{Deserialize, Serialize}; use serde_json::Value; -use std::future::Future; -use std::pin::Pin; -use std::sync::Arc; pub use super::locale::{current_bot_language, BotLanguage}; use super::locale::{fmt_count, strings_for, BotStrings}; -use super::menu::{MenuItem, MenuItemStyle, MenuView}; +use super::menu::{MenuItem, MenuView}; +pub use bitfun_services_integrations::remote_connect::bot::{ + parse_command, BotAction, BotActionStyle, BotChatState, BotCommand, BotDisplayMode, + BotInteractionHandler, BotInteractiveRequest, BotMessageSender, BotQuestion, BotQuestionOption, + PendingAction, +}; // ── Constants ────────────────────────────────────────────────────── -/// How long a pending interactive prompt stays valid before auto-clearing. -const PENDING_TTL_SECS: i64 = 5 * 60; /// How many invalid replies are tolerated before pending state is auto-cleared. const PENDING_INVALID_LIMIT: u8 = 3; // ── Per-chat state ───────────────────────────────────────────────── -/// Display mode for IM bot sessions. -#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)] -pub enum BotDisplayMode { - /// Expert mode: can create Code / Cowork sessions on real workspaces. - #[serde(rename = "pro")] - Pro, - /// Default assistant mode: Claw sessions on the assistant workspace. - #[serde(rename = "assistant")] - #[default] - Assistant, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct BotChatState { - pub chat_id: String, - pub paired: bool, - pub current_workspace: Option, - pub current_assistant: Option, - /// Human-readable name of the active assistant (e.g. "默认助理" / "Bob"). - /// Populated alongside `current_assistant` from `WorkspaceInfo.name` so - /// the assistant-mode menu body can show a meaningful label instead of - /// the workspace directory name (which is often a generic - /// "workspace" / "workspace-" folder). - #[serde(default, skip_serializing_if = "Option::is_none")] - pub current_assistant_name: Option, - pub current_session_id: Option, - #[serde(default)] - pub display_mode: BotDisplayMode, - - /// Active interactive prompt awaiting a user reply. - /// Not persisted — cleared on bot restart. - #[serde(skip)] - pub pending_action: Option, - /// Unix timestamp (seconds) when the current `pending_action` becomes - /// invalid. Refreshed whenever a new pending action is set. - #[serde(skip)] - pub pending_expires_at: i64, - /// How many invalid replies the user has sent against the current - /// pending action. Resets on every successful transition. - #[serde(skip)] - pub pending_invalid_count: u8, - - /// Commands corresponding to the items in the most recent menu, used so - /// numeric replies (`1` ~ `last_menu_commands.len()`) work without - /// platform-native buttons. Not persisted. - #[serde(skip, default)] - pub last_menu_commands: Vec, -} - -impl BotChatState { - pub fn new(chat_id: String) -> Self { - Self { - chat_id, - paired: false, - current_workspace: None, - current_assistant: None, - current_assistant_name: None, - current_session_id: None, - display_mode: BotDisplayMode::Assistant, - pending_action: None, - pending_expires_at: 0, - pending_invalid_count: 0, - last_menu_commands: Vec::new(), - } - } - - /// Returns the workspace root path that should be used to resolve relative - /// file references emitted by the agent (e.g. markdown links in replies). - /// - /// In Pro mode this is the explicitly switched workspace - /// (`current_workspace`); in Assistant mode the agent runs against the - /// per-user assistant workspace held in `current_assistant`. IM platform - /// adapters MUST consult both — looking only at `current_workspace` causes - /// auto-push to silently drop relative-path attachments produced by - /// assistant sessions (the most common case for end users). - pub fn active_workspace_path(&self) -> Option { - self.current_workspace - .clone() - .or_else(|| self.current_assistant.clone()) - } - - fn set_pending(&mut self, action: PendingAction) { - self.pending_action = Some(action); - self.pending_expires_at = now_secs() + PENDING_TTL_SECS; - self.pending_invalid_count = 0; - } - - fn clear_pending(&mut self) { - self.pending_action = None; - self.pending_expires_at = 0; - self.pending_invalid_count = 0; - } - - fn pending_expired(&self) -> bool { - self.pending_action.is_some() && now_secs() > self.pending_expires_at - } -} - -fn now_secs() -> i64 { - std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .map(|d| d.as_secs() as i64) - .unwrap_or(0) -} - -// ── Pending action ───────────────────────────────────────────────── - -#[derive(Debug, Clone)] -pub enum PendingAction { - SelectWorkspace { - options: Vec<(String, String)>, - }, - SelectAssistant { - options: Vec<(String, String)>, - }, - SelectSession { - options: Vec<(String, String)>, - page: usize, - has_more: bool, - }, - AskUserQuestion { - tool_id: String, - questions: Vec, - current_index: usize, - answers: Vec, - awaiting_custom_text: bool, - pending_answer: Option, - }, - /// Confirm switching to the other display mode and then run `target_cmd`. - ConfirmModeSwitch { - target_mode: BotDisplayMode, - target_cmd: String, - }, -} - -// ── Question DTOs ────────────────────────────────────────────────── - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct BotQuestionOption { - pub label: String, - #[serde(default)] - pub description: String, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct BotQuestion { - #[serde(default)] - pub question: String, - #[serde(default)] - pub header: String, - #[serde(default)] - pub options: Vec, - #[serde(rename = "multiSelect", default)] - pub multi_select: bool, -} - -// ── Action / handle result (compat surface) ──────────────────────── - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum BotActionStyle { - Primary, - Default, -} - -#[derive(Debug, Clone)] -pub struct BotAction { - pub label: String, - pub command: String, - pub style: BotActionStyle, -} - -impl BotAction { - pub fn primary(label: impl Into, command: impl Into) -> Self { - Self { - label: label.into(), - command: command.into(), - style: BotActionStyle::Primary, - } - } - pub fn secondary(label: impl Into, command: impl Into) -> Self { - Self { - label: label.into(), - command: command.into(), - style: BotActionStyle::Default, - } - } -} - -impl From for BotAction { - fn from(item: MenuItem) -> Self { - let style = match item.style { - MenuItemStyle::Primary => BotActionStyle::Primary, - // Danger and Default both map to non-primary on platforms that - // don't have a native danger style. - _ => BotActionStyle::Default, - }; - BotAction { - label: item.label, - command: item.command, - style, - } - } -} - pub struct HandleResult { pub reply: String, pub actions: Vec, @@ -248,20 +43,6 @@ pub struct HandleResult { pub menu: MenuView, } -#[derive(Debug, Clone)] -pub struct BotInteractiveRequest { - pub reply: String, - pub actions: Vec, - pub menu: MenuView, - pub pending_action: PendingAction, -} - -pub type BotInteractionHandler = - Arc Pin + Send>> + Send + Sync>; - -pub type BotMessageSender = - Arc Pin + Send>> + Send + Sync>; - pub struct ForwardRequest { pub session_id: String, pub content: String, @@ -277,141 +58,6 @@ pub struct ForwardedTurnResult { // ── BotCommand ───────────────────────────────────────────────────── -#[derive(Debug, Clone, PartialEq, Eq)] -pub enum BotCommand { - /// Show welcome (unpaired) or main menu (paired). Triggered by - /// `/start`, `/menu`, `/m`, `菜单`, or `0` at the top level. - Menu, - /// Show settings sub-menu. - Settings, - /// Show help text. - Help, - /// Switch display mode. - SwitchMode(BotDisplayMode), - /// Toggle verbose execution-detail mode (persisted globally). - SetVerbose(bool), - /// Generic "switch" entry — picks workspace or assistant by mode. - SwitchContext, - /// Generic "new session" entry — picks the right session type by mode. - NewSession, - /// Specific session creators (kept as hidden aliases). - NewCodeSession, - NewCoworkSession, - NewClawSession, - /// Resume an existing session (workspace or assistant by mode). - ResumeSession, - /// Cancel currently running task. - CancelTask(Option), - /// Pairing code submitted before pairing. - PairingCode(String), - /// Numeric reply to a menu / pending action. - NumberSelection(usize), - /// Free-form chat message forwarded to the AI session. - ChatMessage(String), -} - -// ── Command parsing ──────────────────────────────────────────────── - -fn normalize_im_command_text(text: &str) -> String { - text.trim() - .chars() - .map(|c| match c { - '\u{FF10}'..='\u{FF19}' => { - char::from_u32(c as u32 - 0xFF10 + u32::from(b'0')).unwrap_or(c) - } - c => c, - }) - .collect() -} - -fn strip_numeric_reply_suffix(s: &str) -> &str { - s.trim_end_matches(|c: char| { - matches!( - c, - '.' | '。' | '、' | ',' | ',' | ':' | ':' | ';' | ';' | ')' | ')' | ']' | '】' - ) - }) - .trim() -} - -pub fn parse_command(text: &str) -> BotCommand { - let normalized = normalize_im_command_text(text); - let trimmed = normalized.trim(); - if let Some(rest) = trimmed.strip_prefix("/cancel_task") { - let arg = rest.trim(); - return if arg.is_empty() { - BotCommand::CancelTask(None) - } else { - BotCommand::CancelTask(Some(arg.to_string())) - }; - } - if let Some(rest) = trimmed.strip_prefix("/cancel") { - let arg = rest.trim(); - return if arg.is_empty() { - BotCommand::CancelTask(None) - } else { - BotCommand::CancelTask(Some(arg.to_string())) - }; - } - let lower = trimmed.to_ascii_lowercase(); - match lower.as_str() { - // Top-level navigation / settings. - "/start" | "/menu" | "/m" | "菜单" => return BotCommand::Menu, - "/settings" | "/s" | "设置" => return BotCommand::Settings, - "/help" | "/?" | "/h" | "帮助" | "?" => return BotCommand::Help, - - // Mode switches (visible). - "/expert" | "/pro" | "专业模式" => { - return BotCommand::SwitchMode(BotDisplayMode::Pro); - } - "/assistant" | "助理模式" => { - return BotCommand::SwitchMode(BotDisplayMode::Assistant); - } - - // Verbose toggles. - "/verbose" | "详细" => return BotCommand::SetVerbose(true), - "/concise" | "简洁" => return BotCommand::SetVerbose(false), - - // Generic switch (picks workspace or assistant by mode). - "/switch" | "切换" => return BotCommand::SwitchContext, - // Hidden aliases. - "/switch_workspace" | "切换工作区" => return BotCommand::SwitchContext, - "/switch_assistant" | "切换助理" => return BotCommand::SwitchContext, - - // Generic "new" picks the right session type by mode. - "/new" | "/n" | "新建" | "新建会话" | "新会话" => return BotCommand::NewSession, - // Hidden aliases / power users. - "/new_code_session" | "新建编码会话" => return BotCommand::NewCodeSession, - "/new_cowork_session" | "新建协作会话" => { - return BotCommand::NewCoworkSession; - } - "/new_claw_session" | "新建助理会话" => return BotCommand::NewClawSession, - - // Resume. - "/resume" | "/r" | "/resume_session" | "恢复" | "恢复会话" => { - return BotCommand::ResumeSession; - } - _ => {} - } - - if trimmed.len() == 6 && trimmed.chars().all(|c| c.is_ascii_digit()) { - return BotCommand::PairingCode(trimmed.to_string()); - } - - let num_token = strip_numeric_reply_suffix(trimmed); - if let Ok(n) = num_token.parse::() { - if n <= 99 { - // `0` is intentionally returned as `NumberSelection(0)` so context - // such as "next page" inside SelectSession can override the - // default "0 = back to menu" interpretation. See `handle_number`. - return BotCommand::NumberSelection(n); - } - } - BotCommand::ChatMessage(trimmed.to_string()) -} - -// ── Public welcome / help text (compat) ─────────────────────────── - pub fn welcome_message(language: BotLanguage) -> &'static str { strings_for(language).welcome } @@ -2419,7 +2065,7 @@ mod state_tests { state.set_pending(PendingAction::SelectWorkspace { options: vec![] }); assert!(state.pending_action.is_some()); assert!(!state.pending_expired()); - state.pending_expires_at = now_secs() - 1; + state.pending_expires_at = 0; assert!(state.pending_expired()); } diff --git a/src/crates/assembly/core/src/service/remote_connect/bot/locale.rs b/src/crates/assembly/core/src/service/remote_connect/bot/locale.rs index 3a7c1a743a..5c2470ad9d 100644 --- a/src/crates/assembly/core/src/service/remote_connect/bot/locale.rs +++ b/src/crates/assembly/core/src/service/remote_connect/bot/locale.rs @@ -1,27 +1,6 @@ -//! Centralised IM-bot strings for the simplified bot UX. -//! -//! All user-facing IM bot strings live here so command routing, menu -//! rendering, and platform adapters can share a single source of truth. -//! New languages add one more `static BotStrings` and one match arm in -//! [`strings_for`]. - -use serde::{Deserialize, Serialize}; - -#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] -pub enum BotLanguage { - #[serde(rename = "zh-CN")] - ZhCN, - #[serde(rename = "zh-TW")] - ZhTW, - #[serde(rename = "en-US")] - EnUS, -} - -impl BotLanguage { - pub fn is_chinese(self) -> bool { - matches!(self, Self::ZhCN | Self::ZhTW) - } -} +pub use bitfun_services_integrations::remote_connect::bot::{ + fmt_count, strings_for, BotLanguage, BotStrings, +}; pub async fn current_bot_language() -> BotLanguage { match crate::service::config::get_app_language().await { @@ -30,570 +9,3 @@ pub async fn current_bot_language() -> BotLanguage { crate::service::LocaleId::EnUS => BotLanguage::EnUS, } } - -/// Centralised string table consumed by command router, menu builder, and -/// platform adapters. Add new strings here, then translate in both -/// [`STRINGS_ZH`], [`STRINGS_ZH_TW`], and [`STRINGS_EN`]. -pub struct BotStrings { - // ── Onboarding ─────────────────────────────────────────────── - pub welcome: &'static str, - pub paired_success: &'static str, - pub need_pairing: &'static str, - pub invalid_pairing_code: &'static str, - pub bootstrap_workspace_unavailable: &'static str, - pub bootstrap_session_failed_prefix: &'static str, - pub bootstrap_ready: &'static str, - - // ── Mode / context labels ──────────────────────────────────── - pub mode_assistant: &'static str, - pub mode_expert: &'static str, - pub current_session_label: &'static str, - pub current_workspace_label: &'static str, - pub current_assistant_label: &'static str, - pub no_session: &'static str, - pub no_workspace: &'static str, - pub no_assistant: &'static str, - - // ── Main menu (one-line title) ─────────────────────────────── - pub main_title_assistant: &'static str, - pub main_title_expert: &'static str, - pub settings_title: &'static str, - pub welcome_title: &'static str, - pub need_session_title: &'static str, - - // ── Menu item labels (≤ 14 chars target) ───────────────────── - pub item_new_session: &'static str, - pub item_new_code_session: &'static str, - pub item_new_cowork_session: &'static str, - pub item_resume_session: &'static str, - pub item_switch_assistant: &'static str, - pub item_switch_workspace: &'static str, - pub item_settings: &'static str, - pub item_back: &'static str, - pub item_help: &'static str, - pub item_switch_to_expert: &'static str, - pub item_switch_to_assistant: &'static str, - pub item_verbose_on: &'static str, - pub item_verbose_off: &'static str, - pub item_cancel_task: &'static str, - pub item_confirm_switch: &'static str, - pub item_next_page: &'static str, - pub item_other: &'static str, - - // ── Auxiliary labels ───────────────────────────────────────── - pub question_title: &'static str, - pub verbose_label: &'static str, - pub workspace_session_count_fmt: &'static str, - - // ── Footer hints ───────────────────────────────────────────── - pub footer_reply_or_menu: &'static str, - pub footer_reply_workspace: &'static str, - pub footer_reply_assistant: &'static str, - pub footer_reply_session_or_next: &'static str, - pub footer_reply_session: &'static str, - pub footer_question_single: &'static str, - pub footer_question_multi: &'static str, - pub footer_question_custom: &'static str, - pub footer_processing_cancel_hint: &'static str, - - // ── Body / inline texts ────────────────────────────────────── - pub welcome_body: &'static str, - pub paired_body_intro: &'static str, - pub help_body: &'static str, - - pub switch_pick_workspace: &'static str, - pub switch_pick_assistant: &'static str, - pub switch_no_workspaces: &'static str, - pub switch_no_assistants: &'static str, - pub current_marker: &'static str, - - pub resume_no_sessions: &'static str, - pub resume_page_label: &'static str, - pub resume_msg_count_zero: &'static str, - pub resume_msg_count_one: &'static str, - pub resume_msg_count_many_fmt: &'static str, - pub resume_resumed_prefix: &'static str, - pub resume_last_dialog_header: &'static str, - pub resume_you_label: &'static str, - pub resume_continue_hint: &'static str, - pub resume_first_message_hint: &'static str, - - pub processing: &'static str, - pub queued: &'static str, - pub no_response: &'static str, - pub task_cancelled: &'static str, - pub task_cancel_requested: &'static str, - pub task_cancel_failed_prefix: &'static str, - pub task_no_active: &'static str, - pub timeout_one_hour: &'static str, - pub error_prefix: &'static str, - pub send_failed_prefix: &'static str, - - pub mode_switched_to_expert: &'static str, - pub mode_switched_to_assistant: &'static str, - pub mode_already_expert: &'static str, - pub mode_already_assistant: &'static str, - pub mode_confirm_switch_prefix: &'static str, - - pub verbose_enabled: &'static str, - pub verbose_disabled: &'static str, - pub verbose_status_on: &'static str, - pub verbose_status_off: &'static str, - - pub session_created_prefix: &'static str, - pub session_workspace_label: &'static str, - pub session_start_hint: &'static str, - pub session_create_failed_prefix: &'static str, - pub session_system_unavailable: &'static str, - pub workspace_service_unavailable: &'static str, - pub workspace_open_failed_prefix: &'static str, - pub assistant_create_failed_prefix: &'static str, - - pub pending_expired: &'static str, - pub pending_invalid_input: &'static str, - pub pending_invalid_after_retries: &'static str, - pub pending_back_hint: &'static str, - - pub answers_submitted: &'static str, - pub answers_submit_failed_prefix: &'static str, - pub question_invalid_state: &'static str, - pub question_custom_required: &'static str, - pub question_custom_for_other_prefix: &'static str, - - pub thinking_label: &'static str, - - pub auto_push_intro_one: &'static str, - pub auto_push_intro_many_fmt: &'static str, - pub auto_push_skip_too_large_fmt: &'static str, - pub auto_push_failed_fmt: &'static str, -} - -const STRINGS_ZH: BotStrings = BotStrings { - welcome: "\ -欢迎使用 BitFun。 - -请在 BitFun 桌面端打开 Remote Connect 面板,复制 6 位配对码并发送到这里完成连接。", - paired_success: "配对成功,BitFun 已连接。", - need_pairing: "尚未连接 BitFun 桌面端。请先发送 6 位配对码。", - invalid_pairing_code: "配对码无效或已过期,请到桌面端重新生成后再发送。", - bootstrap_workspace_unavailable: "工作区服务暂时不可用,请稍后再试。", - bootstrap_session_failed_prefix: "已进入助理模式,但创建会话失败:", - bootstrap_ready: "已为你新建助理会话,直接发送消息即可开始。", - - mode_assistant: "助理模式", - mode_expert: "专业模式", - current_session_label: "当前会话", - current_workspace_label: "当前工作区", - current_assistant_label: "当前助理", - no_session: "尚未选择会话", - no_workspace: "尚未选择工作区", - no_assistant: "尚未选择助理", - - main_title_assistant: "BitFun · 助理模式", - main_title_expert: "BitFun · 专业模式", - settings_title: "设置", - welcome_title: "BitFun", - need_session_title: "请先选择或新建会话", - - item_new_session: "新建会话", - item_new_code_session: "新建编码会话", - item_new_cowork_session: "新建协作会话", - item_resume_session: "恢复会话", - item_switch_assistant: "切换助理", - item_switch_workspace: "切换工作区", - item_settings: "设置", - item_back: "返回", - item_help: "帮助", - item_switch_to_expert: "切换到专业模式", - item_switch_to_assistant: "切换到助理模式", - item_verbose_on: "开启执行细节", - item_verbose_off: "关闭执行细节", - item_cancel_task: "取消任务", - item_confirm_switch: "切换并继续", - item_next_page: "下一页", - item_other: "其他", - - question_title: "问题", - verbose_label: "执行细节", - workspace_session_count_fmt: "{n} 个会话", - - footer_reply_or_menu: "回复编号,或发送 /menu 返回主菜单", - footer_reply_workspace: "回复工作区编号,或发送 0 返回", - footer_reply_assistant: "回复助理编号,或发送 0 返回", - footer_reply_session_or_next: "回复会话编号;发送 0 查看下一页或返回", - footer_reply_session: "回复会话编号,或发送 0 返回", - footer_question_single: "回复单个选项编号;发送 /menu 退出", - footer_question_multi: "回复一个或多个选项编号(如 1,3);发送 /menu 退出", - footer_question_custom: "请输入你的自定义答案;发送 /menu 退出", - footer_processing_cancel_hint: "如需中止,回复 /cancel 或点击「取消任务」", - - welcome_body: "当前未配对。", - paired_body_intro: "可以直接发送消息开始对话。", - help_body: "\ -常用命令: -/menu 返回主菜单 -/new 新建会话 -/resume 恢复历史会话 -/switch 切换助理或工作区 -/cancel 取消当前任务 -/expert /assistant 切换模式 -/verbose /concise 开关执行细节 -/help 显示本帮助", - - switch_pick_workspace: "请选择要切换的工作区:", - switch_pick_assistant: "请选择要切换的助理:", - switch_no_workspaces: "尚未发现工作区,请先在 BitFun 桌面端打开一个项目。", - switch_no_assistants: "尚未发现助理,请先在 BitFun 桌面端创建一个助理。", - current_marker: " · 当前", - - resume_no_sessions: "当前还没有会话,可以发送 /new 直接新建。", - resume_page_label: "会话历史", - resume_msg_count_zero: "无消息", - resume_msg_count_one: "1 条消息", - resume_msg_count_many_fmt: "{n} 条消息", - resume_resumed_prefix: "已恢复会话:", - resume_last_dialog_header: "— 最近一次对话 —", - resume_you_label: "你", - resume_continue_hint: "可以继续对话。", - resume_first_message_hint: "发送一条消息即可开始。", - - processing: "正在处理你的消息……", - queued: "消息已加入队列,等当前步骤结束会自动接续。", - no_response: "(无回复)", - task_cancelled: "任务已取消。", - task_cancel_requested: "已请求取消当前任务。", - task_cancel_failed_prefix: "取消任务失败:", - task_no_active: "当前没有正在运行的任务。", - timeout_one_hour: "等待响应超时(1 小时)。", - error_prefix: "错误:", - send_failed_prefix: "发送失败:", - - mode_switched_to_expert: "已切换到专业模式,可创建编码 / 协作会话。", - mode_switched_to_assistant: "已切换到助理模式,适合日常持续对话。", - mode_already_expert: "当前已在专业模式。", - mode_already_assistant: "当前已在助理模式。", - mode_confirm_switch_prefix: "该操作需要切换到另一种模式,确认继续吗?", - - verbose_enabled: "已开启「执行细节」,下一次任务会显示思考与工具过程。", - verbose_disabled: "已关闭「执行细节」,仅显示最终结果。", - verbose_status_on: "开", - verbose_status_off: "关", - - session_created_prefix: "已创建新会话:", - session_workspace_label: "工作区:", - session_start_hint: "可以发送消息开始对话。", - session_create_failed_prefix: "创建会话失败:", - session_system_unavailable: "BitFun 会话系统尚未就绪,请稍后再试。", - workspace_service_unavailable: "工作区服务暂时不可用。", - workspace_open_failed_prefix: "打开工作区失败:", - assistant_create_failed_prefix: "创建助理工作区失败:", - - pending_expired: "上一步已超时,已为你返回主菜单。", - pending_invalid_input: "输入无效,请按提示回复或发送 /menu 返回主菜单。", - pending_invalid_after_retries: "多次输入无效,已为你返回主菜单。", - pending_back_hint: "发送 0 或 /menu 返回主菜单。", - - answers_submitted: "答案已提交,等待助手继续……", - answers_submit_failed_prefix: "提交答案失败:", - question_invalid_state: "问题状态无效,请重新发起对话。", - question_custom_required: "自定义答案不能为空,请重新输入。", - question_custom_for_other_prefix: "请为「其他」输入你的自定义答案:", - - thinking_label: "思考中", - - auto_push_intro_one: "正在为你发送 1 个文件……", - auto_push_intro_many_fmt: "正在为你发送 {n} 个文件……", - auto_push_skip_too_large_fmt: "已跳过「{name}」:{size} 超过 {limit} 上限,请改用桌面端获取。", - auto_push_failed_fmt: "发送「{name}」失败:{err}", -}; - -const STRINGS_ZH_TW: BotStrings = BotStrings { - welcome: "\ -歡迎使用 BitFun。 - -請在 BitFun 桌面端打開 Remote Connect 面板,複製 6 位配對碼併發送到這裡完成連接。", - paired_success: "配對成功,BitFun 已連接。", - need_pairing: "尚未連接 BitFun 桌面端。請先發送 6 位配對碼。", - invalid_pairing_code: "配對碼無效或已過期,請到桌面端重新生成後再發送。", - bootstrap_workspace_unavailable: "工作區服務暫時不可用,請稍後再試。", - bootstrap_session_failed_prefix: "已進入助理模式,但創建會話失敗:", - bootstrap_ready: "已為你新建助理會話,直接發送消息即可開始。", - - mode_assistant: "助理模式", - mode_expert: "專業模式", - current_session_label: "當前會話", - current_workspace_label: "當前工作區", - current_assistant_label: "當前助理", - no_session: "尚未選擇會話", - no_workspace: "尚未選擇工作區", - no_assistant: "尚未選擇助理", - - main_title_assistant: "BitFun · 助理模式", - main_title_expert: "BitFun · 專業模式", - settings_title: "設置", - welcome_title: "BitFun", - need_session_title: "請先選擇或新建會話", - - item_new_session: "新建會話", - item_new_code_session: "新建編碼會話", - item_new_cowork_session: "新建協作會話", - item_resume_session: "恢復會話", - item_switch_assistant: "切換助理", - item_switch_workspace: "切換工作區", - item_settings: "設置", - item_back: "返回", - item_help: "幫助", - item_switch_to_expert: "切換到專業模式", - item_switch_to_assistant: "切換到助理模式", - item_verbose_on: "開啟執行細節", - item_verbose_off: "關閉執行細節", - item_cancel_task: "取消任務", - item_confirm_switch: "切換並繼續", - item_next_page: "下一頁", - item_other: "其他", - - question_title: "問題", - verbose_label: "執行細節", - workspace_session_count_fmt: "{n} 個會話", - - footer_reply_or_menu: "回覆編號,或發送 /menu 返回主菜單", - footer_reply_workspace: "回覆工作區編號,或發送 0 返回", - footer_reply_assistant: "回覆助理編號,或發送 0 返回", - footer_reply_session_or_next: "回覆會話編號;發送 0 查看下一頁或返回", - footer_reply_session: "回覆會話編號,或發送 0 返回", - footer_question_single: "回覆單個選項編號;發送 /menu 退出", - footer_question_multi: "回覆一個或多個選項編號(如 1,3);發送 /menu 退出", - footer_question_custom: "請輸入你的自定義答案;發送 /menu 退出", - footer_processing_cancel_hint: "如需中止,回覆 /cancel 或點擊「取消任務」", - - welcome_body: "當前未配對。", - paired_body_intro: "可以直接發送消息開始對話。", - help_body: "\ -常用命令: -/menu 返回主菜單 -/new 新建會話 -/resume 恢復歷史會話 -/switch 切換助理或工作區 -/cancel 取消當前任務 -/expert /assistant 切換模式 -/verbose /concise 開關執行細節 -/help 顯示本幫助", - - switch_pick_workspace: "請選擇要切換的工作區:", - switch_pick_assistant: "請選擇要切換的助理:", - switch_no_workspaces: "尚未發現工作區,請先在 BitFun 桌面端打開一個項目。", - switch_no_assistants: "尚未發現助理,請先在 BitFun 桌面端創建一個助理。", - current_marker: " · 當前", - - resume_no_sessions: "當前還沒有會話,可以發送 /new 直接新建。", - resume_page_label: "會話歷史", - resume_msg_count_zero: "無消息", - resume_msg_count_one: "1 條消息", - resume_msg_count_many_fmt: "{n} 條消息", - resume_resumed_prefix: "已恢復會話:", - resume_last_dialog_header: "— 最近一次對話 —", - resume_you_label: "你", - resume_continue_hint: "可以繼續對話。", - resume_first_message_hint: "發送一條消息即可開始。", - - processing: "正在處理你的消息……", - queued: "消息已加入隊列,等當前步驟結束會自動接續。", - no_response: "(無回覆)", - task_cancelled: "任務已取消。", - task_cancel_requested: "已請求取消當前任務。", - task_cancel_failed_prefix: "取消任務失敗:", - task_no_active: "當前沒有正在運行的任務。", - timeout_one_hour: "等待響應超時(1 小時)。", - error_prefix: "錯誤:", - send_failed_prefix: "發送失敗:", - - mode_switched_to_expert: "已切換到專業模式,可創建編碼 / 協作會話。", - mode_switched_to_assistant: "已切換到助理模式,適合日常持續對話。", - mode_already_expert: "當前已在專業模式。", - mode_already_assistant: "當前已在助理模式。", - mode_confirm_switch_prefix: "該操作需要切換到另一種模式,確認繼續嗎?", - - verbose_enabled: "已開啟「執行細節」,下一次任務會顯示思考與工具過程。", - verbose_disabled: "已關閉「執行細節」,僅顯示最終結果。", - verbose_status_on: "開", - verbose_status_off: "關", - - session_created_prefix: "已創建新會話:", - session_workspace_label: "工作區:", - session_start_hint: "可以發送消息開始對話。", - session_create_failed_prefix: "創建會話失敗:", - session_system_unavailable: "BitFun 會話系統尚未就緒,請稍後再試。", - workspace_service_unavailable: "工作區服務暫時不可用。", - workspace_open_failed_prefix: "打開工作區失敗:", - assistant_create_failed_prefix: "創建助理工作區失敗:", - - pending_expired: "上一步已超時,已為你返回主菜單。", - pending_invalid_input: "輸入無效,請按提示回覆或發送 /menu 返回主菜單。", - pending_invalid_after_retries: "多次輸入無效,已為你返回主菜單。", - pending_back_hint: "發送 0 或 /menu 返回主菜單。", - - answers_submitted: "答案已提交,等待助手繼續……", - answers_submit_failed_prefix: "提交答案失敗:", - question_invalid_state: "問題狀態無效,請重新發起對話。", - question_custom_required: "自定義答案不能為空,請重新輸入。", - question_custom_for_other_prefix: "請為「其他」輸入你的自定義答案:", - - thinking_label: "思考中", - - auto_push_intro_one: "正在為你發送 1 個文件……", - auto_push_intro_many_fmt: "正在為你發送 {n} 個文件……", - auto_push_skip_too_large_fmt: "已跳過「{name}」:{size} 超過 {limit} 上限,請改用桌面端獲取。", - auto_push_failed_fmt: "發送「{name}」失敗:{err}", -}; - -const STRINGS_EN: BotStrings = BotStrings { - welcome: "\ -Welcome to BitFun. - -Open Remote Connect in BitFun Desktop and send the 6-digit pairing code here to connect.", - paired_success: "Pairing successful. BitFun is now connected.", - need_pairing: "Not connected yet. Please send the 6-digit pairing code first.", - invalid_pairing_code: "Invalid or expired pairing code. Generate a new one in BitFun Desktop and try again.", - bootstrap_workspace_unavailable: "Workspace service is unavailable. Please try again shortly.", - bootstrap_session_failed_prefix: "Assistant mode is on but session creation failed: ", - bootstrap_ready: "A new assistant session is ready. Send a message to start.", - - mode_assistant: "Assistant Mode", - mode_expert: "Expert Mode", - current_session_label: "Current session", - current_workspace_label: "Current workspace", - current_assistant_label: "Current assistant", - no_session: "No session selected", - no_workspace: "No workspace selected", - no_assistant: "No assistant selected", - - main_title_assistant: "BitFun · Assistant", - main_title_expert: "BitFun · Expert", - settings_title: "Settings", - welcome_title: "BitFun", - need_session_title: "Pick or create a session first", - - item_new_session: "New Session", - item_new_code_session: "New Code Session", - item_new_cowork_session: "New Cowork Session", - item_resume_session: "Resume Session", - item_switch_assistant: "Switch Assistant", - item_switch_workspace: "Switch Workspace", - item_settings: "Settings", - item_back: "Back", - item_help: "Help", - item_switch_to_expert: "Switch to Expert Mode", - item_switch_to_assistant: "Switch to Assistant Mode", - item_verbose_on: "Show Execution Details", - item_verbose_off: "Hide Execution Details", - item_cancel_task: "Cancel Task", - item_confirm_switch: "Switch & Continue", - item_next_page: "Next Page", - item_other: "Other", - - question_title: "Question", - verbose_label: "Execution details", - workspace_session_count_fmt: "{n} sessions", - - footer_reply_or_menu: "Reply with a number, or send /menu to return.", - footer_reply_workspace: "Reply with a workspace number, or 0 to go back.", - footer_reply_assistant: "Reply with an assistant number, or 0 to go back.", - footer_reply_session_or_next: "Reply with a session number; send 0 for next page or to go back.", - footer_reply_session: "Reply with a session number, or 0 to go back.", - footer_question_single: "Reply with one option number; send /menu to exit.", - footer_question_multi: "Reply with one or more option numbers (e.g. 1,3); send /menu to exit.", - footer_question_custom: "Type your custom answer; send /menu to exit.", - footer_processing_cancel_hint: "To stop, reply /cancel or tap Cancel Task.", - - welcome_body: "Not paired yet.", - paired_body_intro: "Send a message to start the conversation.", - help_body: "\ -Common commands: -/menu Return to the main menu -/new Create a new session -/resume Resume an existing session -/switch Switch assistant or workspace -/cancel Cancel the current task -/expert /assistant Switch modes -/verbose /concise Toggle execution details -/help Show this help", - - switch_pick_workspace: "Pick a workspace to switch to:", - switch_pick_assistant: "Pick an assistant to switch to:", - switch_no_workspaces: "No workspaces found. Open a project in BitFun Desktop first.", - switch_no_assistants: "No assistants found. Create one in BitFun Desktop first.", - current_marker: " · current", - - resume_no_sessions: "No sessions yet. Send /new to create one.", - resume_page_label: "Sessions", - resume_msg_count_zero: "no messages", - resume_msg_count_one: "1 message", - resume_msg_count_many_fmt: "{n} messages", - resume_resumed_prefix: "Resumed session: ", - resume_last_dialog_header: "— Last conversation —", - resume_you_label: "You", - resume_continue_hint: "You can continue the conversation.", - resume_first_message_hint: "Send a message to start.", - - processing: "Processing your message…", - queued: "Message queued. It will run when the current step finishes.", - no_response: "(no response)", - task_cancelled: "Task cancelled.", - task_cancel_requested: "Cancellation requested.", - task_cancel_failed_prefix: "Failed to cancel: ", - task_no_active: "No active task to cancel.", - timeout_one_hour: "Response timed out after 1 hour.", - error_prefix: "Error: ", - send_failed_prefix: "Send failed: ", - - mode_switched_to_expert: "Switched to Expert mode. You can create code or cowork sessions.", - mode_switched_to_assistant: "Switched to Assistant mode. Best for ongoing conversations.", - mode_already_expert: "Already in Expert mode.", - mode_already_assistant: "Already in Assistant mode.", - mode_confirm_switch_prefix: "This action needs the other mode. Switch and continue?", - - verbose_enabled: "Execution details enabled. The next task will show thinking and tool steps.", - verbose_disabled: "Execution details disabled. Only final results will be shown.", - verbose_status_on: "on", - verbose_status_off: "off", - - session_created_prefix: "New session created: ", - session_workspace_label: "Workspace: ", - session_start_hint: "Send a message to start the conversation.", - session_create_failed_prefix: "Failed to create session: ", - session_system_unavailable: "BitFun session system is not ready yet.", - workspace_service_unavailable: "Workspace service unavailable.", - workspace_open_failed_prefix: "Failed to open workspace: ", - assistant_create_failed_prefix: "Failed to create assistant workspace: ", - - pending_expired: "Previous step expired. Returned to the main menu.", - pending_invalid_input: "Invalid input. Follow the prompt above, or send /menu to return.", - pending_invalid_after_retries: "Too many invalid replies. Returned to the main menu.", - pending_back_hint: "Send 0 or /menu to return to the main menu.", - - answers_submitted: "Answers submitted. Waiting for the assistant to continue…", - answers_submit_failed_prefix: "Failed to submit answers: ", - question_invalid_state: "Question state is invalid; please restart the conversation.", - question_custom_required: "Custom answer cannot be empty. Please type it again.", - question_custom_for_other_prefix: "Type your custom answer for `Other`: ", - - thinking_label: "Thinking", - - auto_push_intro_one: "Sending 1 file for you…", - auto_push_intro_many_fmt: "Sending {n} files for you…", - auto_push_skip_too_large_fmt: "Skipping \"{name}\": {size} exceeds the {limit} limit. Please grab it from BitFun Desktop instead.", - auto_push_failed_fmt: "Failed to send \"{name}\": {err}", -}; - -pub fn strings_for(language: BotLanguage) -> &'static BotStrings { - match language { - BotLanguage::ZhCN => &STRINGS_ZH, - BotLanguage::ZhTW => &STRINGS_ZH_TW, - BotLanguage::EnUS => &STRINGS_EN, - } -} - -/// Substitute `{n}` placeholder in formatted strings. -pub fn fmt_count(template: &str, n: usize) -> String { - template.replace("{n}", &n.to_string()) -} diff --git a/src/crates/assembly/core/src/service/remote_connect/bot/menu.rs b/src/crates/assembly/core/src/service/remote_connect/bot/menu.rs index 27e6952319..d94fa3375b 100644 --- a/src/crates/assembly/core/src/service/remote_connect/bot/menu.rs +++ b/src/crates/assembly/core/src/service/remote_connect/bot/menu.rs @@ -1,212 +1 @@ -//! Unified menu model shared by all IM bot adapters. -//! -//! The command router builds a [`MenuView`] for every reply. Each platform -//! adapter renders the view to its native primitive: Telegram inline -//! keyboards, Feishu interactive cards, or WeChat numbered text lines. -//! -//! There is intentionally no per-platform menu state in this module — menu -//! semantics live in `command_router::dispatch_im_bot_command_inner`. - -use serde::{Deserialize, Serialize}; - -use super::locale::{strings_for, BotLanguage, BotStrings}; - -/// Visual style of a menu item. Adapters map this to their own primitive -/// (e.g. Telegram has no styling, Feishu uses a `type` field). -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -pub enum MenuItemStyle { - Primary, - Default, - Danger, -} - -/// One row in a [`MenuView`]. -#[derive(Debug, Clone)] -pub struct MenuItem { - /// Short, button-friendly label shown to the user. Should be ≤ 14 chars. - pub label: String, - /// Real command string the bot will execute when the item is selected. - /// For platforms with native buttons this becomes `callback_data`; for - /// WeChat it is mapped via [`MenuView::numeric_commands`] for `1` ~ `n` - /// numeric replies. - pub command: String, - pub style: MenuItemStyle, -} - -impl MenuItem { - pub fn primary(label: impl Into, command: impl Into) -> Self { - Self { - label: label.into(), - command: command.into(), - style: MenuItemStyle::Primary, - } - } - pub fn default(label: impl Into, command: impl Into) -> Self { - Self { - label: label.into(), - command: command.into(), - style: MenuItemStyle::Default, - } - } - pub fn danger(label: impl Into, command: impl Into) -> Self { - Self { - label: label.into(), - command: command.into(), - style: MenuItemStyle::Danger, - } - } -} - -/// Unified, platform-agnostic menu/reply view. -/// -/// Always provide a short `title` and at most 5 items. The body field is -/// optional and used for context like "Current session: …" or last dialog -/// playback. -#[derive(Debug, Clone, Default)] -pub struct MenuView { - /// One-line context header (≤ 30 chars target). - pub title: String, - /// Optional secondary body text. - pub body: Option, - pub items: Vec, - /// Optional footer hint shown below items (telegram/feishu silently - /// drop this; weixin shows it as the last text line). - pub footer_hint: Option, - /// Whether text-only renderers should append `items` as numbered lines. - /// Some selection prompts include richer numbered lines in `body` while - /// still keeping `items` for native buttons; appending both duplicates the - /// option list on plain-text platforms. - pub render_items_in_plain_text: bool, -} - -impl MenuView { - pub fn plain(title: impl Into) -> Self { - Self { - title: title.into(), - body: None, - items: Vec::new(), - footer_hint: None, - render_items_in_plain_text: true, - } - } - - pub fn with_body(mut self, body: impl Into) -> Self { - self.body = Some(body.into()); - self - } - - pub fn with_items(mut self, items: Vec) -> Self { - self.items = items; - self - } - - pub fn with_footer(mut self, hint: impl Into) -> Self { - self.footer_hint = Some(hint.into()); - self - } - - pub fn without_plain_text_items(mut self) -> Self { - self.render_items_in_plain_text = false; - self - } - - pub fn push_item(&mut self, item: MenuItem) { - self.items.push(item); - } - - /// Commands corresponding to numeric replies `1..=items.len()`. - pub fn numeric_commands(&self) -> Vec { - self.items.iter().map(|i| i.command.clone()).collect() - } - - /// Render the menu as plain text suitable for IM platforms without - /// native buttons (e.g. WeChat iLink). - pub fn render_plain_text(&self, language: BotLanguage) -> String { - let s = strings_for(language); - let mut out = String::new(); - if !self.title.is_empty() { - out.push_str(&self.title); - } - if let Some(body) = &self.body { - if !body.is_empty() { - if !out.is_empty() { - out.push_str("\n\n"); - } - out.push_str(body); - } - } - if self.render_items_in_plain_text && !self.items.is_empty() { - if !out.is_empty() { - out.push_str("\n\n"); - } - for (i, item) in self.items.iter().enumerate() { - if i > 0 { - out.push('\n'); - } - out.push_str(&format!("{} {}", i + 1, item.label)); - } - } - let hint = self - .footer_hint - .clone() - .filter(|h| !h.is_empty()) - .unwrap_or_else(|| { - if self.items.is_empty() { - String::new() - } else { - s.footer_reply_or_menu.to_string() - } - }); - if !hint.is_empty() { - if !out.is_empty() { - out.push_str("\n\n"); - } - out.push_str(&hint); - } - out - } - - /// Render the title and optional body as a single text block, used by - /// adapters with native buttons (Telegram / Feishu) where the items are - /// shown separately as buttons. - pub fn render_text_block(&self) -> String { - let mut out = String::new(); - if !self.title.is_empty() { - out.push_str(&self.title); - } - if let Some(body) = &self.body { - if !body.is_empty() { - if !out.is_empty() { - out.push_str("\n\n"); - } - out.push_str(body); - } - } - if let Some(hint) = &self.footer_hint { - if !hint.is_empty() { - if !out.is_empty() { - out.push_str("\n\n"); - } - out.push_str(hint); - } - } - if out.is_empty() { - // Telegram refuses empty messages; fall back to a single space. - " ".to_string() - } else { - out - } - } -} - -/// Common menu builder helpers used by command router and platforms. -pub mod build { - use super::*; - - /// Append the standard `back to main menu` item if not already present. - pub fn with_back(view: &mut MenuView, s: &BotStrings) { - if !view.items.iter().any(|i| i.command == "/menu") { - view.push_item(MenuItem::default(s.item_back, "/menu")); - } - } -} +pub use bitfun_services_integrations::remote_connect::bot::{MenuItem, MenuItemStyle, MenuView}; diff --git a/src/crates/assembly/core/src/service/remote_connect/bot/mod.rs b/src/crates/assembly/core/src/service/remote_connect/bot/mod.rs index cf3fef04d6..16f6159e18 100644 --- a/src/crates/assembly/core/src/service/remote_connect/bot/mod.rs +++ b/src/crates/assembly/core/src/service/remote_connect/bot/mod.rs @@ -1,8 +1,9 @@ -//! Bot integration for Remote Connect. +//! Bot integration compatibility facade for Remote Connect. //! -//! Supports Feishu, Telegram, and Weixin (iLink) bots as relay channels. -//! Shared command logic lives in `command_router`; platform-specific -//! I/O is handled by `telegram`, `feishu`, and `weixin`. +//! Provider-neutral bot DTOs, menu and locale rendering, persistence, and file +//! delivery helpers live in `bitfun-services-integrations`. Core keeps the +//! command router and platform bot adapters because they still call concrete +//! session, coordinator, image, and runtime services. pub mod command_router; pub mod feishu; @@ -11,595 +12,12 @@ pub mod menu; pub mod telegram; pub mod weixin; -use serde::{Deserialize, Serialize}; - +pub use bitfun_services_integrations::remote_connect::bot::{ + auto_push_failed_message, auto_push_intro, auto_push_skip_too_large_message, + collect_auto_push_files, detect_mime_type, extract_computer_file_paths, + extract_downloadable_file_paths, format_file_size, get_file_metadata, load_bot_persistence, + read_workspace_file, resolve_workspace_path, save_bot_persistence, AutoPushFile, BotConfig, + BotLanguage, BotPairingInfo, BotPersistenceData, MenuItem, MenuItemStyle, MenuView, + RemoteConnectFormState, SavedBotConnection, WorkspaceFileContent, +}; pub use command_router::{BotChatState, ForwardRequest, ForwardedTurnResult, HandleResult}; -pub use locale::BotLanguage; -pub use menu::{MenuItem, MenuItemStyle, MenuView}; - -/// Configuration for a bot-based connection. -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(tag = "bot_type", rename_all = "snake_case")] -pub enum BotConfig { - Feishu { - app_id: String, - app_secret: String, - }, - Telegram { - bot_token: String, - }, - Weixin { - ilink_token: String, - base_url: String, - bot_account_id: String, - }, -} - -/// Pairing state for bot-based connections. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct BotPairingInfo { - pub pairing_code: String, - pub bot_type: String, - pub bot_link: String, - pub expires_at: i64, -} - -/// Persisted bot connection — saved to disk so reconnect survives restarts. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct SavedBotConnection { - pub bot_type: String, - pub chat_id: String, - pub config: BotConfig, - pub chat_state: BotChatState, - pub connected_at: i64, -} - -/// Persisted remote-connect form values shown in the desktop dialog. -#[derive(Debug, Clone, Default, Serialize, Deserialize)] -pub struct RemoteConnectFormState { - pub custom_server_url: String, - pub telegram_bot_token: String, - pub feishu_app_id: String, - pub feishu_app_secret: String, - /// Weixin iLink credentials after QR login (optional until user links WeChat). - #[serde(default, skip_serializing_if = "String::is_empty")] - pub weixin_ilink_token: String, - #[serde(default, skip_serializing_if = "String::is_empty")] - pub weixin_base_url: String, - #[serde(default, skip_serializing_if = "String::is_empty")] - pub weixin_bot_account_id: String, -} - -/// All persisted bot connections (one per bot type at most). -#[derive(Debug, Clone, Default, Serialize, Deserialize)] -pub struct BotPersistenceData { - #[serde(default)] - pub connections: Vec, - #[serde(default)] - pub form_state: RemoteConnectFormState, - /// Global verbose mode setting for all bot connections. - /// When true, the agent's intermediate thinking summaries (one short - /// `[Thinking] …` line per `ThinkingEnd`) are forwarded to the user. - /// Tool-call notifications are intentionally NOT sent even in verbose - /// mode — they were too noisy for IM channels (especially WeChat where - /// each line costs a `context_token` slot) without giving the user - /// information they could act on. - /// Defaults to `false` (concise mode). - #[serde(default)] - pub verbose_mode: bool, -} - -impl BotPersistenceData { - pub fn upsert(&mut self, conn: SavedBotConnection) { - self.connections.retain(|c| c.bot_type != conn.bot_type); - self.connections.push(conn); - } - - pub fn remove(&mut self, bot_type: &str) { - self.connections.retain(|c| c.bot_type != bot_type); - } - - pub fn get(&self, bot_type: &str) -> Option<&SavedBotConnection> { - self.connections.iter().find(|c| c.bot_type == bot_type) - } -} - -// ── Shared workspace-file utilities ──────────────────────────────── - -/// File content read from the local workspace, ready to be sent over any channel. -pub struct WorkspaceFileContent { - pub name: String, - pub bytes: Vec, - pub mime_type: &'static str, - pub size: u64, -} - -/// Resolve a raw path (with or without `computer://` / `file://` prefix) to an -/// absolute `PathBuf`. -/// -/// Absolute paths are passed through directly. Relative paths are resolved -/// against `workspace_root` when provided, and paths escaping that root are -/// rejected. -pub fn resolve_workspace_path( - raw: &str, - workspace_root: Option<&std::path::Path>, -) -> Option { - bitfun_services_integrations::remote_connect::resolve_remote_workspace_path(raw, workspace_root) -} - -/// Return the best-effort MIME type for a file based on its extension. -pub fn detect_mime_type(path: &std::path::Path) -> &'static str { - bitfun_services_integrations::remote_connect::detect_remote_mime_type(path) -} - -/// Read a workspace file, resolving `computer://` prefixes. -/// -/// `max_size` is the caller-specific byte limit (e.g. 50 MB for Telegram, -/// 30 MB for Feishu, 10 MB for mobile relay). -/// -/// Returns an error when the file is missing, is a directory, or exceeds -/// `max_size`. -pub async fn read_workspace_file( - raw_path: &str, - max_size: u64, - workspace_root: Option<&std::path::Path>, -) -> anyhow::Result { - let content = bitfun_services_integrations::remote_connect::read_remote_workspace_file( - raw_path, - max_size, - workspace_root, - ) - .await - .map_err(anyhow::Error::msg)?; - - Ok(WorkspaceFileContent { - name: content.name, - bytes: content.bytes, - mime_type: content.mime_type, - size: content.size, - }) -} - -/// Get file metadata (name and size in bytes) without reading the full content. -/// Returns `None` if the path cannot be resolved, does not exist, or is not a -/// regular file. -pub fn get_file_metadata( - raw_path: &str, - workspace_root: Option<&std::path::Path>, -) -> Option<(String, u64)> { - let abs = resolve_workspace_path(raw_path, workspace_root)?; - if !abs.is_file() { - return None; - } - let name = abs - .file_name() - .and_then(|n| n.to_str()) - .unwrap_or("file") - .to_string(); - let size = std::fs::metadata(&abs).ok()?.len(); - Some((name, size)) -} - -/// Format a byte count as a human-readable string (e.g. "1.4 MB", "320 KB"). -pub fn format_file_size(bytes: u64) -> String { - if bytes >= 1024 * 1024 { - format!("{:.1} MB", bytes as f64 / (1024.0 * 1024.0)) - } else if bytes >= 1024 { - format!("{} KB", bytes / 1024) - } else { - format!("{bytes} B") - } -} - -// ── Downloadable file link extraction ────────────────────────────── - -/// Extensions that are source-code / config files — excluded from download -/// when referenced via absolute paths (matches mobile-web `CODE_FILE_EXTENSIONS`). -const CODE_FILE_EXTENSIONS: &[&str] = &[ - "js", - "jsx", - "ts", - "tsx", - "mjs", - "cjs", - "mts", - "cts", - "py", - "pyw", - "pyi", - "rs", - "go", - "java", - "kt", - "kts", - "scala", - "groovy", - "c", - "cpp", - "cc", - "cxx", - "h", - "hpp", - "hxx", - "hh", - "cs", - "rb", - "php", - "swift", - "vue", - "svelte", - "css", - "scss", - "less", - "sass", - "json", - "jsonc", - "yaml", - "yml", - "toml", - "xml", - "md", - "mdx", - "rst", - "txt", - "sh", - "bash", - "zsh", - "fish", - "ps1", - "bat", - "cmd", - "sql", - "graphql", - "gql", - "proto", - "lock", - "env", - "ini", - "cfg", - "conf", - "cj", - "ets", - "editorconfig", - "gitignore", - "log", -]; - -/// Extensions that should be treated as downloadable when referenced via -/// relative markdown links (matches mobile-web `DOWNLOADABLE_EXTENSIONS`). -const DOWNLOADABLE_EXTENSIONS: &[&str] = &[ - "pdf", "doc", "docx", "xls", "xlsx", "ppt", "pptx", "odt", "ods", "odp", "rtf", "pages", - "numbers", "key", "png", "jpg", "jpeg", "gif", "bmp", "svg", "webp", "ico", "tiff", "tif", - "zip", "tar", "gz", "bz2", "7z", "rar", "dmg", "iso", "xz", "mp3", "wav", "ogg", "flac", "aac", - "m4a", "wma", "mp4", "avi", "mkv", "mov", "webm", "wmv", "flv", "csv", "tsv", "sqlite", "db", - "parquet", "epub", "mobi", "apk", "ipa", "exe", "msi", "deb", "rpm", "ttf", "otf", "woff", - "woff2", -]; - -/// Check whether a bare file path (no protocol prefix) should be treated as -/// a downloadable file based on its extension. -/// -/// Absolute local file paths exclude source/config files. Relative links -/// are allowed when they point to known downloadable file types. -fn is_downloadable_by_extension(file_path: &str) -> bool { - let ext = std::path::Path::new(file_path) - .extension() - .and_then(|e| e.to_str()) - .unwrap_or("") - .to_lowercase(); - if ext.is_empty() { - return false; - } - let is_absolute = file_path.starts_with('/') - || (file_path.len() >= 3 && file_path.as_bytes().get(1) == Some(&b':')); - if is_absolute { - !CODE_FILE_EXTENSIONS.contains(&ext.as_str()) - } else { - DOWNLOADABLE_EXTENSIONS.contains(&ext.as_str()) - } -} - -/// Only file paths that can be resolved to existing files are returned. -/// Directories and missing paths are skipped. Duplicate paths are deduplicated -/// before returning. -pub fn extract_computer_file_paths( - text: &str, - workspace_root: Option<&std::path::Path>, -) -> Vec { - const PREFIX: &str = "computer://"; - let mut paths: Vec = Vec::new(); - let mut search = text; - - while let Some(idx) = search.find(PREFIX) { - let rest = &search[idx + PREFIX.len()..]; - let end = rest - .find(|c: char| c.is_whitespace() || matches!(c, '<' | '>' | '(' | ')' | '"' | '\'')) - .unwrap_or(rest.len()); - let raw_suffix = rest[..end].trim_end_matches(['.', ',', ';', ':', ')', ']']); - if !raw_suffix.is_empty() { - push_if_existing_file(&format!("{PREFIX}{raw_suffix}"), &mut paths, workspace_root); - } - search = &rest[end..]; - } - - paths -} - -/// Try to resolve `file_path` and, if it exists as a regular file, push -/// its absolute path into `out` (deduplicating). -fn push_if_existing_file( - file_path: &str, - out: &mut Vec, - workspace_root: Option<&std::path::Path>, -) { - if let Some(abs) = resolve_workspace_path(file_path, workspace_root) { - let abs_str = abs.to_string_lossy().into_owned(); - if abs.exists() && abs.is_file() && !out.contains(&abs_str) { - out.push(abs_str); - } - } -} - -/// Extract all downloadable file paths from agent response markdown text. -/// -/// Detects three kinds of references: -/// 1. `computer://` links in plain text. -/// 2. `file://` links in plain text. -/// 3. Markdown hyperlinks `[text](href)` pointing to absolute local files -/// (excluding code/config source files). -/// -/// Only paths that exist as regular files on disk are returned. -/// Duplicate paths are deduplicated. -pub fn extract_downloadable_file_paths( - text: &str, - workspace_root: Option<&std::path::Path>, -) -> Vec { - let mut paths: Vec = Vec::new(); - - // Phase 1 — protocol-prefixed links (`computer://` and `file://`). - for prefix in ["computer://", "file://"] { - let mut search = text; - while let Some(idx) = search.find(prefix) { - let rest = &search[idx + prefix.len()..]; - let end = rest - .find(|c: char| { - c.is_whitespace() || matches!(c, '<' | '>' | '(' | ')' | '"' | '\'') - }) - .unwrap_or(rest.len()); - let raw_suffix = rest[..end].trim_end_matches(['.', ',', ';', ':', ')', ']']); - if !raw_suffix.is_empty() { - let resolve_input = if prefix == "computer://" { - format!("{prefix}{raw_suffix}") - } else { - raw_suffix.to_string() - }; - push_if_existing_file(&resolve_input, &mut paths, workspace_root); - } - search = &rest[end..]; - } - } - - // Phase 2 — markdown hyperlinks `[text](href)` referencing local files. - let bytes = text.as_bytes(); - let len = bytes.len(); - let mut i = 0; - while i + 2 < len { - if bytes[i] == b']' && bytes[i + 1] == b'(' { - let href_start = i + 2; - if let Some(rel_end) = text[href_start..].find(')') { - let href = text[href_start..href_start + rel_end].trim(); - // Skip protocols already handled above and non-local URLs. - if !href.is_empty() - && !href.starts_with("computer://") - && !href.starts_with("file://") - && !href.starts_with("http://") - && !href.starts_with("https://") - && !href.starts_with("mailto:") - && !href.starts_with("tel:") - && !href.starts_with('#') - && !href.starts_with("//") - && is_downloadable_by_extension(href) - { - push_if_existing_file(href, &mut paths, workspace_root); - } - i = href_start + rel_end + 1; - } else { - i += 2; - } - } else { - i += 1; - } - } - - paths -} - -// ── Auto-push file delivery helpers ─────────────────────────────── - -/// One file to be auto-pushed to the IM peer alongside an agent reply. -#[derive(Debug, Clone)] -pub struct AutoPushFile { - /// Absolute path on the desktop (already resolved). - pub abs_path: String, - /// User-visible filename (basename of `abs_path`). - pub name: String, - /// Plaintext file size in bytes (for size-limit checks and UI). - pub size: u64, -} - -/// Scan an agent reply for downloadable file references and resolve their -/// metadata so each platform adapter can push them directly to the user -/// without an intermediate "tap to download" prompt. -pub fn collect_auto_push_files( - text: &str, - workspace_root: Option<&std::path::Path>, -) -> Vec { - extract_downloadable_file_paths(text, workspace_root) - .into_iter() - .filter_map(|path| { - get_file_metadata(&path, workspace_root).map(|(name, size)| AutoPushFile { - abs_path: path, - name, - size, - }) - }) - .collect() -} - -/// Caption sent once before the first auto-pushed file. -pub fn auto_push_intro(language: BotLanguage, count: usize) -> String { - let strings = locale::strings_for(language); - if count <= 1 { - strings.auto_push_intro_one.to_string() - } else { - locale::fmt_count(strings.auto_push_intro_many_fmt, count) - } -} - -/// Notice sent when a single file exceeds the platform's size limit and is skipped. -pub fn auto_push_skip_too_large_message( - language: BotLanguage, - file_name: &str, - size: u64, - limit: u64, -) -> String { - let strings = locale::strings_for(language); - strings - .auto_push_skip_too_large_fmt - .replace("{name}", file_name) - .replace("{size}", &format_file_size(size)) - .replace("{limit}", &format_file_size(limit)) -} - -/// Notice sent when an upload/send call fails for a single file. -pub fn auto_push_failed_message(language: BotLanguage, file_name: &str, err: &str) -> String { - let strings = locale::strings_for(language); - strings - .auto_push_failed_fmt - .replace("{name}", file_name) - .replace("{err}", err) -} - -const REMOTE_CONNECT_PERSISTENCE_FILENAME: &str = "remote_connect_persistence.json"; -const LEGACY_BOT_PERSISTENCE_FILENAME: &str = "bot_connections.json"; - -pub fn bot_persistence_path() -> Option { - dirs::home_dir().map(|home| { - home.join(".bitfun") - .join(REMOTE_CONNECT_PERSISTENCE_FILENAME) - }) -} - -fn legacy_bot_persistence_path() -> Option { - dirs::home_dir().map(|home| home.join(".bitfun").join(LEGACY_BOT_PERSISTENCE_FILENAME)) -} - -pub fn load_bot_persistence() -> BotPersistenceData { - let Some(path) = bot_persistence_path() else { - return BotPersistenceData::default(); - }; - match std::fs::read_to_string(&path) { - Ok(data) => serde_json::from_str(&data).unwrap_or_default(), - Err(_) => { - let Some(legacy_path) = legacy_bot_persistence_path() else { - return BotPersistenceData::default(); - }; - match std::fs::read_to_string(&legacy_path) { - Ok(data) => serde_json::from_str(&data).unwrap_or_default(), - Err(_) => BotPersistenceData::default(), - } - } - } -} - -pub fn save_bot_persistence(data: &BotPersistenceData) { - let Some(path) = bot_persistence_path() else { - return; - }; - if let Some(parent) = path.parent() { - let _ = std::fs::create_dir_all(parent); - } - if let Ok(json) = serde_json::to_string_pretty(data) { - if let Err(e) = std::fs::write(&path, json) { - log::error!("Failed to save bot persistence: {e}"); - } - } -} - -#[cfg(test)] -mod tests { - use super::{collect_auto_push_files, extract_downloadable_file_paths, resolve_workspace_path}; - - fn make_temp_workspace() -> (std::path::PathBuf, std::path::PathBuf, std::path::PathBuf) { - let base = std::env::temp_dir().join(format!( - "bitfun-remote-connect-test-{}", - uuid::Uuid::new_v4() - )); - let workspace = base.join("workspace"); - let artifacts = workspace.join("artifacts"); - let report = artifacts.join("report.pptx"); - std::fs::create_dir_all(&artifacts).unwrap(); - std::fs::write(&report, b"ppt").unwrap(); - (base, workspace, report) - } - - #[test] - fn resolves_relative_paths_within_workspace_root() { - let (base, workspace, report) = make_temp_workspace(); - - let resolved = - resolve_workspace_path("computer://artifacts/report.pptx", Some(&workspace)).unwrap(); - - assert_eq!(resolved, std::fs::canonicalize(report).unwrap()); - let _ = std::fs::remove_dir_all(base); - } - - #[test] - fn rejects_relative_paths_that_escape_workspace_root() { - let (base, workspace, _report) = make_temp_workspace(); - let secret = base.join("secret.txt"); - std::fs::write(&secret, b"secret").unwrap(); - - let resolved = resolve_workspace_path("computer://../secret.txt", Some(&workspace)); - - assert!(resolved.is_none()); - let _ = std::fs::remove_dir_all(base); - } - - /// Regression: `[name.pptx](name.pptx)` style relative markdown links - /// emitted by the agent must be auto-pushed when the active workspace - /// (Pro mode `current_workspace` OR Assistant mode `current_assistant`) - /// is known. Previously only `current_workspace` was consulted, so - /// assistant-mode replies silently dropped attachments — see - /// `BotChatState::active_workspace_path` and the per-platform - /// `notify_files_ready` callers. - #[test] - fn collects_relative_pptx_link_against_assistant_workspace_root() { - let (base, workspace, _report) = make_temp_workspace(); - let pptx = workspace.join("apple-vision-pro-keynote-style.pptx"); - std::fs::write(&pptx, b"pptx-bytes").unwrap(); - - let text = "[apple-vision-pro-keynote-style.pptx](apple-vision-pro-keynote-style.pptx)"; - let files = collect_auto_push_files(text, Some(&workspace)); - - assert_eq!(files.len(), 1, "relative pptx link must be auto-pushed"); - assert_eq!(files[0].name, "apple-vision-pro-keynote-style.pptx"); - assert_eq!(files[0].size, b"pptx-bytes".len() as u64); - let _ = std::fs::remove_dir_all(base); - } - - #[test] - fn extracts_relative_computer_links_when_workspace_root_is_known() { - let (base, workspace, _report) = make_temp_workspace(); - let text = "Download [deck](computer://artifacts/report.pptx)"; - - let paths = extract_downloadable_file_paths(text, Some(&workspace)); - - assert_eq!(paths.len(), 1); - assert!(std::path::Path::new(&paths[0]).is_absolute()); - assert!(std::path::Path::new(&paths[0]) - .ends_with(std::path::Path::new("artifacts").join("report.pptx"))); - assert!(std::path::Path::new(&paths[0]).exists()); - let _ = std::fs::remove_dir_all(base); - } -} diff --git a/src/crates/services/services-integrations/Cargo.toml b/src/crates/services/services-integrations/Cargo.toml index cbc82fa1f1..412d5515ea 100644 --- a/src/crates/services/services-integrations/Cargo.toml +++ b/src/crates/services/services-integrations/Cargo.toml @@ -101,6 +101,7 @@ remote-connect = [ "base64", "bitfun-runtime-ports", "chrono", + "dirs", "futures", "hostname", "image", diff --git a/src/crates/services/services-integrations/src/remote_connect.rs b/src/crates/services/services-integrations/src/remote_connect.rs index 4dfe8a05bf..5942e14fb8 100644 --- a/src/crates/services/services-integrations/src/remote_connect.rs +++ b/src/crates/services/services-integrations/src/remote_connect.rs @@ -3,9 +3,11 @@ //! This module owns remote-connect wire assembly, runtime-port request //! construction, compatibility re-exports, and remote session tracker state. //! Pairing, encryption, QR generation, relay websocket lifecycle primitives, -//! and command routing live here. Product assembly and concrete runtime hosts -//! stay in `bitfun-core` until their ports are explicit. +//! wire command routing, and provider-neutral IM bot support live here. Product +//! assembly, concrete runtime hosts, and IM bot command routing that still needs +//! session/runtime hosts stay in `bitfun-core` until their ports are explicit. +pub mod bot; pub mod device; pub mod encryption; pub mod pairing; diff --git a/src/crates/services/services-integrations/src/remote_connect/bot/command.rs b/src/crates/services/services-integrations/src/remote_connect/bot/command.rs new file mode 100644 index 0000000000..9a79e61346 --- /dev/null +++ b/src/crates/services/services-integrations/src/remote_connect/bot/command.rs @@ -0,0 +1,196 @@ +use super::state::BotDisplayMode; +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum BotCommand { + /// Show welcome (unpaired) or main menu (paired). Triggered by + /// `/start`, `/menu`, `/m`, `菜单`, or `0` at the top level. + Menu, + /// Show settings sub-menu. + Settings, + /// Show help text. + Help, + /// Switch display mode. + SwitchMode(BotDisplayMode), + /// Toggle verbose execution-detail mode (persisted globally). + SetVerbose(bool), + /// Generic "switch" entry — picks workspace or assistant by mode. + SwitchContext, + /// Generic "new session" entry — picks the right session type by mode. + NewSession, + /// Specific session creators (kept as hidden aliases). + NewCodeSession, + NewCoworkSession, + NewClawSession, + /// Resume an existing session (workspace or assistant by mode). + ResumeSession, + /// Cancel currently running task. + CancelTask(Option), + /// Pairing code submitted before pairing. + PairingCode(String), + /// Numeric reply to a menu / pending action. + NumberSelection(usize), + /// Free-form chat message forwarded to the AI session. + ChatMessage(String), +} + +// ── Command parsing ──────────────────────────────────────────────── + +fn normalize_im_command_text(text: &str) -> String { + text.trim() + .chars() + .map(|c| match c { + '\u{FF10}'..='\u{FF19}' => { + char::from_u32(c as u32 - 0xFF10 + u32::from(b'0')).unwrap_or(c) + } + c => c, + }) + .collect() +} + +fn strip_numeric_reply_suffix(s: &str) -> &str { + s.trim_end_matches(|c: char| { + matches!( + c, + '.' | '。' | '、' | ',' | ',' | ':' | ':' | ';' | ';' | ')' | ')' | ']' | '】' + ) + }) + .trim() +} + +pub fn parse_command(text: &str) -> BotCommand { + let normalized = normalize_im_command_text(text); + let trimmed = normalized.trim(); + if let Some(rest) = trimmed.strip_prefix("/cancel_task") { + let arg = rest.trim(); + return if arg.is_empty() { + BotCommand::CancelTask(None) + } else { + BotCommand::CancelTask(Some(arg.to_string())) + }; + } + if let Some(rest) = trimmed.strip_prefix("/cancel") { + let arg = rest.trim(); + return if arg.is_empty() { + BotCommand::CancelTask(None) + } else { + BotCommand::CancelTask(Some(arg.to_string())) + }; + } + let lower = trimmed.to_ascii_lowercase(); + match lower.as_str() { + // Top-level navigation / settings. + "/start" | "/menu" | "/m" | "菜单" => return BotCommand::Menu, + "/settings" | "/s" | "设置" => return BotCommand::Settings, + "/help" | "/?" | "/h" | "帮助" | "?" => return BotCommand::Help, + + // Mode switches (visible). + "/expert" | "/pro" | "专业模式" => { + return BotCommand::SwitchMode(BotDisplayMode::Pro); + } + "/assistant" | "助理模式" => { + return BotCommand::SwitchMode(BotDisplayMode::Assistant); + } + + // Verbose toggles. + "/verbose" | "详细" => return BotCommand::SetVerbose(true), + "/concise" | "简洁" => return BotCommand::SetVerbose(false), + + // Generic switch (picks workspace or assistant by mode). + "/switch" | "切换" => return BotCommand::SwitchContext, + // Hidden aliases. + "/switch_workspace" | "切换工作区" => return BotCommand::SwitchContext, + "/switch_assistant" | "切换助理" => return BotCommand::SwitchContext, + + // Generic "new" picks the right session type by mode. + "/new" | "/n" | "新建" | "新建会话" | "新会话" => return BotCommand::NewSession, + // Hidden aliases / power users. + "/new_code_session" | "新建编码会话" => return BotCommand::NewCodeSession, + "/new_cowork_session" | "新建协作会话" => { + return BotCommand::NewCoworkSession; + } + "/new_claw_session" | "新建助理会话" => return BotCommand::NewClawSession, + + // Resume. + "/resume" | "/r" | "/resume_session" | "恢复" | "恢复会话" => { + return BotCommand::ResumeSession; + } + _ => {} + } + + if trimmed.len() == 6 && trimmed.chars().all(|c| c.is_ascii_digit()) { + return BotCommand::PairingCode(trimmed.to_string()); + } + + let num_token = strip_numeric_reply_suffix(trimmed); + if let Ok(n) = num_token.parse::() { + if n <= 99 { + // `0` is intentionally returned as `NumberSelection(0)` so context + // such as "next page" inside SelectSession can override the + // default "0 = back to menu" interpretation. See `handle_number`. + return BotCommand::NumberSelection(n); + } + } + BotCommand::ChatMessage(trimmed.to_string()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parses_core_navigation_aliases() { + assert!(matches!(parse_command("/menu"), BotCommand::Menu)); + assert!(matches!(parse_command("/settings"), BotCommand::Settings)); + assert!(matches!(parse_command("/help"), BotCommand::Help)); + assert!(matches!( + parse_command("/resume"), + BotCommand::ResumeSession + )); + } + + #[test] + fn parses_mode_verbose_and_cancel_commands() { + assert!(matches!( + parse_command("/pro"), + BotCommand::SwitchMode(BotDisplayMode::Pro) + )); + assert!(matches!( + parse_command("/assistant"), + BotCommand::SwitchMode(BotDisplayMode::Assistant) + )); + assert!(matches!( + parse_command("/verbose"), + BotCommand::SetVerbose(true) + )); + assert!(matches!( + parse_command("/concise"), + BotCommand::SetVerbose(false) + )); + assert!(matches!( + parse_command("/cancel"), + BotCommand::CancelTask(None) + )); + assert!(matches!( + parse_command("/cancel_task turn-1"), + BotCommand::CancelTask(Some(id)) if id == "turn-1" + )); + } + + #[test] + fn parses_pairing_numeric_and_chat_fallbacks() { + assert!(matches!( + parse_command("123456"), + BotCommand::PairingCode(code) if code == "123456" + )); + assert!(matches!( + parse_command("1"), + BotCommand::NumberSelection(1) + )); + assert!(matches!(parse_command("0"), BotCommand::NumberSelection(0))); + assert!(matches!( + parse_command("hello"), + BotCommand::ChatMessage(text) if text == "hello" + )); + } +} + +// ── Public welcome / help text (compat) ─────────────────────────── diff --git a/src/crates/services/services-integrations/src/remote_connect/bot/locale.rs b/src/crates/services/services-integrations/src/remote_connect/bot/locale.rs new file mode 100644 index 0000000000..744371ca44 --- /dev/null +++ b/src/crates/services/services-integrations/src/remote_connect/bot/locale.rs @@ -0,0 +1,591 @@ +//! Centralised IM-bot strings for the simplified bot UX. +//! +//! All user-facing IM bot strings live here so command routing, menu +//! rendering, and platform adapters can share a single source of truth. +//! New languages add one more `static BotStrings` and one match arm in +//! [`strings_for`]. + +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +pub enum BotLanguage { + #[serde(rename = "zh-CN")] + ZhCN, + #[serde(rename = "zh-TW")] + ZhTW, + #[serde(rename = "en-US")] + EnUS, +} + +impl BotLanguage { + pub fn is_chinese(self) -> bool { + matches!(self, Self::ZhCN | Self::ZhTW) + } +} + +/// Centralised string table consumed by command router, menu builder, and +/// platform adapters. Add new strings here, then translate in both +/// [`STRINGS_ZH`], [`STRINGS_ZH_TW`], and [`STRINGS_EN`]. +pub struct BotStrings { + // ── Onboarding ─────────────────────────────────────────────── + pub welcome: &'static str, + pub paired_success: &'static str, + pub need_pairing: &'static str, + pub invalid_pairing_code: &'static str, + pub bootstrap_workspace_unavailable: &'static str, + pub bootstrap_session_failed_prefix: &'static str, + pub bootstrap_ready: &'static str, + + // ── Mode / context labels ──────────────────────────────────── + pub mode_assistant: &'static str, + pub mode_expert: &'static str, + pub current_session_label: &'static str, + pub current_workspace_label: &'static str, + pub current_assistant_label: &'static str, + pub no_session: &'static str, + pub no_workspace: &'static str, + pub no_assistant: &'static str, + + // ── Main menu (one-line title) ─────────────────────────────── + pub main_title_assistant: &'static str, + pub main_title_expert: &'static str, + pub settings_title: &'static str, + pub welcome_title: &'static str, + pub need_session_title: &'static str, + + // ── Menu item labels (≤ 14 chars target) ───────────────────── + pub item_new_session: &'static str, + pub item_new_code_session: &'static str, + pub item_new_cowork_session: &'static str, + pub item_resume_session: &'static str, + pub item_switch_assistant: &'static str, + pub item_switch_workspace: &'static str, + pub item_settings: &'static str, + pub item_back: &'static str, + pub item_help: &'static str, + pub item_switch_to_expert: &'static str, + pub item_switch_to_assistant: &'static str, + pub item_verbose_on: &'static str, + pub item_verbose_off: &'static str, + pub item_cancel_task: &'static str, + pub item_confirm_switch: &'static str, + pub item_next_page: &'static str, + pub item_other: &'static str, + + // ── Auxiliary labels ───────────────────────────────────────── + pub question_title: &'static str, + pub verbose_label: &'static str, + pub workspace_session_count_fmt: &'static str, + + // ── Footer hints ───────────────────────────────────────────── + pub footer_reply_or_menu: &'static str, + pub footer_reply_workspace: &'static str, + pub footer_reply_assistant: &'static str, + pub footer_reply_session_or_next: &'static str, + pub footer_reply_session: &'static str, + pub footer_question_single: &'static str, + pub footer_question_multi: &'static str, + pub footer_question_custom: &'static str, + pub footer_processing_cancel_hint: &'static str, + + // ── Body / inline texts ────────────────────────────────────── + pub welcome_body: &'static str, + pub paired_body_intro: &'static str, + pub help_body: &'static str, + + pub switch_pick_workspace: &'static str, + pub switch_pick_assistant: &'static str, + pub switch_no_workspaces: &'static str, + pub switch_no_assistants: &'static str, + pub current_marker: &'static str, + + pub resume_no_sessions: &'static str, + pub resume_page_label: &'static str, + pub resume_msg_count_zero: &'static str, + pub resume_msg_count_one: &'static str, + pub resume_msg_count_many_fmt: &'static str, + pub resume_resumed_prefix: &'static str, + pub resume_last_dialog_header: &'static str, + pub resume_you_label: &'static str, + pub resume_continue_hint: &'static str, + pub resume_first_message_hint: &'static str, + + pub processing: &'static str, + pub queued: &'static str, + pub no_response: &'static str, + pub task_cancelled: &'static str, + pub task_cancel_requested: &'static str, + pub task_cancel_failed_prefix: &'static str, + pub task_no_active: &'static str, + pub timeout_one_hour: &'static str, + pub error_prefix: &'static str, + pub send_failed_prefix: &'static str, + + pub mode_switched_to_expert: &'static str, + pub mode_switched_to_assistant: &'static str, + pub mode_already_expert: &'static str, + pub mode_already_assistant: &'static str, + pub mode_confirm_switch_prefix: &'static str, + + pub verbose_enabled: &'static str, + pub verbose_disabled: &'static str, + pub verbose_status_on: &'static str, + pub verbose_status_off: &'static str, + + pub session_created_prefix: &'static str, + pub session_workspace_label: &'static str, + pub session_start_hint: &'static str, + pub session_create_failed_prefix: &'static str, + pub session_system_unavailable: &'static str, + pub workspace_service_unavailable: &'static str, + pub workspace_open_failed_prefix: &'static str, + pub assistant_create_failed_prefix: &'static str, + + pub pending_expired: &'static str, + pub pending_invalid_input: &'static str, + pub pending_invalid_after_retries: &'static str, + pub pending_back_hint: &'static str, + + pub answers_submitted: &'static str, + pub answers_submit_failed_prefix: &'static str, + pub question_invalid_state: &'static str, + pub question_custom_required: &'static str, + pub question_custom_for_other_prefix: &'static str, + + pub thinking_label: &'static str, + + pub auto_push_intro_one: &'static str, + pub auto_push_intro_many_fmt: &'static str, + pub auto_push_skip_too_large_fmt: &'static str, + pub auto_push_failed_fmt: &'static str, +} + +const STRINGS_ZH: BotStrings = BotStrings { + welcome: "\ +欢迎使用 BitFun。 + +请在 BitFun 桌面端打开 Remote Connect 面板,复制 6 位配对码并发送到这里完成连接。", + paired_success: "配对成功,BitFun 已连接。", + need_pairing: "尚未连接 BitFun 桌面端。请先发送 6 位配对码。", + invalid_pairing_code: "配对码无效或已过期,请到桌面端重新生成后再发送。", + bootstrap_workspace_unavailable: "工作区服务暂时不可用,请稍后再试。", + bootstrap_session_failed_prefix: "已进入助理模式,但创建会话失败:", + bootstrap_ready: "已为你新建助理会话,直接发送消息即可开始。", + + mode_assistant: "助理模式", + mode_expert: "专业模式", + current_session_label: "当前会话", + current_workspace_label: "当前工作区", + current_assistant_label: "当前助理", + no_session: "尚未选择会话", + no_workspace: "尚未选择工作区", + no_assistant: "尚未选择助理", + + main_title_assistant: "BitFun · 助理模式", + main_title_expert: "BitFun · 专业模式", + settings_title: "设置", + welcome_title: "BitFun", + need_session_title: "请先选择或新建会话", + + item_new_session: "新建会话", + item_new_code_session: "新建编码会话", + item_new_cowork_session: "新建协作会话", + item_resume_session: "恢复会话", + item_switch_assistant: "切换助理", + item_switch_workspace: "切换工作区", + item_settings: "设置", + item_back: "返回", + item_help: "帮助", + item_switch_to_expert: "切换到专业模式", + item_switch_to_assistant: "切换到助理模式", + item_verbose_on: "开启执行细节", + item_verbose_off: "关闭执行细节", + item_cancel_task: "取消任务", + item_confirm_switch: "切换并继续", + item_next_page: "下一页", + item_other: "其他", + + question_title: "问题", + verbose_label: "执行细节", + workspace_session_count_fmt: "{n} 个会话", + + footer_reply_or_menu: "回复编号,或发送 /menu 返回主菜单", + footer_reply_workspace: "回复工作区编号,或发送 0 返回", + footer_reply_assistant: "回复助理编号,或发送 0 返回", + footer_reply_session_or_next: "回复会话编号;发送 0 查看下一页或返回", + footer_reply_session: "回复会话编号,或发送 0 返回", + footer_question_single: "回复单个选项编号;发送 /menu 退出", + footer_question_multi: "回复一个或多个选项编号(如 1,3);发送 /menu 退出", + footer_question_custom: "请输入你的自定义答案;发送 /menu 退出", + footer_processing_cancel_hint: "如需中止,回复 /cancel 或点击「取消任务」", + + welcome_body: "当前未配对。", + paired_body_intro: "可以直接发送消息开始对话。", + help_body: "\ +常用命令: +/menu 返回主菜单 +/new 新建会话 +/resume 恢复历史会话 +/switch 切换助理或工作区 +/cancel 取消当前任务 +/expert /assistant 切换模式 +/verbose /concise 开关执行细节 +/help 显示本帮助", + + switch_pick_workspace: "请选择要切换的工作区:", + switch_pick_assistant: "请选择要切换的助理:", + switch_no_workspaces: "尚未发现工作区,请先在 BitFun 桌面端打开一个项目。", + switch_no_assistants: "尚未发现助理,请先在 BitFun 桌面端创建一个助理。", + current_marker: " · 当前", + + resume_no_sessions: "当前还没有会话,可以发送 /new 直接新建。", + resume_page_label: "会话历史", + resume_msg_count_zero: "无消息", + resume_msg_count_one: "1 条消息", + resume_msg_count_many_fmt: "{n} 条消息", + resume_resumed_prefix: "已恢复会话:", + resume_last_dialog_header: "— 最近一次对话 —", + resume_you_label: "你", + resume_continue_hint: "可以继续对话。", + resume_first_message_hint: "发送一条消息即可开始。", + + processing: "正在处理你的消息……", + queued: "消息已加入队列,等当前步骤结束会自动接续。", + no_response: "(无回复)", + task_cancelled: "任务已取消。", + task_cancel_requested: "已请求取消当前任务。", + task_cancel_failed_prefix: "取消任务失败:", + task_no_active: "当前没有正在运行的任务。", + timeout_one_hour: "等待响应超时(1 小时)。", + error_prefix: "错误:", + send_failed_prefix: "发送失败:", + + mode_switched_to_expert: "已切换到专业模式,可创建编码 / 协作会话。", + mode_switched_to_assistant: "已切换到助理模式,适合日常持续对话。", + mode_already_expert: "当前已在专业模式。", + mode_already_assistant: "当前已在助理模式。", + mode_confirm_switch_prefix: "该操作需要切换到另一种模式,确认继续吗?", + + verbose_enabled: "已开启「执行细节」,下一次任务会显示思考与工具过程。", + verbose_disabled: "已关闭「执行细节」,仅显示最终结果。", + verbose_status_on: "开", + verbose_status_off: "关", + + session_created_prefix: "已创建新会话:", + session_workspace_label: "工作区:", + session_start_hint: "可以发送消息开始对话。", + session_create_failed_prefix: "创建会话失败:", + session_system_unavailable: "BitFun 会话系统尚未就绪,请稍后再试。", + workspace_service_unavailable: "工作区服务暂时不可用。", + workspace_open_failed_prefix: "打开工作区失败:", + assistant_create_failed_prefix: "创建助理工作区失败:", + + pending_expired: "上一步已超时,已为你返回主菜单。", + pending_invalid_input: "输入无效,请按提示回复或发送 /menu 返回主菜单。", + pending_invalid_after_retries: "多次输入无效,已为你返回主菜单。", + pending_back_hint: "发送 0 或 /menu 返回主菜单。", + + answers_submitted: "答案已提交,等待助手继续……", + answers_submit_failed_prefix: "提交答案失败:", + question_invalid_state: "问题状态无效,请重新发起对话。", + question_custom_required: "自定义答案不能为空,请重新输入。", + question_custom_for_other_prefix: "请为「其他」输入你的自定义答案:", + + thinking_label: "思考中", + + auto_push_intro_one: "正在为你发送 1 个文件……", + auto_push_intro_many_fmt: "正在为你发送 {n} 个文件……", + auto_push_skip_too_large_fmt: "已跳过「{name}」:{size} 超过 {limit} 上限,请改用桌面端获取。", + auto_push_failed_fmt: "发送「{name}」失败:{err}", +}; + +const STRINGS_ZH_TW: BotStrings = BotStrings { + welcome: "\ +歡迎使用 BitFun。 + +請在 BitFun 桌面端打開 Remote Connect 面板,複製 6 位配對碼併發送到這裡完成連接。", + paired_success: "配對成功,BitFun 已連接。", + need_pairing: "尚未連接 BitFun 桌面端。請先發送 6 位配對碼。", + invalid_pairing_code: "配對碼無效或已過期,請到桌面端重新生成後再發送。", + bootstrap_workspace_unavailable: "工作區服務暫時不可用,請稍後再試。", + bootstrap_session_failed_prefix: "已進入助理模式,但創建會話失敗:", + bootstrap_ready: "已為你新建助理會話,直接發送消息即可開始。", + + mode_assistant: "助理模式", + mode_expert: "專業模式", + current_session_label: "當前會話", + current_workspace_label: "當前工作區", + current_assistant_label: "當前助理", + no_session: "尚未選擇會話", + no_workspace: "尚未選擇工作區", + no_assistant: "尚未選擇助理", + + main_title_assistant: "BitFun · 助理模式", + main_title_expert: "BitFun · 專業模式", + settings_title: "設置", + welcome_title: "BitFun", + need_session_title: "請先選擇或新建會話", + + item_new_session: "新建會話", + item_new_code_session: "新建編碼會話", + item_new_cowork_session: "新建協作會話", + item_resume_session: "恢復會話", + item_switch_assistant: "切換助理", + item_switch_workspace: "切換工作區", + item_settings: "設置", + item_back: "返回", + item_help: "幫助", + item_switch_to_expert: "切換到專業模式", + item_switch_to_assistant: "切換到助理模式", + item_verbose_on: "開啟執行細節", + item_verbose_off: "關閉執行細節", + item_cancel_task: "取消任務", + item_confirm_switch: "切換並繼續", + item_next_page: "下一頁", + item_other: "其他", + + question_title: "問題", + verbose_label: "執行細節", + workspace_session_count_fmt: "{n} 個會話", + + footer_reply_or_menu: "回覆編號,或發送 /menu 返回主菜單", + footer_reply_workspace: "回覆工作區編號,或發送 0 返回", + footer_reply_assistant: "回覆助理編號,或發送 0 返回", + footer_reply_session_or_next: "回覆會話編號;發送 0 查看下一頁或返回", + footer_reply_session: "回覆會話編號,或發送 0 返回", + footer_question_single: "回覆單個選項編號;發送 /menu 退出", + footer_question_multi: "回覆一個或多個選項編號(如 1,3);發送 /menu 退出", + footer_question_custom: "請輸入你的自定義答案;發送 /menu 退出", + footer_processing_cancel_hint: "如需中止,回覆 /cancel 或點擊「取消任務」", + + welcome_body: "當前未配對。", + paired_body_intro: "可以直接發送消息開始對話。", + help_body: "\ +常用命令: +/menu 返回主菜單 +/new 新建會話 +/resume 恢復歷史會話 +/switch 切換助理或工作區 +/cancel 取消當前任務 +/expert /assistant 切換模式 +/verbose /concise 開關執行細節 +/help 顯示本幫助", + + switch_pick_workspace: "請選擇要切換的工作區:", + switch_pick_assistant: "請選擇要切換的助理:", + switch_no_workspaces: "尚未發現工作區,請先在 BitFun 桌面端打開一個項目。", + switch_no_assistants: "尚未發現助理,請先在 BitFun 桌面端創建一個助理。", + current_marker: " · 當前", + + resume_no_sessions: "當前還沒有會話,可以發送 /new 直接新建。", + resume_page_label: "會話歷史", + resume_msg_count_zero: "無消息", + resume_msg_count_one: "1 條消息", + resume_msg_count_many_fmt: "{n} 條消息", + resume_resumed_prefix: "已恢復會話:", + resume_last_dialog_header: "— 最近一次對話 —", + resume_you_label: "你", + resume_continue_hint: "可以繼續對話。", + resume_first_message_hint: "發送一條消息即可開始。", + + processing: "正在處理你的消息……", + queued: "消息已加入隊列,等當前步驟結束會自動接續。", + no_response: "(無回覆)", + task_cancelled: "任務已取消。", + task_cancel_requested: "已請求取消當前任務。", + task_cancel_failed_prefix: "取消任務失敗:", + task_no_active: "當前沒有正在運行的任務。", + timeout_one_hour: "等待響應超時(1 小時)。", + error_prefix: "錯誤:", + send_failed_prefix: "發送失敗:", + + mode_switched_to_expert: "已切換到專業模式,可創建編碼 / 協作會話。", + mode_switched_to_assistant: "已切換到助理模式,適合日常持續對話。", + mode_already_expert: "當前已在專業模式。", + mode_already_assistant: "當前已在助理模式。", + mode_confirm_switch_prefix: "該操作需要切換到另一種模式,確認繼續嗎?", + + verbose_enabled: "已開啟「執行細節」,下一次任務會顯示思考與工具過程。", + verbose_disabled: "已關閉「執行細節」,僅顯示最終結果。", + verbose_status_on: "開", + verbose_status_off: "關", + + session_created_prefix: "已創建新會話:", + session_workspace_label: "工作區:", + session_start_hint: "可以發送消息開始對話。", + session_create_failed_prefix: "創建會話失敗:", + session_system_unavailable: "BitFun 會話系統尚未就緒,請稍後再試。", + workspace_service_unavailable: "工作區服務暫時不可用。", + workspace_open_failed_prefix: "打開工作區失敗:", + assistant_create_failed_prefix: "創建助理工作區失敗:", + + pending_expired: "上一步已超時,已為你返回主菜單。", + pending_invalid_input: "輸入無效,請按提示回覆或發送 /menu 返回主菜單。", + pending_invalid_after_retries: "多次輸入無效,已為你返回主菜單。", + pending_back_hint: "發送 0 或 /menu 返回主菜單。", + + answers_submitted: "答案已提交,等待助手繼續……", + answers_submit_failed_prefix: "提交答案失敗:", + question_invalid_state: "問題狀態無效,請重新發起對話。", + question_custom_required: "自定義答案不能為空,請重新輸入。", + question_custom_for_other_prefix: "請為「其他」輸入你的自定義答案:", + + thinking_label: "思考中", + + auto_push_intro_one: "正在為你發送 1 個文件……", + auto_push_intro_many_fmt: "正在為你發送 {n} 個文件……", + auto_push_skip_too_large_fmt: "已跳過「{name}」:{size} 超過 {limit} 上限,請改用桌面端獲取。", + auto_push_failed_fmt: "發送「{name}」失敗:{err}", +}; + +const STRINGS_EN: BotStrings = BotStrings { + welcome: "\ +Welcome to BitFun. + +Open Remote Connect in BitFun Desktop and send the 6-digit pairing code here to connect.", + paired_success: "Pairing successful. BitFun is now connected.", + need_pairing: "Not connected yet. Please send the 6-digit pairing code first.", + invalid_pairing_code: "Invalid or expired pairing code. Generate a new one in BitFun Desktop and try again.", + bootstrap_workspace_unavailable: "Workspace service is unavailable. Please try again shortly.", + bootstrap_session_failed_prefix: "Assistant mode is on but session creation failed: ", + bootstrap_ready: "A new assistant session is ready. Send a message to start.", + + mode_assistant: "Assistant Mode", + mode_expert: "Expert Mode", + current_session_label: "Current session", + current_workspace_label: "Current workspace", + current_assistant_label: "Current assistant", + no_session: "No session selected", + no_workspace: "No workspace selected", + no_assistant: "No assistant selected", + + main_title_assistant: "BitFun · Assistant", + main_title_expert: "BitFun · Expert", + settings_title: "Settings", + welcome_title: "BitFun", + need_session_title: "Pick or create a session first", + + item_new_session: "New Session", + item_new_code_session: "New Code Session", + item_new_cowork_session: "New Cowork Session", + item_resume_session: "Resume Session", + item_switch_assistant: "Switch Assistant", + item_switch_workspace: "Switch Workspace", + item_settings: "Settings", + item_back: "Back", + item_help: "Help", + item_switch_to_expert: "Switch to Expert Mode", + item_switch_to_assistant: "Switch to Assistant Mode", + item_verbose_on: "Show Execution Details", + item_verbose_off: "Hide Execution Details", + item_cancel_task: "Cancel Task", + item_confirm_switch: "Switch & Continue", + item_next_page: "Next Page", + item_other: "Other", + + question_title: "Question", + verbose_label: "Execution details", + workspace_session_count_fmt: "{n} sessions", + + footer_reply_or_menu: "Reply with a number, or send /menu to return.", + footer_reply_workspace: "Reply with a workspace number, or 0 to go back.", + footer_reply_assistant: "Reply with an assistant number, or 0 to go back.", + footer_reply_session_or_next: "Reply with a session number; send 0 for next page or to go back.", + footer_reply_session: "Reply with a session number, or 0 to go back.", + footer_question_single: "Reply with one option number; send /menu to exit.", + footer_question_multi: "Reply with one or more option numbers (e.g. 1,3); send /menu to exit.", + footer_question_custom: "Type your custom answer; send /menu to exit.", + footer_processing_cancel_hint: "To stop, reply /cancel or tap Cancel Task.", + + welcome_body: "Not paired yet.", + paired_body_intro: "Send a message to start the conversation.", + help_body: "\ +Common commands: +/menu Return to the main menu +/new Create a new session +/resume Resume an existing session +/switch Switch assistant or workspace +/cancel Cancel the current task +/expert /assistant Switch modes +/verbose /concise Toggle execution details +/help Show this help", + + switch_pick_workspace: "Pick a workspace to switch to:", + switch_pick_assistant: "Pick an assistant to switch to:", + switch_no_workspaces: "No workspaces found. Open a project in BitFun Desktop first.", + switch_no_assistants: "No assistants found. Create one in BitFun Desktop first.", + current_marker: " · current", + + resume_no_sessions: "No sessions yet. Send /new to create one.", + resume_page_label: "Sessions", + resume_msg_count_zero: "no messages", + resume_msg_count_one: "1 message", + resume_msg_count_many_fmt: "{n} messages", + resume_resumed_prefix: "Resumed session: ", + resume_last_dialog_header: "— Last conversation —", + resume_you_label: "You", + resume_continue_hint: "You can continue the conversation.", + resume_first_message_hint: "Send a message to start.", + + processing: "Processing your message…", + queued: "Message queued. It will run when the current step finishes.", + no_response: "(no response)", + task_cancelled: "Task cancelled.", + task_cancel_requested: "Cancellation requested.", + task_cancel_failed_prefix: "Failed to cancel: ", + task_no_active: "No active task to cancel.", + timeout_one_hour: "Response timed out after 1 hour.", + error_prefix: "Error: ", + send_failed_prefix: "Send failed: ", + + mode_switched_to_expert: "Switched to Expert mode. You can create code or cowork sessions.", + mode_switched_to_assistant: "Switched to Assistant mode. Best for ongoing conversations.", + mode_already_expert: "Already in Expert mode.", + mode_already_assistant: "Already in Assistant mode.", + mode_confirm_switch_prefix: "This action needs the other mode. Switch and continue?", + + verbose_enabled: "Execution details enabled. The next task will show thinking and tool steps.", + verbose_disabled: "Execution details disabled. Only final results will be shown.", + verbose_status_on: "on", + verbose_status_off: "off", + + session_created_prefix: "New session created: ", + session_workspace_label: "Workspace: ", + session_start_hint: "Send a message to start the conversation.", + session_create_failed_prefix: "Failed to create session: ", + session_system_unavailable: "BitFun session system is not ready yet.", + workspace_service_unavailable: "Workspace service unavailable.", + workspace_open_failed_prefix: "Failed to open workspace: ", + assistant_create_failed_prefix: "Failed to create assistant workspace: ", + + pending_expired: "Previous step expired. Returned to the main menu.", + pending_invalid_input: "Invalid input. Follow the prompt above, or send /menu to return.", + pending_invalid_after_retries: "Too many invalid replies. Returned to the main menu.", + pending_back_hint: "Send 0 or /menu to return to the main menu.", + + answers_submitted: "Answers submitted. Waiting for the assistant to continue…", + answers_submit_failed_prefix: "Failed to submit answers: ", + question_invalid_state: "Question state is invalid; please restart the conversation.", + question_custom_required: "Custom answer cannot be empty. Please type it again.", + question_custom_for_other_prefix: "Type your custom answer for `Other`: ", + + thinking_label: "Thinking", + + auto_push_intro_one: "Sending 1 file for you…", + auto_push_intro_many_fmt: "Sending {n} files for you…", + auto_push_skip_too_large_fmt: "Skipping \"{name}\": {size} exceeds the {limit} limit. Please grab it from BitFun Desktop instead.", + auto_push_failed_fmt: "Failed to send \"{name}\": {err}", +}; + +pub fn strings_for(language: BotLanguage) -> &'static BotStrings { + match language { + BotLanguage::ZhCN => &STRINGS_ZH, + BotLanguage::ZhTW => &STRINGS_ZH_TW, + BotLanguage::EnUS => &STRINGS_EN, + } +} + +/// Substitute `{n}` placeholder in formatted strings. +pub fn fmt_count(template: &str, n: usize) -> String { + template.replace("{n}", &n.to_string()) +} diff --git a/src/crates/services/services-integrations/src/remote_connect/bot/menu.rs b/src/crates/services/services-integrations/src/remote_connect/bot/menu.rs new file mode 100644 index 0000000000..07e8a75109 --- /dev/null +++ b/src/crates/services/services-integrations/src/remote_connect/bot/menu.rs @@ -0,0 +1,220 @@ +//! Unified menu model shared by all IM bot adapters. +//! +//! The command router builds a [`MenuView`] for every reply. Each platform +//! adapter renders the view to its native primitive: Telegram inline +//! keyboards, Feishu interactive cards, or WeChat numbered text lines. +//! +//! There is intentionally no per-platform menu state in this module — menu +//! semantics live in `command_router::dispatch_im_bot_command_inner`. + +use serde::{Deserialize, Serialize}; + +use super::locale::{strings_for, BotLanguage}; + +/// Visual style of a menu item. Adapters map this to their own primitive +/// (e.g. Telegram has no styling, Feishu uses a `type` field). +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum MenuItemStyle { + Primary, + Default, + Danger, +} + +/// One row in a [`MenuView`]. +#[derive(Debug, Clone)] +pub struct MenuItem { + /// Short, button-friendly label shown to the user. Should be ≤ 14 chars. + pub label: String, + /// Real command string the bot will execute when the item is selected. + /// For platforms with native buttons this becomes `callback_data`; for + /// WeChat it is mapped via [`MenuView::numeric_commands`] for `1` ~ `n` + /// numeric replies. + pub command: String, + pub style: MenuItemStyle, +} + +impl MenuItem { + pub fn primary(label: impl Into, command: impl Into) -> Self { + Self { + label: label.into(), + command: command.into(), + style: MenuItemStyle::Primary, + } + } + pub fn default(label: impl Into, command: impl Into) -> Self { + Self { + label: label.into(), + command: command.into(), + style: MenuItemStyle::Default, + } + } + pub fn danger(label: impl Into, command: impl Into) -> Self { + Self { + label: label.into(), + command: command.into(), + style: MenuItemStyle::Danger, + } + } +} + +/// Unified, platform-agnostic menu/reply view. +/// +/// Always provide a short `title` and at most 5 items. The body field is +/// optional and used for context like "Current session: …" or last dialog +/// playback. +#[derive(Debug, Clone, Default)] +pub struct MenuView { + /// One-line context header (≤ 30 chars target). + pub title: String, + /// Optional secondary body text. + pub body: Option, + pub items: Vec, + /// Optional footer hint shown below items (telegram/feishu silently + /// drop this; weixin shows it as the last text line). + pub footer_hint: Option, + /// Whether text-only renderers should append `items` as numbered lines. + /// Some selection prompts include richer numbered lines in `body` while + /// still keeping `items` for native buttons; appending both duplicates the + /// option list on plain-text platforms. + pub render_items_in_plain_text: bool, +} + +impl MenuView { + pub fn plain(title: impl Into) -> Self { + Self { + title: title.into(), + body: None, + items: Vec::new(), + footer_hint: None, + render_items_in_plain_text: true, + } + } + + pub fn with_body(mut self, body: impl Into) -> Self { + self.body = Some(body.into()); + self + } + + pub fn with_items(mut self, items: Vec) -> Self { + self.items = items; + self + } + + pub fn with_footer(mut self, hint: impl Into) -> Self { + self.footer_hint = Some(hint.into()); + self + } + + pub fn without_plain_text_items(mut self) -> Self { + self.render_items_in_plain_text = false; + self + } + + pub fn push_item(&mut self, item: MenuItem) { + self.items.push(item); + } + + /// Commands corresponding to numeric replies `1..=items.len()`. + pub fn numeric_commands(&self) -> Vec { + self.items.iter().map(|i| i.command.clone()).collect() + } + + /// Render the menu as plain text suitable for IM platforms without + /// native buttons (e.g. WeChat iLink). + pub fn render_plain_text(&self, language: BotLanguage) -> String { + let s = strings_for(language); + let mut out = String::new(); + if !self.title.is_empty() { + out.push_str(&self.title); + } + if let Some(body) = &self.body { + if !body.is_empty() { + if !out.is_empty() { + out.push_str("\n\n"); + } + out.push_str(body); + } + } + if self.render_items_in_plain_text && !self.items.is_empty() { + if !out.is_empty() { + out.push_str("\n\n"); + } + for (i, item) in self.items.iter().enumerate() { + if i > 0 { + out.push('\n'); + } + out.push_str(&format!("{} {}", i + 1, item.label)); + } + } + let hint = self + .footer_hint + .clone() + .filter(|h| !h.is_empty()) + .unwrap_or_else(|| { + if self.items.is_empty() { + String::new() + } else { + s.footer_reply_or_menu.to_string() + } + }); + if !hint.is_empty() { + if !out.is_empty() { + out.push_str("\n\n"); + } + out.push_str(&hint); + } + out + } + + /// Render the title and optional body as a single text block, used by + /// adapters with native buttons (Telegram / Feishu) where the items are + /// shown separately as buttons. + pub fn render_text_block(&self) -> String { + let mut out = String::new(); + if !self.title.is_empty() { + out.push_str(&self.title); + } + if let Some(body) = &self.body { + if !body.is_empty() { + if !out.is_empty() { + out.push_str("\n\n"); + } + out.push_str(body); + } + } + if let Some(hint) = &self.footer_hint { + if !hint.is_empty() { + if !out.is_empty() { + out.push_str("\n\n"); + } + out.push_str(hint); + } + } + if out.is_empty() { + // Telegram refuses empty messages; fall back to a single space. + " ".to_string() + } else { + out + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn numeric_commands_follow_item_order() { + let view = MenuView::plain("title").with_items(vec![ + MenuItem::primary("New", "/new"), + MenuItem::default("Resume", "/resume"), + ]); + + assert_eq!(view.numeric_commands(), vec!["/new", "/resume"]); + } + + #[test] + fn text_block_never_returns_empty_message() { + assert_eq!(MenuView::plain("").render_text_block(), " "); + } +} diff --git a/src/crates/services/services-integrations/src/remote_connect/bot/mod.rs b/src/crates/services/services-integrations/src/remote_connect/bot/mod.rs new file mode 100644 index 0000000000..e8f64365ee --- /dev/null +++ b/src/crates/services/services-integrations/src/remote_connect/bot/mod.rs @@ -0,0 +1,602 @@ +//! Provider-neutral bot support for Remote Connect. +//! +//! Platform adapters and command routing remain in product assembly until their +//! concrete session/runtime dependencies are reduced to service ports. + +mod command; +mod locale; +mod menu; +mod state; + +use serde::{Deserialize, Serialize}; + +pub use command::{parse_command, BotCommand}; +pub use locale::{fmt_count, strings_for, BotLanguage, BotStrings}; +pub use menu::{MenuItem, MenuItemStyle, MenuView}; +pub use state::{ + BotAction, BotActionStyle, BotChatState, BotDisplayMode, BotInteractionHandler, + BotInteractiveRequest, BotMessageSender, BotQuestion, BotQuestionOption, PendingAction, +}; + +/// Configuration for a bot-based connection. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(tag = "bot_type", rename_all = "snake_case")] +pub enum BotConfig { + Feishu { + app_id: String, + app_secret: String, + }, + Telegram { + bot_token: String, + }, + Weixin { + ilink_token: String, + base_url: String, + bot_account_id: String, + }, +} + +/// Pairing state for bot-based connections. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct BotPairingInfo { + pub pairing_code: String, + pub bot_type: String, + pub bot_link: String, + pub expires_at: i64, +} + +/// Persisted bot connection — saved to disk so reconnect survives restarts. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SavedBotConnection { + pub bot_type: String, + pub chat_id: String, + pub config: BotConfig, + pub chat_state: BotChatState, + pub connected_at: i64, +} + +/// Persisted remote-connect form values shown in the desktop dialog. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct RemoteConnectFormState { + pub custom_server_url: String, + pub telegram_bot_token: String, + pub feishu_app_id: String, + pub feishu_app_secret: String, + /// Weixin iLink credentials after QR login (optional until user links WeChat). + #[serde(default, skip_serializing_if = "String::is_empty")] + pub weixin_ilink_token: String, + #[serde(default, skip_serializing_if = "String::is_empty")] + pub weixin_base_url: String, + #[serde(default, skip_serializing_if = "String::is_empty")] + pub weixin_bot_account_id: String, +} + +/// All persisted bot connections (one per bot type at most). +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct BotPersistenceData { + #[serde(default)] + pub connections: Vec, + #[serde(default)] + pub form_state: RemoteConnectFormState, + /// Global verbose mode setting for all bot connections. + /// When true, the agent's intermediate thinking summaries (one short + /// `[Thinking] …` line per `ThinkingEnd`) are forwarded to the user. + /// Tool-call notifications are intentionally NOT sent even in verbose + /// mode — they were too noisy for IM channels (especially WeChat where + /// each line costs a `context_token` slot) without giving the user + /// information they could act on. + /// Defaults to `false` (concise mode). + #[serde(default)] + pub verbose_mode: bool, +} + +impl BotPersistenceData { + pub fn upsert(&mut self, conn: SavedBotConnection) { + self.connections.retain(|c| c.bot_type != conn.bot_type); + self.connections.push(conn); + } + + pub fn remove(&mut self, bot_type: &str) { + self.connections.retain(|c| c.bot_type != bot_type); + } + + pub fn get(&self, bot_type: &str) -> Option<&SavedBotConnection> { + self.connections.iter().find(|c| c.bot_type == bot_type) + } +} + +// ── Shared workspace-file utilities ──────────────────────────────── + +/// File content read from the local workspace, ready to be sent over any channel. +pub struct WorkspaceFileContent { + pub name: String, + pub bytes: Vec, + pub mime_type: &'static str, + pub size: u64, +} + +/// Resolve a raw path (with or without `computer://` / `file://` prefix) to an +/// absolute `PathBuf`. +/// +/// Absolute paths are passed through directly. Relative paths are resolved +/// against `workspace_root` when provided, and paths escaping that root are +/// rejected. +pub fn resolve_workspace_path( + raw: &str, + workspace_root: Option<&std::path::Path>, +) -> Option { + super::resolve_remote_workspace_path(raw, workspace_root) +} + +/// Return the best-effort MIME type for a file based on its extension. +pub fn detect_mime_type(path: &std::path::Path) -> &'static str { + super::detect_remote_mime_type(path) +} + +/// Read a workspace file, resolving `computer://` prefixes. +/// +/// `max_size` is the caller-specific byte limit (e.g. 50 MB for Telegram, +/// 30 MB for Feishu, 10 MB for mobile relay). +/// +/// Returns an error when the file is missing, is a directory, or exceeds +/// `max_size`. +pub async fn read_workspace_file( + raw_path: &str, + max_size: u64, + workspace_root: Option<&std::path::Path>, +) -> anyhow::Result { + let content = super::read_remote_workspace_file(raw_path, max_size, workspace_root) + .await + .map_err(anyhow::Error::msg)?; + + Ok(WorkspaceFileContent { + name: content.name, + bytes: content.bytes, + mime_type: content.mime_type, + size: content.size, + }) +} + +/// Get file metadata (name and size in bytes) without reading the full content. +/// Returns `None` if the path cannot be resolved, does not exist, or is not a +/// regular file. +pub fn get_file_metadata( + raw_path: &str, + workspace_root: Option<&std::path::Path>, +) -> Option<(String, u64)> { + let abs = resolve_workspace_path(raw_path, workspace_root)?; + if !abs.is_file() { + return None; + } + let name = abs + .file_name() + .and_then(|n| n.to_str()) + .unwrap_or("file") + .to_string(); + let size = std::fs::metadata(&abs).ok()?.len(); + Some((name, size)) +} + +/// Format a byte count as a human-readable string (e.g. "1.4 MB", "320 KB"). +pub fn format_file_size(bytes: u64) -> String { + if bytes >= 1024 * 1024 { + format!("{:.1} MB", bytes as f64 / (1024.0 * 1024.0)) + } else if bytes >= 1024 { + format!("{} KB", bytes / 1024) + } else { + format!("{bytes} B") + } +} + +// ── Downloadable file link extraction ────────────────────────────── + +/// Extensions that are source-code / config files — excluded from download +/// when referenced via absolute paths (matches mobile-web `CODE_FILE_EXTENSIONS`). +const CODE_FILE_EXTENSIONS: &[&str] = &[ + "js", + "jsx", + "ts", + "tsx", + "mjs", + "cjs", + "mts", + "cts", + "py", + "pyw", + "pyi", + "rs", + "go", + "java", + "kt", + "kts", + "scala", + "groovy", + "c", + "cpp", + "cc", + "cxx", + "h", + "hpp", + "hxx", + "hh", + "cs", + "rb", + "php", + "swift", + "vue", + "svelte", + "css", + "scss", + "less", + "sass", + "json", + "jsonc", + "yaml", + "yml", + "toml", + "xml", + "md", + "mdx", + "rst", + "txt", + "sh", + "bash", + "zsh", + "fish", + "ps1", + "bat", + "cmd", + "sql", + "graphql", + "gql", + "proto", + "lock", + "env", + "ini", + "cfg", + "conf", + "cj", + "ets", + "editorconfig", + "gitignore", + "log", +]; + +/// Extensions that should be treated as downloadable when referenced via +/// relative markdown links (matches mobile-web `DOWNLOADABLE_EXTENSIONS`). +const DOWNLOADABLE_EXTENSIONS: &[&str] = &[ + "pdf", "doc", "docx", "xls", "xlsx", "ppt", "pptx", "odt", "ods", "odp", "rtf", "pages", + "numbers", "key", "png", "jpg", "jpeg", "gif", "bmp", "svg", "webp", "ico", "tiff", "tif", + "zip", "tar", "gz", "bz2", "7z", "rar", "dmg", "iso", "xz", "mp3", "wav", "ogg", "flac", "aac", + "m4a", "wma", "mp4", "avi", "mkv", "mov", "webm", "wmv", "flv", "csv", "tsv", "sqlite", "db", + "parquet", "epub", "mobi", "apk", "ipa", "exe", "msi", "deb", "rpm", "ttf", "otf", "woff", + "woff2", +]; + +/// Check whether a bare file path (no protocol prefix) should be treated as +/// a downloadable file based on its extension. +/// +/// Absolute local file paths exclude source/config files. Relative links +/// are allowed when they point to known downloadable file types. +fn is_downloadable_by_extension(file_path: &str) -> bool { + let ext = std::path::Path::new(file_path) + .extension() + .and_then(|e| e.to_str()) + .unwrap_or("") + .to_lowercase(); + if ext.is_empty() { + return false; + } + let is_absolute = file_path.starts_with('/') + || (file_path.len() >= 3 && file_path.as_bytes().get(1) == Some(&b':')); + if is_absolute { + !CODE_FILE_EXTENSIONS.contains(&ext.as_str()) + } else { + DOWNLOADABLE_EXTENSIONS.contains(&ext.as_str()) + } +} + +/// Only file paths that can be resolved to existing files are returned. +/// Directories and missing paths are skipped. Duplicate paths are deduplicated +/// before returning. +pub fn extract_computer_file_paths( + text: &str, + workspace_root: Option<&std::path::Path>, +) -> Vec { + const PREFIX: &str = "computer://"; + let mut paths: Vec = Vec::new(); + let mut search = text; + + while let Some(idx) = search.find(PREFIX) { + let rest = &search[idx + PREFIX.len()..]; + let end = rest + .find(|c: char| c.is_whitespace() || matches!(c, '<' | '>' | '(' | ')' | '"' | '\'')) + .unwrap_or(rest.len()); + let raw_suffix = rest[..end].trim_end_matches(['.', ',', ';', ':', ')', ']']); + if !raw_suffix.is_empty() { + push_if_existing_file(&format!("{PREFIX}{raw_suffix}"), &mut paths, workspace_root); + } + search = &rest[end..]; + } + + paths +} + +/// Try to resolve `file_path` and, if it exists as a regular file, push +/// its absolute path into `out` (deduplicating). +fn push_if_existing_file( + file_path: &str, + out: &mut Vec, + workspace_root: Option<&std::path::Path>, +) { + if let Some(abs) = resolve_workspace_path(file_path, workspace_root) { + let abs_str = abs.to_string_lossy().into_owned(); + if abs.exists() && abs.is_file() && !out.contains(&abs_str) { + out.push(abs_str); + } + } +} + +/// Extract all downloadable file paths from agent response markdown text. +/// +/// Detects three kinds of references: +/// 1. `computer://` links in plain text. +/// 2. `file://` links in plain text. +/// 3. Markdown hyperlinks `[text](href)` pointing to absolute local files +/// (excluding code/config source files). +/// +/// Only paths that exist as regular files on disk are returned. +/// Duplicate paths are deduplicated. +pub fn extract_downloadable_file_paths( + text: &str, + workspace_root: Option<&std::path::Path>, +) -> Vec { + let mut paths: Vec = Vec::new(); + + // Phase 1 — protocol-prefixed links (`computer://` and `file://`). + for prefix in ["computer://", "file://"] { + let mut search = text; + while let Some(idx) = search.find(prefix) { + let rest = &search[idx + prefix.len()..]; + let end = rest + .find(|c: char| { + c.is_whitespace() || matches!(c, '<' | '>' | '(' | ')' | '"' | '\'') + }) + .unwrap_or(rest.len()); + let raw_suffix = rest[..end].trim_end_matches(['.', ',', ';', ':', ')', ']']); + if !raw_suffix.is_empty() { + let resolve_input = if prefix == "computer://" { + format!("{prefix}{raw_suffix}") + } else { + raw_suffix.to_string() + }; + push_if_existing_file(&resolve_input, &mut paths, workspace_root); + } + search = &rest[end..]; + } + } + + // Phase 2 — markdown hyperlinks `[text](href)` referencing local files. + let bytes = text.as_bytes(); + let len = bytes.len(); + let mut i = 0; + while i + 2 < len { + if bytes[i] == b']' && bytes[i + 1] == b'(' { + let href_start = i + 2; + if let Some(rel_end) = text[href_start..].find(')') { + let href = text[href_start..href_start + rel_end].trim(); + // Skip protocols already handled above and non-local URLs. + if !href.is_empty() + && !href.starts_with("computer://") + && !href.starts_with("file://") + && !href.starts_with("http://") + && !href.starts_with("https://") + && !href.starts_with("mailto:") + && !href.starts_with("tel:") + && !href.starts_with('#') + && !href.starts_with("//") + && is_downloadable_by_extension(href) + { + push_if_existing_file(href, &mut paths, workspace_root); + } + i = href_start + rel_end + 1; + } else { + i += 2; + } + } else { + i += 1; + } + } + + paths +} + +// ── Auto-push file delivery helpers ─────────────────────────────── + +/// One file to be auto-pushed to the IM peer alongside an agent reply. +#[derive(Debug, Clone)] +pub struct AutoPushFile { + /// Absolute path on the desktop (already resolved). + pub abs_path: String, + /// User-visible filename (basename of `abs_path`). + pub name: String, + /// Plaintext file size in bytes (for size-limit checks and UI). + pub size: u64, +} + +/// Scan an agent reply for downloadable file references and resolve their +/// metadata so each platform adapter can push them directly to the user +/// without an intermediate "tap to download" prompt. +pub fn collect_auto_push_files( + text: &str, + workspace_root: Option<&std::path::Path>, +) -> Vec { + extract_downloadable_file_paths(text, workspace_root) + .into_iter() + .filter_map(|path| { + get_file_metadata(&path, workspace_root).map(|(name, size)| AutoPushFile { + abs_path: path, + name, + size, + }) + }) + .collect() +} + +/// Caption sent once before the first auto-pushed file. +pub fn auto_push_intro(language: BotLanguage, count: usize) -> String { + let strings = locale::strings_for(language); + if count <= 1 { + strings.auto_push_intro_one.to_string() + } else { + locale::fmt_count(strings.auto_push_intro_many_fmt, count) + } +} + +/// Notice sent when a single file exceeds the platform's size limit and is skipped. +pub fn auto_push_skip_too_large_message( + language: BotLanguage, + file_name: &str, + size: u64, + limit: u64, +) -> String { + let strings = locale::strings_for(language); + strings + .auto_push_skip_too_large_fmt + .replace("{name}", file_name) + .replace("{size}", &format_file_size(size)) + .replace("{limit}", &format_file_size(limit)) +} + +/// Notice sent when an upload/send call fails for a single file. +pub fn auto_push_failed_message(language: BotLanguage, file_name: &str, err: &str) -> String { + let strings = locale::strings_for(language); + strings + .auto_push_failed_fmt + .replace("{name}", file_name) + .replace("{err}", err) +} + +const REMOTE_CONNECT_PERSISTENCE_FILENAME: &str = "remote_connect_persistence.json"; +const LEGACY_BOT_PERSISTENCE_FILENAME: &str = "bot_connections.json"; + +fn bot_persistence_path() -> Option { + dirs::home_dir().map(|home| { + home.join(".bitfun") + .join(REMOTE_CONNECT_PERSISTENCE_FILENAME) + }) +} + +fn legacy_bot_persistence_path() -> Option { + dirs::home_dir().map(|home| home.join(".bitfun").join(LEGACY_BOT_PERSISTENCE_FILENAME)) +} + +pub fn load_bot_persistence() -> BotPersistenceData { + let Some(path) = bot_persistence_path() else { + return BotPersistenceData::default(); + }; + match std::fs::read_to_string(&path) { + Ok(data) => serde_json::from_str(&data).unwrap_or_default(), + Err(_) => { + let Some(legacy_path) = legacy_bot_persistence_path() else { + return BotPersistenceData::default(); + }; + match std::fs::read_to_string(&legacy_path) { + Ok(data) => serde_json::from_str(&data).unwrap_or_default(), + Err(_) => BotPersistenceData::default(), + } + } + } +} + +pub fn save_bot_persistence(data: &BotPersistenceData) { + let Some(path) = bot_persistence_path() else { + return; + }; + if let Some(parent) = path.parent() { + let _ = std::fs::create_dir_all(parent); + } + if let Ok(json) = serde_json::to_string_pretty(data) { + if let Err(e) = std::fs::write(&path, json) { + log::error!("Failed to save bot persistence: {e}"); + } + } +} + +#[cfg(test)] +mod tests { + use super::{collect_auto_push_files, extract_downloadable_file_paths, resolve_workspace_path}; + + fn make_temp_workspace() -> (std::path::PathBuf, std::path::PathBuf, std::path::PathBuf) { + let base = std::env::temp_dir().join(format!( + "bitfun-remote-connect-test-{}", + uuid::Uuid::new_v4() + )); + let workspace = base.join("workspace"); + let artifacts = workspace.join("artifacts"); + let report = artifacts.join("report.pptx"); + std::fs::create_dir_all(&artifacts).unwrap(); + std::fs::write(&report, b"ppt").unwrap(); + (base, workspace, report) + } + + #[test] + fn resolves_relative_paths_within_workspace_root() { + let (base, workspace, report) = make_temp_workspace(); + + let resolved = + resolve_workspace_path("computer://artifacts/report.pptx", Some(&workspace)).unwrap(); + + assert_eq!(resolved, std::fs::canonicalize(report).unwrap()); + let _ = std::fs::remove_dir_all(base); + } + + #[test] + fn rejects_relative_paths_that_escape_workspace_root() { + let (base, workspace, _report) = make_temp_workspace(); + let secret = base.join("secret.txt"); + std::fs::write(&secret, b"secret").unwrap(); + + let resolved = resolve_workspace_path("computer://../secret.txt", Some(&workspace)); + + assert!(resolved.is_none()); + let _ = std::fs::remove_dir_all(base); + } + + /// Regression: `[name.pptx](name.pptx)` style relative markdown links + /// emitted by the agent must be auto-pushed when the active workspace + /// (Pro mode `current_workspace` OR Assistant mode `current_assistant`) + /// is known. Previously only `current_workspace` was consulted, so + /// assistant-mode replies silently dropped attachments — see + /// `BotChatState::active_workspace_path` and the per-platform + /// `notify_files_ready` callers. + #[test] + fn collects_relative_pptx_link_against_assistant_workspace_root() { + let (base, workspace, _report) = make_temp_workspace(); + let pptx = workspace.join("apple-vision-pro-keynote-style.pptx"); + std::fs::write(&pptx, b"pptx-bytes").unwrap(); + + let text = "[apple-vision-pro-keynote-style.pptx](apple-vision-pro-keynote-style.pptx)"; + let files = collect_auto_push_files(text, Some(&workspace)); + + assert_eq!(files.len(), 1, "relative pptx link must be auto-pushed"); + assert_eq!(files[0].name, "apple-vision-pro-keynote-style.pptx"); + assert_eq!(files[0].size, b"pptx-bytes".len() as u64); + let _ = std::fs::remove_dir_all(base); + } + + #[test] + fn extracts_relative_computer_links_when_workspace_root_is_known() { + let (base, workspace, _report) = make_temp_workspace(); + let text = "Download [deck](computer://artifacts/report.pptx)"; + + let paths = extract_downloadable_file_paths(text, Some(&workspace)); + + assert_eq!(paths.len(), 1); + assert!(std::path::Path::new(&paths[0]).is_absolute()); + assert!(std::path::Path::new(&paths[0]) + .ends_with(std::path::Path::new("artifacts").join("report.pptx"))); + assert!(std::path::Path::new(&paths[0]).exists()); + let _ = std::fs::remove_dir_all(base); + } +} diff --git a/src/crates/services/services-integrations/src/remote_connect/bot/state.rs b/src/crates/services/services-integrations/src/remote_connect/bot/state.rs new file mode 100644 index 0000000000..a7a36131eb --- /dev/null +++ b/src/crates/services/services-integrations/src/remote_connect/bot/state.rs @@ -0,0 +1,224 @@ +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use std::future::Future; +use std::pin::Pin; +use std::sync::Arc; + +use super::menu::{MenuItem, MenuItemStyle, MenuView}; + +const PENDING_TTL_SECS: i64 = 5 * 60; + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)] +pub enum BotDisplayMode { + #[serde(rename = "pro")] + Pro, + #[serde(rename = "assistant")] + #[default] + Assistant, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct BotChatState { + pub chat_id: String, + pub paired: bool, + pub current_workspace: Option, + pub current_assistant: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub current_assistant_name: Option, + pub current_session_id: Option, + #[serde(default)] + pub display_mode: BotDisplayMode, + #[serde(skip)] + pub pending_action: Option, + #[serde(skip)] + pub pending_expires_at: i64, + #[serde(skip)] + pub pending_invalid_count: u8, + #[serde(skip, default)] + pub last_menu_commands: Vec, +} + +impl BotChatState { + pub fn new(chat_id: String) -> Self { + Self { + chat_id, + paired: false, + current_workspace: None, + current_assistant: None, + current_assistant_name: None, + current_session_id: None, + display_mode: BotDisplayMode::Assistant, + pending_action: None, + pending_expires_at: 0, + pending_invalid_count: 0, + last_menu_commands: Vec::new(), + } + } + + pub fn active_workspace_path(&self) -> Option { + self.current_workspace + .clone() + .or_else(|| self.current_assistant.clone()) + } + + pub fn set_pending(&mut self, action: PendingAction) { + self.pending_action = Some(action); + self.pending_expires_at = now_secs() + PENDING_TTL_SECS; + self.pending_invalid_count = 0; + } + + pub fn clear_pending(&mut self) { + self.pending_action = None; + self.pending_expires_at = 0; + self.pending_invalid_count = 0; + } + + pub fn pending_expired(&self) -> bool { + self.pending_action.is_some() && now_secs() > self.pending_expires_at + } +} + +fn now_secs() -> i64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_secs() as i64) + .unwrap_or(0) +} + +#[derive(Debug, Clone)] +pub enum PendingAction { + SelectWorkspace { + options: Vec<(String, String)>, + }, + SelectAssistant { + options: Vec<(String, String)>, + }, + SelectSession { + options: Vec<(String, String)>, + page: usize, + has_more: bool, + }, + AskUserQuestion { + tool_id: String, + questions: Vec, + current_index: usize, + answers: Vec, + awaiting_custom_text: bool, + pending_answer: Option, + }, + ConfirmModeSwitch { + target_mode: BotDisplayMode, + target_cmd: String, + }, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct BotQuestionOption { + pub label: String, + #[serde(default)] + pub description: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct BotQuestion { + #[serde(default)] + pub question: String, + #[serde(default)] + pub header: String, + #[serde(default)] + pub options: Vec, + #[serde(rename = "multiSelect", default)] + pub multi_select: bool, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum BotActionStyle { + Primary, + Default, +} + +#[derive(Debug, Clone)] +pub struct BotAction { + pub label: String, + pub command: String, + pub style: BotActionStyle, +} + +impl BotAction { + pub fn primary(label: impl Into, command: impl Into) -> Self { + Self { + label: label.into(), + command: command.into(), + style: BotActionStyle::Primary, + } + } + + pub fn secondary(label: impl Into, command: impl Into) -> Self { + Self { + label: label.into(), + command: command.into(), + style: BotActionStyle::Default, + } + } +} + +impl From for BotAction { + fn from(item: MenuItem) -> Self { + let style = match item.style { + MenuItemStyle::Primary => BotActionStyle::Primary, + _ => BotActionStyle::Default, + }; + BotAction { + label: item.label, + command: item.command, + style, + } + } +} + +#[derive(Debug, Clone)] +pub struct BotInteractiveRequest { + pub reply: String, + pub actions: Vec, + pub menu: MenuView, + pub pending_action: PendingAction, +} + +pub type BotInteractionHandler = + Arc Pin + Send>> + Send + Sync>; + +pub type BotMessageSender = + Arc Pin + Send>> + Send + Sync>; + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn active_workspace_path_prefers_workspace_then_assistant() { + let mut state = BotChatState::new("chat".into()); + assert_eq!(state.active_workspace_path(), None); + + state.current_assistant = Some("/assistant".into()); + assert_eq!(state.active_workspace_path().as_deref(), Some("/assistant")); + + state.current_workspace = Some("/workspace".into()); + assert_eq!(state.active_workspace_path().as_deref(), Some("/workspace")); + } + + #[test] + fn pending_state_sets_expires_and_clear_resets_transient_fields() { + let mut state = BotChatState::new("chat".into()); + state.set_pending(PendingAction::SelectWorkspace { options: vec![] }); + assert!(state.pending_action.is_some()); + assert!(state.pending_expires_at > 0); + assert_eq!(state.pending_invalid_count, 0); + + state.pending_invalid_count = 2; + state.clear_pending(); + + assert!(state.pending_action.is_none()); + assert_eq!(state.pending_expires_at, 0); + assert_eq!(state.pending_invalid_count, 0); + } +}