abi-stable-host-api 0.1.1

ABI-stable FFI types for QimenBot dynamic plugins
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
//! ABI-stable types for the dynamic plugin FFI boundary.
//!
//! All structs are `#[repr(C)]` and use [`abi_stable::std_types`]
//! so they can be safely passed across `dlopen`-loaded library boundaries.
//!
//! ## API Version History
//!
//! - **0.1** — Initial version: single command per plugin, plain-text responses.
//! - **0.2** — Multi-command support via `RVec<CommandDescriptorEntry>`,
//!   rich-media responses via JSON segments, event context in requests.
//! - **0.3** — Extended `CommandRequest` with sender nickname, message ID, and
//!   timestamp. Added `ReplyBuilder` for fluent rich-media construction.
//!   Added `PluginInitConfig` / `PluginInitResult` lifecycle hooks.

use abi_stable::std_types::{RString, RVec};
use std::sync::Mutex;

/// Current plugin API version. Dynamic plugins must declare the same version
/// to be loaded by the host.
pub fn expected_api_version() -> RString {
    RString::from("0.3")
}

/// Also accept legacy 0.1 / 0.2 plugins for backward compatibility.
pub fn is_compatible_api_version(version: &str) -> bool {
    version == "0.1" || version == "0.2" || version == "0.3"
}

// ─── Action constants ───────────────────────────────────────────────────

pub const ACTION_IGNORE: i32 = 0;
pub const ACTION_REPLY: i32 = 1;
pub const ACTION_APPROVE: i32 = 2;
pub const ACTION_REJECT: i32 = 3;

// ─── Shared response ───────────────────────────────────────────────────

/// The action portion of every dynamic plugin response.
#[repr(C)]
#[derive(Clone)]
pub struct DynamicActionResponse {
    /// One of the `ACTION_*` constants.
    pub action_kind: i32,
    /// Plain text message (for backward compatibility or simple replies).
    pub message: RString,
    /// JSON-encoded array of message segments for rich-media responses.
    /// Example: `[{"type":"text","data":{"text":"hello"}},{"type":"face","data":{"id":"1"}}]`
    /// When non-empty, this takes precedence over `message`.
    pub segments_json: RString,
}

impl DynamicActionResponse {
    /// Create a simple text reply response.
    pub fn text_reply(text: &str) -> Self {
        Self {
            action_kind: ACTION_REPLY,
            message: RString::from(text),
            segments_json: RString::new(),
        }
    }

    /// Create a rich-media reply response with JSON segments.
    pub fn rich_reply(segments_json: &str) -> Self {
        Self {
            action_kind: ACTION_REPLY,
            message: RString::new(),
            segments_json: RString::from(segments_json),
        }
    }

    /// Create an ignore response.
    pub fn ignore() -> Self {
        Self {
            action_kind: ACTION_IGNORE,
            message: RString::new(),
            segments_json: RString::new(),
        }
    }

    /// Create an approve response (for friend/group requests).
    pub fn approve(remark: &str) -> Self {
        Self {
            action_kind: ACTION_APPROVE,
            message: RString::from(remark),
            segments_json: RString::new(),
        }
    }

    /// Create a reject response (for friend/group requests).
    pub fn reject(reason: &str) -> Self {
        Self {
            action_kind: ACTION_REJECT,
            message: RString::from(reason),
            segments_json: RString::new(),
        }
    }
}

// ─── Command FFI types ──────────────────────────────────────────────────

/// Request passed to command callback.
#[repr(C)]
pub struct CommandRequest {
    /// Command arguments joined by space (same as v0.1).
    pub args: RString,
    /// The command name that was matched.
    pub command_name: RString,
    /// Sender user ID.
    pub sender_id: RString,
    /// Group ID (empty if private chat).
    pub group_id: RString,
    /// Raw OneBot event JSON (for advanced use).
    pub raw_event_json: RString,

    // ── v0.3 fields ──
    /// Sender display name / nickname.
    pub sender_nickname: RString,
    /// Message ID (if applicable).
    pub message_id: RString,
    /// Unix timestamp of the event (seconds since epoch). 0 if unavailable.
    pub timestamp: i64,
}

/// Response from command callback.
#[repr(C)]
pub struct CommandResponse {
    pub action: DynamicActionResponse,
}

