Skip to main content

robit_chatbot/
adapter.rs

1//! Platform adapter trait and supporting types.
2//!
3//! Platform crates (e.g. `robit-qq`) implement [`PlatformAdapter`] to bridge
4//! a chat platform's connection, message sending, and event receiving into
5//! the platform-agnostic `ChatbotManager`.
6
7// NOTE: Full implementation lands in Phase 3. This stub keeps the crate compiling.
8
9use async_trait::async_trait;
10use robit_agent::error::Result;
11
12/// Capabilities that vary by platform.
13#[derive(Debug, Clone)]
14pub struct PlatformCaps {
15    pub supports_edit: bool,
16    pub returns_msg_id: bool,
17    pub supports_markdown: bool,
18    pub markdown_features: MarkdownFeatures,
19    pub max_message_length: usize,
20    /// Whether the platform supports image upload and sending.
21    pub supports_images: bool,
22    /// Whether the platform supports file upload and sending.
23    pub supports_files: bool,
24    /// Maximum upload file size in bytes (0 = no limit).
25    pub max_upload_size: u64,
26}
27
28/// Supported Markdown features for a platform.
29#[derive(Debug, Clone, Default)]
30pub struct MarkdownFeatures {
31    pub headings: bool,
32    pub bold: bool,
33    pub italic: bool,
34    pub code_blocks: bool,
35    pub inline_code: bool,
36    pub links: bool,
37    pub unordered_lists: bool,
38    pub ordered_lists: bool,
39    pub blockquotes: bool,
40    pub tables: bool,
41    pub task_lists: bool,
42    pub images: bool,
43    pub strikethrough: bool,
44}
45
46impl MarkdownFeatures {
47    /// QQ Official Bot Markdown feature subset.
48    /// QQ Bot only supports very limited Markdown - use plain text fallback for most features.
49    pub fn qq() -> Self {
50        Self {
51            headings: false,      // Not supported
52            bold: false,          // Not supported
53            italic: false,        // Not supported
54            code_blocks: true,    // Supported
55            inline_code: false,   // Not supported
56            links: false,         // Not supported
57            unordered_lists: false,
58            ordered_lists: false,
59            blockquotes: false,
60            tables: false,
61            task_lists: false,
62            images: false,
63            strikethrough: false,
64        }
65    }
66
67    /// Feishu Markdown features (reserved for future use).
68    pub fn feishu() -> Self {
69        Self {
70            headings: true,
71            bold: true,
72            italic: true,
73            code_blocks: true,
74            inline_code: true,
75            links: true,
76            unordered_lists: true,
77            ordered_lists: true,
78            blockquotes: true,
79            tables: true,
80            task_lists: true,
81            images: true,
82            strikethrough: true,
83        }
84    }
85}
86
87impl PlatformCaps {
88    /// QQ Official Bot capabilities.
89    /// Note: QQ Bot has very limited Markdown support - we use the sanitizer to convert
90    /// Markdown to readable plain text with only minimal formatting preserved.
91    pub fn qq() -> Self {
92        Self {
93            supports_edit: true,
94            returns_msg_id: true,
95            supports_markdown: true, // Enable sanitizer
96            markdown_features: MarkdownFeatures::qq(),
97            max_message_length: 2000,
98            supports_images: true,
99            supports_files: true,
100            max_upload_size: 20 * 1024 * 1024, // 20MB
101        }
102    }
103
104    /// Feishu capabilities (reserved for future use).
105    pub fn feishu() -> Self {
106        Self {
107            supports_edit: true,
108            returns_msg_id: true,
109            supports_markdown: true,
110            markdown_features: MarkdownFeatures::feishu(),
111            max_message_length: 30000,
112            supports_images: true,
113            supports_files: true,
114            max_upload_size: 50 * 1024 * 1024, // 50MB
115        }
116    }
117}
118
119/// Result of sending a message.
120#[derive(Debug, Clone)]
121pub struct SendResult {
122    pub msg_id: String,
123}
124
125/// Result of uploading a file to the platform.
126#[derive(Debug, Clone)]
127pub struct UploadResult {
128    /// Platform-assigned file identifier (used for referencing in messages).
129    pub file_id: String,
130    /// Direct URL to the uploaded file.
131    pub url: String,
132}
133
134/// A media attachment (image, file, etc.) received from the platform.
135#[derive(Debug, Clone)]
136pub struct MediaAttachment {
137    /// MIME type: "image/jpeg", "image/png", "application/pdf", etc.
138    pub content_type: String,
139    /// Media URL (platform CDN download address).
140    pub url: String,
141    /// Original filename (if available).
142    pub filename: Option<String>,
143    /// File size in bytes (if available).
144    pub size: Option<u64>,
145    /// Image width in pixels (if available).
146    pub width: Option<u32>,
147    /// Image height in pixels (if available).
148    pub height: Option<u32>,
149}
150
151impl MediaAttachment {
152    /// Whether this attachment is an image.
153    pub fn is_image(&self) -> bool {
154        self.content_type.starts_with("image/")
155    }
156
157    /// A human-readable description for the LLM.
158    pub fn describe(&self) -> String {
159        let kind = if self.is_image() { "图片" } else { "文件" };
160        let name = self
161            .filename
162            .as_deref()
163            .unwrap_or_else(|| if self.is_image() { "image" } else { "file" });
164        let size_str = self
165            .size
166            .map(|s| format!(" ({:.1}KB)", s as f64 / 1024.0))
167            .unwrap_or_default();
168        format!("[用户发送了{}: {}{}]", kind, name, size_str)
169    }
170}
171
172// Convert to agent's MediaAttachment
173impl From<MediaAttachment> for robit_agent::event::MediaAttachment {
174    fn from(att: MediaAttachment) -> Self {
175        Self {
176            content_type: att.content_type,
177            url: att.url,
178            filename: att.filename,
179            size: att.size,
180            width: att.width,
181            height: att.height,
182        }
183    }
184}
185
186/// Sender information extracted from a platform event.
187#[derive(Debug, Clone)]
188pub struct SenderInfo {
189    pub user_id: String,
190    pub chat_id: String,
191    pub chat_type: ChatType,
192}
193
194#[derive(Debug, Clone, PartialEq, Eq)]
195pub enum ChatType {
196    Private,
197    Group,
198}
199
200/// A parsed chat message from the platform.
201#[derive(Debug, Clone)]
202pub struct ChatMessage {
203    pub text: String,
204    pub sender: SenderInfo,
205    /// Media attachments (images, files) included in the message.
206    pub attachments: Vec<MediaAttachment>,
207}
208
209/// Platform events that `ChatbotManager` processes.
210#[derive(Debug)]
211pub enum PlatformEvent {
212    Message(ChatMessage),
213    Disconnected,
214    Other(serde_json::Value),
215}
216
217/// The trait every chat platform must implement.
218///
219/// Connection lifecycle (WebSocket/HTTP setup, auth, background tasks) is the
220/// platform crate's responsibility — it constructs a connected adapter and
221/// hands it to [`ChatbotManager`](crate::manager::ChatbotManager) as an
222/// `Arc<Self>`. This avoids the tension between a `connect() -> Self` trait
223/// method and adapters that spawn background tasks holding `Arc<Self>`.
224#[async_trait]
225pub trait PlatformAdapter: Send + Sync + 'static {
226    /// Platform capabilities. Used by `ChatbotFrontend` for streaming strategy
227    /// and by the Markdown sanitizer.
228    fn capabilities() -> PlatformCaps;
229
230    /// Send a text message to a chat. Returns the platform message ID.
231    async fn send_message(&self, chat_id: &str, text: &str) -> Result<SendResult>;
232
233    /// Edit a previously-sent message. Default implementation falls back
234    /// to `send_message` for platforms that don't support editing.
235    async fn edit_message(&self, chat_id: &str, _msg_id: &str, text: &str) -> Result<()> {
236        let _ = self.send_message(chat_id, text).await;
237        Ok(())
238    }
239
240    /// Upload a file to the platform. Returns the platform file URL/ID
241    /// that can be referenced in subsequent messages.
242    ///
243    /// `file_path` is a local filesystem path to the file.
244    /// `media_type` is a hint: "image" or "file".
245    ///
246    /// The default implementation returns an error — platforms that don't
247    /// support file uploads can keep the default.
248    async fn upload_file(
249        &self,
250        _chat_id: &str,
251        _file_path: &str,
252        _media_type: &str,
253    ) -> Result<UploadResult> {
254        Err(robit_agent::error::AgentError::InternalError(
255            "File upload not supported on this platform".into(),
256        ))
257    }
258
259    /// Send a media message (image/file) to a chat. Default implementation
260    /// falls back to `send_message` with the URL as text.
261    async fn send_media_message(
262        &self,
263        chat_id: &str,
264        file_url: &str,
265        file_name: &str,
266        media_type: &str,
267    ) -> Result<SendResult> {
268        let _ = file_url;
269        let label = if media_type == "image" { "📷" } else { "📎" };
270        let text = format!("{} {}", label, file_name);
271        self.send_message(chat_id, &text).await
272    }
273
274    /// Receive the next platform event. Blocks until an event arrives.
275    async fn recv_event(&self) -> Result<PlatformEvent>;
276}