Skip to main content

abi_stable_host_api/
lib.rs

1//! ABI-stable types for the dynamic plugin FFI boundary.
2//!
3//! All structs are `#[repr(C)]` and use [`abi_stable::std_types`]
4//! so they can be safely passed across `dlopen`-loaded library boundaries.
5//!
6//! ## API Version History
7//!
8//! - **0.1** — Initial version: single command per plugin, plain-text responses.
9//! - **0.2** — Multi-command support via `RVec<CommandDescriptorEntry>`,
10//!   rich-media responses via JSON segments, event context in requests.
11//! - **0.3** — Extended `CommandRequest` with sender nickname, message ID, and
12//!   timestamp. Added `ReplyBuilder` for fluent rich-media construction.
13//!   Added `PluginInitConfig` / `PluginInitResult` lifecycle hooks.
14
15use abi_stable::std_types::{RString, RVec};
16use std::sync::Mutex;
17
18/// Current plugin API version. Dynamic plugins must declare the same version
19/// to be loaded by the host.
20pub fn expected_api_version() -> RString {
21    RString::from("0.3")
22}
23
24/// Also accept legacy 0.1 / 0.2 plugins for backward compatibility.
25pub fn is_compatible_api_version(version: &str) -> bool {
26    version == "0.1" || version == "0.2" || version == "0.3"
27}
28
29// ─── Action constants ───────────────────────────────────────────────────
30
31pub const ACTION_IGNORE: i32 = 0;
32pub const ACTION_REPLY: i32 = 1;
33pub const ACTION_APPROVE: i32 = 2;
34pub const ACTION_REJECT: i32 = 3;
35
36// ─── Shared response ───────────────────────────────────────────────────
37
38/// The action portion of every dynamic plugin response.
39#[repr(C)]
40#[derive(Clone)]
41pub struct DynamicActionResponse {
42    /// One of the `ACTION_*` constants.
43    pub action_kind: i32,
44    /// Plain text message (for backward compatibility or simple replies).
45    pub message: RString,
46    /// JSON-encoded array of message segments for rich-media responses.
47    /// Example: `[{"type":"text","data":{"text":"hello"}},{"type":"face","data":{"id":"1"}}]`
48    /// When non-empty, this takes precedence over `message`.
49    pub segments_json: RString,
50}
51
52impl DynamicActionResponse {
53    /// Create a simple text reply response.
54    pub fn text_reply(text: &str) -> Self {
55        Self {
56            action_kind: ACTION_REPLY,
57            message: RString::from(text),
58            segments_json: RString::new(),
59        }
60    }
61
62    /// Create a rich-media reply response with JSON segments.
63    pub fn rich_reply(segments_json: &str) -> Self {
64        Self {
65            action_kind: ACTION_REPLY,
66            message: RString::new(),
67            segments_json: RString::from(segments_json),
68        }
69    }
70
71    /// Create an ignore response.
72    pub fn ignore() -> Self {
73        Self {
74            action_kind: ACTION_IGNORE,
75            message: RString::new(),
76            segments_json: RString::new(),
77        }
78    }
79
80    /// Create an approve response (for friend/group requests).
81    pub fn approve(remark: &str) -> Self {
82        Self {
83            action_kind: ACTION_APPROVE,
84            message: RString::from(remark),
85            segments_json: RString::new(),
86        }
87    }
88
89    /// Create a reject response (for friend/group requests).
90    pub fn reject(reason: &str) -> Self {
91        Self {
92            action_kind: ACTION_REJECT,
93            message: RString::from(reason),
94            segments_json: RString::new(),
95        }
96    }
97}
98
99// ─── Command FFI types ──────────────────────────────────────────────────
100
101/// Request passed to command callback.
102#[repr(C)]
103pub struct CommandRequest {
104    /// Command arguments joined by space (same as v0.1).
105    pub args: RString,
106    /// The command name that was matched.
107    pub command_name: RString,
108    /// Sender user ID.
109    pub sender_id: RString,
110    /// Group ID (empty if private chat).
111    pub group_id: RString,
112    /// Raw OneBot event JSON (for advanced use).
113    pub raw_event_json: RString,
114
115    // ── v0.3 fields ──
116    /// Sender display name / nickname.
117    pub sender_nickname: RString,
118    /// Message ID (if applicable).
119    pub message_id: RString,
120    /// Unix timestamp of the event (seconds since epoch). 0 if unavailable.
121    pub timestamp: i64,
122}
123
124/// Response from command callback.
125#[repr(C)]
126pub struct CommandResponse {
127    pub action: DynamicActionResponse,
128}
129
130impl CommandResponse {
131    /// Create a simple text reply.
132    pub fn text(text: &str) -> Self {
133        Self {
134            action: DynamicActionResponse::text_reply(text),
135        }
136    }
137
138    /// Create a reply builder for rich-media responses.
139    pub fn builder() -> ReplyBuilder {
140        ReplyBuilder::new()
141    }
142
143    /// Create an ignore response.
144    pub fn ignore() -> Self {
145        Self {
146            action: DynamicActionResponse::ignore(),
147        }
148    }
149}
150
151/// Fluent builder for constructing rich-media command responses.
152///
153/// # Example
154/// ```
155/// use abi_stable_host_api::ReplyBuilder;
156/// let response = ReplyBuilder::new()
157///     .text("Hello, ")
158///     .at("12345")
159///     .face(1)
160///     .text("!")
161///     .build();
162/// ```
163pub struct ReplyBuilder {
164    segments: Vec<String>,
165}
166
167impl ReplyBuilder {
168    pub fn new() -> Self {
169        Self {
170            segments: Vec::new(),
171        }
172    }
173
174    /// Add a text segment.
175    pub fn text(mut self, text: &str) -> Self {
176        let escaped = text
177            .replace('\\', "\\\\")
178            .replace('"', "\\\"")
179            .replace('\n', "\\n")
180            .replace('\r', "\\r")
181            .replace('\t', "\\t");
182        self.segments.push(format!(
183            r#"{{"type":"text","data":{{"text":"{}"}}}}"#,
184            escaped
185        ));
186        self
187    }
188
189    /// Add an @mention segment.
190    pub fn at(mut self, user_id: &str) -> Self {
191        self.segments.push(format!(
192            r#"{{"type":"at","data":{{"qq":"{}"}}}}"#,
193            user_id
194        ));
195        self
196    }
197
198    /// Add an @all mention.
199    pub fn at_all(mut self) -> Self {
200        self.segments
201            .push(r#"{"type":"at","data":{"qq":"all"}}"#.to_string());
202        self
203    }
204
205    /// Add a QQ face emoji segment.
206    pub fn face(mut self, id: i32) -> Self {
207        self.segments.push(format!(
208            r#"{{"type":"face","data":{{"id":"{}"}}}}"#,
209            id
210        ));
211        self
212    }
213
214    /// Add an image segment by URL.
215    pub fn image_url(mut self, url: &str) -> Self {
216        let escaped = url.replace('\\', "\\\\").replace('"', "\\\"");
217        self.segments.push(format!(
218            r#"{{"type":"image","data":{{"file":"{}"}}}}"#,
219            escaped
220        ));
221        self
222    }
223
224    /// Add an image segment by base64 data.
225    pub fn image_base64(mut self, base64: &str) -> Self {
226        self.segments.push(format!(
227            r#"{{"type":"image","data":{{"file":"base64://{}"}}}}"#,
228            base64
229        ));
230        self
231    }
232
233    /// Add a record (voice) segment.
234    pub fn record(mut self, file: &str) -> Self {
235        let escaped = file.replace('\\', "\\\\").replace('"', "\\\"");
236        self.segments.push(format!(
237            r#"{{"type":"record","data":{{"file":"{}"}}}}"#,
238            escaped
239        ));
240        self
241    }
242
243    /// Add a reply (quote) segment referencing a message ID.
244    pub fn reply(mut self, message_id: &str) -> Self {
245        self.segments.push(format!(
246            r#"{{"type":"reply","data":{{"id":"{}"}}}}"#,
247            message_id
248        ));
249        self
250    }
251
252    /// Build into a CommandResponse with rich-media content.
253    pub fn build(self) -> CommandResponse {
254        let json = format!("[{}]", self.segments.join(","));
255        CommandResponse {
256            action: DynamicActionResponse::rich_reply(&json),
257        }
258    }
259
260    /// Build into a CommandResponse, but return only text if there's a single text segment.
261    /// Falls back to rich_reply for multi-segment or non-text content.
262    pub fn build_auto(self) -> CommandResponse {
263        self.build()
264    }
265}
266
267impl Default for ReplyBuilder {
268    fn default() -> Self {
269        Self::new()
270    }
271}
272
273// ─── Interceptor FFI types ───────────────────────────────────────────────
274
275/// Request passed to interceptor callbacks (pre_handle / after_completion).
276#[repr(C)]
277pub struct InterceptorRequest {
278    /// Bot instance ID.
279    pub bot_id: RString,
280    /// Sender user ID.
281    pub sender_id: RString,
282    /// Group ID (empty if private chat).
283    pub group_id: RString,
284    /// Message plain text.
285    pub message_text: RString,
286    /// Full event JSON.
287    pub raw_event_json: RString,
288    /// Sender display name / nickname.
289    pub sender_nickname: RString,
290    /// Message ID (if applicable).
291    pub message_id: RString,
292    /// Unix timestamp of the event (seconds since epoch). 0 if unavailable.
293    pub timestamp: i64,
294}
295
296/// Response from a `pre_handle` interceptor callback.
297#[repr(C)]
298pub struct InterceptorResponse {
299    /// 1 = allow (pass through), 0 = block (stop processing).
300    pub allow: i32,
301}
302
303impl InterceptorResponse {
304    /// Allow the event to continue processing.
305    pub fn allow() -> Self {
306        Self { allow: 1 }
307    }
308
309    /// Block the event from further processing.
310    pub fn block() -> Self {
311        Self { allow: 0 }
312    }
313}
314
315/// Describes an interceptor registered by a dynamic plugin.
316#[repr(C)]
317#[derive(Clone)]
318pub struct InterceptorDescriptorEntry {
319    /// Symbol name for the pre_handle callback. Empty = not registered.
320    pub pre_handle_symbol: RString,
321    /// Symbol name for the after_completion callback. Empty = not registered.
322    pub after_completion_symbol: RString,
323}
324
325// ─── Notice FFI types ───────────────────────────────────────────────────
326
327/// Request passed to notice/request/meta callbacks.
328#[repr(C)]
329pub struct NoticeRequest {
330    /// Route name, e.g. "GroupPoke", "Friend", "Heartbeat".
331    pub route: RString,
332    /// Raw OneBot event JSON.
333    pub raw_event_json: RString,
334}
335
336/// Response from notice/request/meta callbacks.
337#[repr(C)]
338pub struct NoticeResponse {
339    pub action: DynamicActionResponse,
340}
341
342// ─── Command descriptor entry (v0.2) ───────────────────────────────────
343
344/// Describes a single command registered by a dynamic plugin.
345#[repr(C)]
346#[derive(Clone)]
347pub struct CommandDescriptorEntry {
348    /// Command name (e.g. "hello").
349    pub name: RString,
350    /// Human-readable description.
351    pub description: RString,
352    /// Callback symbol name in the shared library (e.g. "my_plugin_handle_hello").
353    pub callback_symbol: RString,
354    /// Comma-separated aliases (e.g. "h,hi"). Empty if none.
355    pub aliases: RString,
356    /// Command category (e.g. "general"). Empty defaults to "dynamic".
357    pub category: RString,
358    /// Required role: "" or "anyone" = anyone, "admin" = admin, "owner" = owner.
359    pub required_role: RString,
360    /// Command scope: "" or "all" = all, "group" = group only, "private" = private only.
361    pub scope: RString,
362}
363
364// ─── Route descriptor entry (v0.2) ─────────────────────────────────────
365
366/// Describes a system event route registered by a dynamic plugin.
367#[repr(C)]
368#[derive(Clone)]
369pub struct RouteDescriptorEntry {
370    /// Route type: "notice", "request", or "meta".
371    pub kind: RString,
372    /// Route name, e.g. "GroupPoke", "Friend", "Heartbeat".
373    /// Comma-separated for multiple routes (e.g. "GroupPoke,PrivatePoke").
374    pub route: RString,
375    /// Callback symbol name.
376    pub callback_symbol: RString,
377}
378
379// ─── Plugin descriptor ──────────────────────────────────────────────────
380
381/// Metadata returned by the `qimen_plugin_descriptor` FFI symbol.
382///
383/// v0.2: Supports multiple commands and multiple event routes.
384#[repr(C)]
385pub struct PluginDescriptor {
386    pub plugin_id: RString,
387    pub plugin_version: RString,
388    pub api_version: RString,
389
390    // ── v0.1 legacy fields (kept for backward compatibility) ──
391    /// Single command name (v0.1). Ignored if `commands` is non-empty.
392    pub command_name: RString,
393    /// Single command description (v0.1). Ignored if `commands` is non-empty.
394    pub command_description: RString,
395    /// Single notice route (v0.1). Ignored if `routes` is non-empty.
396    pub notice_route: RString,
397    /// Single request route (v0.1).
398    pub request_route: RString,
399    /// Single meta route (v0.1).
400    pub meta_route: RString,
401
402    // ── v0.2 multi-command / multi-route fields ──
403    /// Multiple command descriptors (v0.2). Takes precedence over legacy fields.
404    pub commands: RVec<CommandDescriptorEntry>,
405    /// Multiple route descriptors (v0.2). Takes precedence over legacy fields.
406    pub routes: RVec<RouteDescriptorEntry>,
407    /// Interceptor descriptors.
408    pub interceptors: RVec<InterceptorDescriptorEntry>,
409}
410
411impl PluginDescriptor {
412    /// Helper to create a v0.2 descriptor with the builder pattern.
413    pub fn new(id: &str, version: &str) -> Self {
414        Self {
415            plugin_id: RString::from(id),
416            plugin_version: RString::from(version),
417            api_version: RString::from("0.3"),
418            command_name: RString::new(),
419            command_description: RString::new(),
420            notice_route: RString::new(),
421            request_route: RString::new(),
422            meta_route: RString::new(),
423            commands: RVec::new(),
424            routes: RVec::new(),
425            interceptors: RVec::new(),
426        }
427    }
428
429    /// Add a command to this descriptor.
430    pub fn add_command(
431        mut self,
432        name: &str,
433        description: &str,
434        callback_symbol: &str,
435    ) -> Self {
436        self.commands.push(CommandDescriptorEntry {
437            name: RString::from(name),
438            description: RString::from(description),
439            callback_symbol: RString::from(callback_symbol),
440            aliases: RString::new(),
441            category: RString::new(),
442            required_role: RString::new(),
443            scope: RString::new(),
444        });
445        self
446    }
447
448    /// Add a command with full options.
449    pub fn add_command_full(mut self, entry: CommandDescriptorEntry) -> Self {
450        self.commands.push(entry);
451        self
452    }
453
454    /// Add an interceptor entry.
455    pub fn add_interceptor(
456        mut self,
457        pre_handle_symbol: &str,
458        after_completion_symbol: &str,
459    ) -> Self {
460        self.interceptors.push(InterceptorDescriptorEntry {
461            pre_handle_symbol: RString::from(pre_handle_symbol),
462            after_completion_symbol: RString::from(after_completion_symbol),
463        });
464        self
465    }
466
467    /// Add a system event route.
468    pub fn add_route(
469        mut self,
470        kind: &str,
471        route: &str,
472        callback_symbol: &str,
473    ) -> Self {
474        self.routes.push(RouteDescriptorEntry {
475            kind: RString::from(kind),
476            route: RString::from(route),
477            callback_symbol: RString::from(callback_symbol),
478        });
479        self
480    }
481}
482
483// ─── Plugin lifecycle hooks (v0.3) ──────────────────────────────────────
484
485/// Configuration passed to plugin init hook.
486#[repr(C)]
487pub struct PluginInitConfig {
488    /// Plugin ID (same as in descriptor).
489    pub plugin_id: RString,
490    /// Plugin-specific configuration as JSON string.
491    /// Loaded from config/plugins/<plugin_id>.toml and serialized to JSON.
492    /// Empty string if no config file exists.
493    pub config_json: RString,
494    /// The directory where the plugin binary resides.
495    pub plugin_dir: RString,
496    /// The bot's data directory root.
497    pub data_dir: RString,
498}
499
500/// Result from plugin init hook.
501#[repr(C)]
502pub struct PluginInitResult {
503    /// 0 = success, non-zero = failure.
504    pub code: i32,
505    /// Error message if code != 0. Empty on success.
506    pub error_message: RString,
507}
508
509impl PluginInitResult {
510    pub fn ok() -> Self {
511        Self {
512            code: 0,
513            error_message: RString::new(),
514        }
515    }
516
517    pub fn err(message: &str) -> Self {
518        Self {
519            code: 1,
520            error_message: RString::from(message),
521        }
522    }
523}
524
525// ─── Send queue (BotApi) ─────────────────────────────────────────────────
526
527/// An outbound send action queued by plugin code via `BotApi` / `SendBuilder`.
528///
529/// The host drains the queue after each FFI callback and executes the sends
530/// asynchronously.
531#[repr(C)]
532#[derive(Clone)]
533pub struct SendAction {
534    /// `"private"` or `"group"`.
535    pub message_type: RString,
536    /// Target user_id (for private) or group_id (for group).
537    pub target_id: RString,
538    /// Plain text message body (used when `segments_json` is empty).
539    pub message: RString,
540    /// JSON-encoded rich-media segments (takes precedence over `message`).
541    pub segments_json: RString,
542}
543
544static SEND_QUEUE: Mutex<Vec<SendAction>> = Mutex::new(Vec::new());
545
546/// Drain all queued send actions. Called by the generated `qimen_plugin_flush_sends` symbol.
547pub fn drain_send_queue() -> Vec<SendAction> {
548    SEND_QUEUE
549        .lock()
550        .map(|mut q| q.drain(..).collect())
551        .unwrap_or_default()
552}
553
554/// Provides static methods for plugins to queue outbound messages to arbitrary
555/// users or groups. Messages are buffered in a process-local queue and flushed
556/// by the host after the callback returns.
557pub struct BotApi;
558
559impl BotApi {
560    /// Send a plain text message to a private chat.
561    pub fn send_private_msg(user_id: &str, text: &str) {
562        Self::push(SendAction {
563            message_type: RString::from("private"),
564            target_id: RString::from(user_id),
565            message: RString::from(text),
566            segments_json: RString::new(),
567        });
568    }
569
570    /// Send a plain text message to a group chat.
571    pub fn send_group_msg(group_id: &str, text: &str) {
572        Self::push(SendAction {
573            message_type: RString::from("group"),
574            target_id: RString::from(group_id),
575            message: RString::from(text),
576            segments_json: RString::new(),
577        });
578    }
579
580    /// Send rich-media (JSON segments) to a private chat.
581    pub fn send_private_rich(user_id: &str, segments_json: &str) {
582        Self::push(SendAction {
583            message_type: RString::from("private"),
584            target_id: RString::from(user_id),
585            message: RString::new(),
586            segments_json: RString::from(segments_json),
587        });
588    }
589
590    /// Send rich-media (JSON segments) to a group chat.
591    pub fn send_group_rich(group_id: &str, segments_json: &str) {
592        Self::push(SendAction {
593            message_type: RString::from("group"),
594            target_id: RString::from(group_id),
595            message: RString::new(),
596            segments_json: RString::from(segments_json),
597        });
598    }
599
600    fn push(action: SendAction) {
601        if let Ok(mut q) = SEND_QUEUE.lock() {
602            q.push(action);
603        }
604    }
605}
606
607/// Fluent builder for constructing and queuing a rich-media send to an
608/// arbitrary target (group or private).
609///
610/// # Example
611/// ```ignore
612/// SendBuilder::group("123456")
613///     .text("hello ")
614///     .at("789")
615///     .send();
616/// ```
617pub struct SendBuilder {
618    message_type: String,
619    target_id: String,
620    segments: Vec<String>,
621}
622
623impl SendBuilder {
624    /// Start building a message destined for a group.
625    pub fn group(group_id: &str) -> Self {
626        Self {
627            message_type: "group".to_string(),
628            target_id: group_id.to_string(),
629            segments: Vec::new(),
630        }
631    }
632
633    /// Start building a message destined for a private chat.
634    pub fn private(user_id: &str) -> Self {
635        Self {
636            message_type: "private".to_string(),
637            target_id: user_id.to_string(),
638            segments: Vec::new(),
639        }
640    }
641
642    /// Add a text segment.
643    pub fn text(mut self, text: &str) -> Self {
644        let escaped = text
645            .replace('\\', "\\\\")
646            .replace('"', "\\\"")
647            .replace('\n', "\\n")
648            .replace('\r', "\\r")
649            .replace('\t', "\\t");
650        self.segments.push(format!(
651            r#"{{"type":"text","data":{{"text":"{}"}}}}"#,
652            escaped
653        ));
654        self
655    }
656
657    /// Add an @mention segment.
658    pub fn at(mut self, user_id: &str) -> Self {
659        self.segments.push(format!(
660            r#"{{"type":"at","data":{{"qq":"{}"}}}}"#,
661            user_id
662        ));
663        self
664    }
665
666    /// Add an @all mention.
667    pub fn at_all(mut self) -> Self {
668        self.segments
669            .push(r#"{"type":"at","data":{"qq":"all"}}"#.to_string());
670        self
671    }
672
673    /// Add a QQ face emoji segment.
674    pub fn face(mut self, id: i32) -> Self {
675        self.segments.push(format!(
676            r#"{{"type":"face","data":{{"id":"{}"}}}}"#,
677            id
678        ));
679        self
680    }
681
682    /// Add an image segment by URL.
683    pub fn image_url(mut self, url: &str) -> Self {
684        let escaped = url.replace('\\', "\\\\").replace('"', "\\\"");
685        self.segments.push(format!(
686            r#"{{"type":"image","data":{{"file":"{}"}}}}"#,
687            escaped
688        ));
689        self
690    }
691
692    /// Add an image segment by base64 data.
693    pub fn image_base64(mut self, base64: &str) -> Self {
694        self.segments.push(format!(
695            r#"{{"type":"image","data":{{"file":"base64://{}"}}}}"#,
696            base64
697        ));
698        self
699    }
700
701    /// Queue the built message for sending. The host will flush and send
702    /// it after the current FFI callback returns.
703    pub fn send(self) {
704        let json = format!("[{}]", self.segments.join(","));
705        BotApi::push(SendAction {
706            message_type: RString::from(self.message_type),
707            target_id: RString::from(self.target_id),
708            message: RString::new(),
709            segments_json: RString::from(json),
710        });
711    }
712}