impl CommandResponse {
    /// Create a simple text reply.
    pub fn text(text: &str) -> Self {
        Self {
            action: DynamicActionResponse::text_reply(text),
        }
    }

    /// Create a reply builder for rich-media responses.
    pub fn builder() -> ReplyBuilder {
        ReplyBuilder::new()
    }

    /// Create an ignore response.
    pub fn ignore() -> Self {
        Self {
            action: DynamicActionResponse::ignore(),
        }
    }
}

/// Fluent builder for constructing rich-media command responses.
///
/// # Example
/// ```
/// use abi_stable_host_api::ReplyBuilder;
/// let response = ReplyBuilder::new()
///     .text("Hello, ")
///     .at("12345")
///     .face(1)
///     .text("!")
///     .build();
/// ```
pub struct ReplyBuilder {
    segments: Vec<String>,
}

impl ReplyBuilder {
    pub fn new() -> Self {
        Self {
            segments: Vec::new(),
        }
    }

    /// Add a text segment.
    pub fn text(mut self, text: &str) -> Self {
        let escaped = text
            .replace('\\', "\\\\")
            .replace('"', "\\\"")
            .replace('\n', "\\n")
            .replace('\r', "\\r")
            .replace('\t', "\\t");
        self.segments.push(format!(
            r#"{{"type":"text","data":{{"text":"{}"}}}}"#,
            escaped
        ));
        self
    }

    /// Add an @mention segment.
    pub fn at(mut self, user_id: &str) -> Self {
        self.segments.push(format!(
            r#"{{"type":"at","data":{{"qq":"{}"}}}}"#,
            user_id
        ));
        self
    }

    /// Add an @all mention.
    pub fn at_all(mut self) -> Self {
        self.segments
            .push(r#"{"type":"at","data":{"qq":"all"}}"#.to_string());
        self
    }

    /// Add a QQ face emoji segment.
    pub fn face(mut self, id: i32) -> Self {
        self.segments.push(format!(
            r#"{{"type":"face","data":{{"id":"{}"}}}}"#,
            id
        ));
        self
    }

    /// Add an image segment by URL.
    pub fn image_url(mut self, url: &str) -> Self {
        let escaped = url.replace('\\', "\\\\").replace('"', "\\\"");
        self.segments.push(format!(
            r#"{{"type":"image","data":{{"file":"{}"}}}}"#,
            escaped
        ));
        self
    }

    /// Add an image segment by base64 data.
    pub fn image_base64(mut self, base64: &str) -> Self {
        self.segments.push(format!(
            r#"{{"type":"image","data":{{"file":"base64://{}"}}}}"#,
            base64
        ));
        self
    }

    /// Add a record (voice) segment.
    pub fn record(mut self, file: &str) -> Self {
        let escaped = file.replace('\\', "\\\\").replace('"', "\\\"");
        self.segments.push(format!(
            r#"{{"type":"record","data":{{"file":"{}"}}}}"#,
            escaped
        ));
        self
    }

    /// Add a reply (quote) segment referencing a message ID.
    pub fn reply(mut self, message_id: &str) -> Self {
        self.segments.push(format!(
            r#"{{"type":"reply","data":{{"id":"{}"}}}}"#,
            message_id
        ));
        self
    }

    /// Build into a CommandResponse with rich-media content.
    pub fn build(self) -> CommandResponse {
        let json = format!("[{}]", self.segments.join(","));
        CommandResponse {
            action: DynamicActionResponse::rich_reply(&json),
        }
    }

    /// Build into a CommandResponse, but return only text if there's a single text segment.
    /// Falls back to rich_reply for multi-segment or non-text content.
    pub fn build_auto(self) -> CommandResponse {
        self.build()
    }
}

impl Default for ReplyBuilder {
    fn default() -> Self {
        Self::new()
    }
}

// ─── Interceptor FFI types ───────────────────────────────────────────────

/// Request passed to interceptor callbacks (pre_handle / after_completion).
#[repr(C)]
pub struct InterceptorRequest {
    /// Bot instance ID.
    pub bot_id: RString,
    /// Sender user ID.
    pub sender_id: RString,
    /// Group ID (empty if private chat).
    pub group_id: RString,
    /// Message plain text.
    pub message_text: RString,
    /// Full event JSON.
    pub raw_event_json: RString,
    /// Sender display name / nickname.
    pub sender_nickname: RString,
    /// Message ID (if applicable).
    pub message_id: RString,
    /// Unix timestamp of the event (seconds since epoch). 0 if unavailable.
    pub timestamp: i64,
}

/// Response from a `pre_handle` interceptor callback.
#[repr(C)]
pub struct InterceptorResponse {
    /// 1 = allow (pass through), 0 = block (stop processing).
    pub allow: i32,
}

impl InterceptorResponse {
    /// Allow the event to continue processing.
    pub fn allow() -> Self {
        Self { allow: 1 }
    }

    /// Block the event from further processing.
    pub fn block() -> Self {
        Self { allow: 0 }
    }
}

/// Describes an interceptor registered by a dynamic plugin.
#[repr(C)]
#[derive(Clone)]
pub struct InterceptorDescriptorEntry {
    /// Symbol name for the pre_handle callback. Empty = not registered.
    pub pre_handle_symbol: RString,
    /// Symbol name for the after_completion callback. Empty = not registered.
    pub after_completion_symbol: RString,
}

// ─── Notice FFI types ───────────────────────────────────────────────────

/// Request passed to notice/request/meta callbacks.
#[repr(C)]
pub struct NoticeRequest {
    /// Route name, e.g. "GroupPoke", "Friend", "Heartbeat".
    pub route: RString,
    /// Raw OneBot event JSON.
    pub raw_event_json: RString,
}

/// Response from notice/request/meta callbacks.
#[repr(C)]
pub struct NoticeResponse {
    pub action: DynamicActionResponse,
}

// ─── Command descriptor entry (v0.2) ───────────────────────────────────

/// Describes a single command registered by a dynamic plugin.
#[repr(C)]
#[derive(Clone)]
pub struct CommandDescriptorEntry {
    /// Command name (e.g. "hello").
    pub name: RString,
    /// Human-readable description.
    pub description: RString,
    /// Callback symbol name in the shared library (e.g. "my_plugin_handle_hello").
    pub callback_symbol: RString,
    /// Comma-separated aliases (e.g. "h,hi"). Empty if none.
    pub aliases: RString,
    /// Command category (e.g. "general"). Empty defaults to "dynamic".
    pub category: RString,
    /// Required role: "" or "anyone" = anyone, "admin" = admin, "owner" = owner.
    pub required_role: RString,
    /// Command scope: "" or "all" = all, "group" = group only, "private" = private only.
    pub scope: RString,
}

// ─── Route descriptor entry (v0.2) ─────────────────────────────────────

/// Describes a system event route registered by a dynamic plugin.
#[repr(C)]
#[derive(Clone)]
pub struct RouteDescriptorEntry {
    /// Route type: "notice", "request", or "meta".
    pub kind: RString,
    /// Route name, e.g. "GroupPoke", "Friend", "Heartbeat".
    /// Comma-separated for multiple routes (e.g. "GroupPoke,PrivatePoke").
    pub route: RString,
    /// Callback symbol name.
    pub callback_symbol: RString,
}

// ─── Plugin descriptor ──────────────────────────────────────────────────

/// Metadata returned by the `qimen_plugin_descriptor` FFI symbol.
///
/// v0.2: Supports multiple commands and multiple event routes.
#[repr(C)]
pub struct PluginDescriptor {
    pub plugin_id: RString,
    pub plugin_version: RString,
    pub api_version: RString,

    // ── v0.1 legacy fields (kept for backward compatibility) ──
    /// Single command name (v0.1). Ignored if `commands` is non-empty.
    pub command_name: RString,
    /// Single command description (v0.1). Ignored if `commands` is non-empty.
    pub command_description: RString,
    /// Single notice route (v0.1). Ignored if `routes` is non-empty.
    pub notice_route: RString,
    /// Single request route (v0.1).
    pub request_route: RString,
    /// Single meta route (v0.1).
    pub meta_route: RString,

    // ── v0.2 multi-command / multi-route fields ──
    /// Multiple command descriptors (v0.2). Takes precedence over legacy fields.
    pub commands: RVec<CommandDescriptorEntry>,
    /// Multiple route descriptors (v0.2). Takes precedence over legacy fields.
    pub routes: RVec<RouteDescriptorEntry>,
    /// Interceptor descriptors.
    pub interceptors: RVec<InterceptorDescriptorEntry>,
}

impl PluginDescriptor {
    /// Helper to create a v0.2 descriptor with the builder pattern.
    pub fn new(id: &str, version: &str) -> Self {
        Self {
            plugin_id: RString::from(id),
            plugin_version: RString::from(version),
            api_version: RString::from("0.3"),
            command_name: RString::new(),
            command_description: RString::new(),
            notice_route: RString::new(),
            request_route: RString::new(),
            meta_route: RString::new(),
            commands: RVec::new(),
            routes: RVec::new(),
            interceptors: RVec::new(),
        }
    }

    /// Add a command to this descriptor.
    pub fn add_command(
        mut self,
        name: &str,
        description: &str,
        callback_symbol: &str,
    ) -> Self {
        self.commands.push(CommandDescriptorEntry {
            name: RString::from(name),
            description: RString::from(description),
            callback_symbol: RString::from(callback_symbol),
            aliases: RString::new(),
            category: RString::new(),
            required_role: RString::new(),
            scope: RString::new(),
        });
        self
    }

    /// Add a command with full options.
    pub fn add_command_full(mut self, entry: CommandDescriptorEntry) -> Self {
        self.commands.push(entry);
        self
    }

    /// Add an interceptor entry.
    pub fn add_interceptor(
        mut self,
        pre_handle_symbol: &str,
        after_completion_symbol: &str,
    ) -> Self {
        self.interceptors.push(InterceptorDescriptorEntry {
            pre_handle_symbol: RString::from(pre_handle_symbol),
            after_completion_symbol: RString::from(after_completion_symbol),
        });
        self
    }

    /// Add a system event route.
    pub fn add_route(
        mut self,
        kind: &str,
        route: &str,
        callback_symbol: &str,
    ) -> Self {
        self.routes.push(RouteDescriptorEntry {
            kind: RString::from(kind),
            route: RString::from(route),
            callback_symbol: RString::from(callback_symbol),
        });
        self
    }
}

// ─── Plugin lifecycle hooks (v0.3) ──────────────────────────────────────

/// Configuration passed to plugin init hook.
#[repr(C)]
pub struct PluginInitConfig {
    /// Plugin ID (same as in descriptor).
    pub plugin_id: RString,
    /// Plugin-specific configuration as JSON string.
    /// Loaded from config/plugins/<plugin_id>.toml and serialized to JSON.
    /// Empty string if no config file exists.
    pub config_json: RString,
    /// The directory where the plugin binary resides.
    pub plugin_dir: RString,
    /// The bot's data directory root.
    pub data_dir: RString,
}

/// Result from plugin init hook.
#[repr(C)]
pub struct PluginInitResult {
    /// 0 = success, non-zero = failure.
    pub code: i32,
    /// Error message if code != 0. Empty on success.
    pub error_message: RString,
}

impl PluginInitResult {
    pub fn ok() -> Self {
        Self {
            code: 0,
            error_message: RString::new(),
        }
    }

    pub fn err(message: &str) -> Self {
        Self {
            code: 1,
            error_message: RString::from(message),
        }
    }
}

// ─── Send queue (BotApi) ─────────────────────────────────────────────────

/// An outbound send action queued by plugin code via `BotApi` / `SendBuilder`.
///
/// The host drains the queue after each FFI callback and executes the sends
/// asynchronously.
#[repr(C)]
#[derive(Clone)]
pub struct SendAction {
    /// `"private"` or `"group"`.
    pub message_type: RString,
    /// Target user_id (for private) or group_id (for group).
    pub target_id: RString,
    /// Plain text message body (used when `segments_json` is empty).
    pub message: RString,
    /// JSON-encoded rich-media segments (takes precedence over `message`).
    pub segments_json: RString,
}

static SEND_QUEUE: Mutex<Vec<SendAction>> = Mutex::new(Vec::new());

/// Drain all queued send actions. Called by the generated `qimen_plugin_flush_sends` symbol.
pub fn drain_send_queue() -> Vec<SendAction> {
    SEND_QUEUE
        .lock()
        .map(|mut q| q.drain(..).collect())
        .unwrap_or_default()
}

/// Provides static methods for plugins to queue outbound messages to arbitrary
/// users or groups. Messages are buffered in a process-local queue and flushed
/// by the host after the callback returns.
pub struct BotApi;

impl BotApi {
    /// Send a plain text message to a private chat.
    pub fn send_private_msg(user_id: &str, text: &str) {
        Self::push(SendAction {
            message_type: RString::from("private"),
            target_id: RString::from(user_id),
            message: RString::from(text),
            segments_json: RString::new(),
        });
    }

    /// Send a plain text message to a group chat.
    pub fn send_group_msg(group_id: &str, text: &str) {
        Self::push(SendAction {
            message_type: RString::from("group"),
            target_id: RString::from(group_id),
            message: RString::from(text),
            segments_json: RString::new(),
        });
    }

    /// Send rich-media (JSON segments) to a private chat.
    pub fn send_private_rich(user_id: &str, segments_json: &str) {
        Self::push(SendAction {
            message_type: RString::from("private"),
            target_id: RString::from(user_id),
            message: RString::new(),
            segments_json: RString::from(segments_json),
        });
    }

    /// Send rich-media (JSON segments) to a group chat.
    pub fn send_group_rich(group_id: &str, segments_json: &str) {
        Self::push(SendAction {
            message_type: RString::from("group"),
            target_id: RString::from(group_id),
            message: RString::new(),
            segments_json: RString::from(segments_json),
        });
    }

    fn push(action: SendAction) {
        if let Ok(mut q) = SEND_QUEUE.lock() {
            q.push(action);
        }
    }
}

/// Fluent builder for constructing and queuing a rich-media send to an
/// arbitrary target (group or private).
///
/// # Example
/// ```ignore
/// SendBuilder::group("123456")
///     .text("hello ")
///     .at("789")
///     .send();
/// ```
pub struct SendBuilder {
    message_type: String,
    target_id: String,
    segments: Vec<String>,
}

impl SendBuilder {
    /// Start building a message destined for a group.
    pub fn group(group_id: &str) -> Self {
        Self {
            message_type: "group".to_string(),
            target_id: group_id.to_string(),
            segments: Vec::new(),
        }
    }

    /// Start building a message destined for a private chat.
    pub fn private(user_id: &str) -> Self {
        Self {
            message_type: "private".to_string(),
            target_id: user_id.to_string(),
            segments: Vec::new(),
        }
    }

    /// Add a text segment.
    pub fn text(mut self, text: &str) -> Self {
        let escaped = text
            .replace('\\', "\\\\")
            .replace('"', "\\\"")
            .replace('\n', "\\n")
            .replace('\r', "\\r")
            .replace('\t', "\\t");
        self.segments.push(format!(
            r#"{{"type":"text","data":{{"text":"{}"}}}}"#,
            escaped
        ));
        self
    }

    /// Add an @mention segment.
    pub fn at(mut self, user_id: &str) -> Self {
        self.segments.push(format!(
            r#"{{"type":"at","data":{{"qq":"{}"}}}}"#,
            user_id
        ));
        self
    }

    /// Add an @all mention.
    pub fn at_all(mut self) -> Self {
        self.segments
            .push(r#"{"type":"at","data":{"qq":"all"}}"#.to_string());
        self
    }

    /// Add a QQ face emoji segment.
    pub fn face(mut self, id: i32) -> Self {
        self.segments.push(format!(
            r#"{{"type":"face","data":{{"id":"{}"}}}}"#,
            id
        ));
        self
    }

    /// Add an image segment by URL.
    pub fn image_url(mut self, url: &str) -> Self {
        let escaped = url.replace('\\', "\\\\").replace('"', "\\\"");
        self.segments.push(format!(
            r#"{{"type":"image","data":{{"file":"{}"}}}}"#,
            escaped
        ));
        self
    }

    /// Add an image segment by base64 data.
    pub fn image_base64(mut self, base64: &str) -> Self {
        self.segments.push(format!(
            r#"{{"type":"image","data":{{"file":"base64://{}"}}}}"#,
            base64
        ));
        self
    }

    /// Queue the built message for sending. The host will flush and send
    /// it after the current FFI callback returns.
    pub fn send(self) {
        let json = format!("[{}]", self.segments.join(","));
        BotApi::push(SendAction {
            message_type: RString::from(self.message_type),
            target_id: RString::from(self.target_id),
            message: RString::new(),
            segments_json: RString::from(json),
        });
    }
}