注意:目前 SDK 的开发重心集中在 Rust 部分,C++ 和 Python SDK 暂未同步更新。
本项目作为一个非盈利项目,如果你有兴趣参与 C++ 或 Python SDK 的维护与开发,亦或者参与luo9_bot本身的开发,欢迎通过邮件联系:luoy-oss@qq.com
已完成 FFI 消息总线架构,插件通过 luo9_core 的 extern "C" 函数进行进程内 pub/sub 通信
已完成多语言 SDK(Rust / C++ / Python),均封装同一份 luo9_core FFI
随着rust的学习推进,发现旧版本的rust实现较为劣质,现阶段正在进行重构
本项目不作为核心项目开发,项目的迭代时间并不确定
请不要使用以下sdk,当前页面仅作保留,后续将继续使用该页面进行sdk更新跟进
架构概述
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16
| ┌─────────────────────────────────────────────┐ │ 宿主 (luo9_bot, Rust) │ │ ├── WebSocket 连接 (Napcat) │ │ ├── 事件路由 (message/event/notice) │ │ ├── 插件加载器 (DLL/SO) │ │ ├── 优先级分发 + 热重载 │ │ └── Cron 调度器 │ └─────────────────┬───────────────────────────┘ │ FFI 消息总线 (luo9_core) │ ┌─────────────────┴───────────────────────────┐ │ 插件 (DLL/SO, 任意语言) │ │ ├── 独立 OS 线程运行 │ │ ├── 通过 Bus 订阅/发布消息 │ │ └── 通过 Bot 发送消息 │ └─────────────────────────────────────────────┘
|
总线 Topic
| Topic |
方向 |
用途 |
luo9_message |
宿主 → 插件 |
QQ 消息(私聊/群聊) |
luo9_meta_event |
宿主 → 插件 |
元事件(心跳/生命周期) |
luo9_notice |
宿主 → 插件 |
通知事件(好友/群变动等) |
luo9_task |
宿主 → 插件 |
定时任务触发事件 |
luo9_task_miso |
插件 → 宿主 |
定时任务注册/取消请求 |
luo9_send |
插件 → 宿主 |
消息发送请求 |
luo9_version |
双向 |
版本查询/响应 |
热重载支持
宿主在加载插件时预创建 subscriber,通过 luo9_init_subscribers FFI 将 ID 传递给 SDK。插件调用 subscribe() 时,SDK 内部透明返回预分配的 ID——插件代码无需任何修改。
禁用插件时,宿主调用 unsubscribe() 将 subscriber 标记为 dead,SDK 的 wait_pop() 会识别哨兵消息 __luo9_unsubscribed__ 并返回 Err(BusError::Unsubscribed),插件循环自然退出,线程结束,动态库被卸载。
消息总线(Bus)
Bus 是 SDK 的核心,封装了 luo9_core 的 FFI 消息总线。
基本用法
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21
| use luo9_sdk::bus::Bus;
let sub_id = Bus::topic("luo9_message").subscribe()?;
if let Some(json) = Bus::topic("luo9_message").pop(sub_id) { }
let json = Bus::topic("luo9_message").wait_pop(sub_id)?;
Bus::topic("luo9_send").publish(r#"{"action":"send_group_msg","group_id":123,"message":"hello"}"#)?;
Bus::topic("luo9_message").publish_to(&payload, &[0, 2])?;
Bus::topic("luo9_message").unsubscribe(sub_id)?;
|
BusError
1 2 3 4 5 6 7 8 9 10
| pub enum BusError { InitFailed, PublishFailed, SubscribeFailed, WaitPopFailed, NotInitialized, InvalidString, UnsubscribeFailed, Unsubscribed, }
|
pop() 在 subscriber 被取消订阅后返回 None(哨兵被视为无消息);wait_pop() 在 subscriber 被取消订阅后返回 Err(BusError::Unsubscribed),插件可据此退出循环。
API 速查
| 方法 |
返回值 |
说明 |
Bus::init() |
Result<(), BusError> |
初始化总线 |
Bus::topic(name) |
Topic |
获取 topic 句柄 |
topic.subscribe() |
Result<usize, BusError> |
订阅,返回 subscriber_id |
topic.unsubscribe(id) |
Result<(), BusError> |
取消订阅 |
topic.publish(payload) |
Result<(), BusError> |
广播消息 |
topic.publish_to(payload, ids) |
Result<(), BusError> |
定向推送 |
topic.pop(id) |
Option<String> |
非阻塞取消息 |
topic.wait_pop(id) |
Result<String, BusError> |
阻塞取消息 |
topic.publish_fmt(payload) |
Result<(), BusError> |
发布(自动 to_string) |
指令解析(Command)
Command 提供高效的指令前缀解析,支持链式匹配和模式匹配。
前缀模式
| 模式 |
说明 |
示例 |
Required(char) |
必须有指定前缀才解析成功 |
/echo hello |
Optional(char) |
前缀可选 |
echo hello 或 /echo hello |
None |
不检查前缀,直接解析 |
echo hello |
基本用法
1 2 3 4 5
| use luo9_sdk::command::{Command, PrefixMode};
if let Some(cmd) = Command::parse(msg, "echo", PrefixMode::Required('/')) { Bot::send_group_msg(group_id, CString::new(cmd.args().join(" ")).unwrap()); }
|
链式匹配
cmd.on() 匹配第一个参数(子指令),匹配成功时执行闭包并传入剩余参数。可链式调用多个 .on():
1 2 3 4 5 6 7 8
| if let Some(cmd) = Command::parse(msg, "task", PrefixMode::Required('/')) { cmd.on("start", |args| { }) .on("end", |args| { }); }
|
默认处理器(handle)
cmd.handle() 在命令无参数时执行闭包,返回 CommandMatcher 可继续链式调用 .on()。适合”有参数走子命令,无参数走默认”的场景:
1 2 3 4 5 6 7 8 9 10 11 12
| if let Some(cmd) = Command::parse(msg, "epic", PrefixMode::None) { cmd.handle(|| { epic_free_games(group_id); }) .on("提醒开启", |_| { task_start(group_id); }) .on("提醒关闭", |_| { task_end(group_id); }); }
|
otherwise 兜底
.otherwise() 在所有 .on() 都未匹配时执行,适合兜底逻辑:
1 2 3 4 5 6 7 8 9
| if let Some(cmd) = Command::parse(msg, "doro", PrefixMode::None) { cmd.on("add", |args| { }) .on("remove", |args| { }) .on("list", |_| { }) .otherwise(|| { send_today(group_id, &uid); }); }
|
模式匹配(on_pattern)
on_pattern 支持带命名捕获的模板,用 {name} 标记捕获组。适合解析 CQ Code 等结构化消息:
1 2 3 4 5
| cmd.on_pattern("[CQ:at,qq={qq}]{content}", |caps, _args| { let qq = caps.get("qq").unwrap(); let content = caps.get("content").unwrap(); });
|
API 速查
| 方法 |
返回值 |
说明 |
Command::parse(msg, name, mode) |
Option<Command> |
解析指令 |
cmd.name() |
&str |
指令名称 |
cmd.args() |
&[String] |
参数列表 |
cmd.arg_at(n) |
Option<&str> |
第 n 个参数 |
cmd.args_from(start) |
&[String] |
从第 start 个开始的参数 |
cmd.args_raw() |
String |
原始参数字符串 |
cmd.has_args() |
bool |
是否有参数 |
cmd.args_count() |
usize |
参数个数 |
cmd.handle(f) |
CommandMatcher |
无参数时执行 f |
cmd.on(expected, f) |
CommandMatcher |
子指令匹配 |
cmd.on_pattern(pattern, f) |
CommandMatcher |
模式匹配 |
matcher.on(expected, f) |
CommandMatcher |
链式子指令 |
matcher.on_pattern(pattern, f) |
CommandMatcher |
链式模式匹配 |
matcher.otherwise(f) |
() |
兜底处理 |
消息构建(MsgBuilder)
MsgBuilder 提供流式 API 构建 CQ Code 消息:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
| use luo9_sdk::Msg;
let msg = Msg::txt("你好").build(); Bot::send_group_msg(group_id, msg);
let msg = Msg::image("https://example.com/img.png").build();
let msg = Msg::txt("游戏名称: Epic Game").endl() .txt("描述: 一款好游戏").endl() .txt("预览图片:").image(game.preview_image) .build(); Bot::send_group_msg(group_id, msg);
|
| 方法 |
说明 |
Msg::new(text) |
创建 builder,初始文本 |
Msg::txt(text) |
创建 builder,初始纯文本 |
Msg::image(path) |
创建 builder,初始图片 |
Msg::at(user_id) |
创建 builder,初始 @某人 |
.txt(text) |
追加文本 |
.at(user_id) |
追加 @ |
.image(path) |
追加图片(含 http 用 url,否则用 file) |
.endl() |
追加换行 |
.build() |
构建为 CString |
消息发送(Bot)
插件通过 Bot 发送消息,内部通过 luo9_send topic 传递给宿主:
1 2 3 4 5
| use luo9_sdk::Bot; use std::ffi::CString;
Bot::send_group_msg(group_id, CString::new("你好").unwrap()); Bot::send_private_msg(user_id, CString::new("你好").unwrap());
|
也可以使用 send 模块的函数(接受 &str,更简洁):
1 2 3 4
| use luo9_sdk::send;
send::send_group_msg(group_id, "你好"); send::send_private_msg(user_id, "你好");
|
消息接收(Payload)
从总线接收的 JSON 通过 BusPayload::parse() 解析:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16
| use luo9_sdk::payload::*;
if let Some(BusPayload::Message(msg)) = BusPayload::parse(&json) { match msg.message_type { MsgType::Group => { let group_id = msg.group_id.unwrap_or(0); let user_id = msg.user_id; let content = &msg.message; } MsgType::Private => { let user_id = msg.user_id; let content = &msg.message; } _ => {} } }
|
类型定义
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
| pub enum MsgType { Private, Group, Other }
pub struct MessagePayload { pub message_type: MsgType, pub user_id: u64, pub group_id: Option<u64>, pub message: String, }
pub enum MetaEventType { Lifecycle, Heartbeat, Unknown }
pub struct MetaEventPayload { pub interval: Option<u64>, pub meta_event_type: MetaEventType, pub sub_type: SubType, pub self_id: u64, pub status: Option<Status>, pub time: u64, }
pub enum NoticeType { FriendAdd, FriendRecall, GroupAdmin, GroupBan, GroupIncrease, GroupDecrease, GroupCard, GroupRecall, GroupUpload, Essence, Notify, Unknown, }
pub struct NoticePayload { pub notice_type: NoticeType, pub sub_type: SubType, pub status: String, pub user_id: u64, pub group_id: Option<u64>, pub time: u64, }
|
模式匹配(Pattern)
Pattern 提供带命名捕获的字符串模板匹配,{name} 为捕获组:
1 2 3 4 5 6 7
| use luo9_sdk::pattern::Pattern;
let pat = Pattern::new("[CQ:at,qq={qq}]{content}"); if let Some(caps) = pat.match_str("[CQ:at,qq=123456]你好呀") { let qq = caps.get("qq").unwrap(); let content = caps.get("content").unwrap(); }
|
定时任务(Task)
插件通过 luo9_task_miso topic 注册/取消定时任务,通过 luo9_task topic 接收触发事件:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21
| use luo9_sdk::bus::Bus; use serde_json::json;
let req = json!({ "action": "schedule", "task_name": "my_task", "cron": "0 0 8 * * *", "payload": group_id.to_string() }); Bus::topic("luo9_task_miso").publish(&req.to_string())?;
let cancel = json!({ "action": "cancel", "task_name": "my_task" }); Bus::topic("luo9_task_miso").publish(&cancel.to_string())?;
|
Cron 表达式为 6 字段格式 秒 分 时 日 月 周,支持 ? L W # 等特殊字符。详见 洛玖定时任务系统。
版本查询(Version)
宿主可以查询插件的版本信息。插件订阅 luo9_version topic,收到查询后回复:
1 2 3 4 5 6 7 8 9 10 11 12 13 14
| use luo9_sdk::version; use luo9_sdk::bus::Bus;
let ver_sub = Bus::topic("luo9_version").subscribe()?; let ver_topic = Bus::topic("luo9_version");
loop { if let Some(json) = ver_topic.pop(ver_sub) { if version::is_version_query(&json) { version::reply_version(env!("CARGO_PKG_NAME"), env!("CARGO_PKG_VERSION")); } } std::thread::sleep(std::time::Duration::from_millis(1)); }
|
| 函数 |
说明 |
version::is_version_query(json) |
判断是否为版本查询请求 |
version::reply_version(name, version) |
发布版本查询响应 |
插件开发模板
Cargo.toml
1 2 3 4 5 6 7 8 9 10 11
| [package] name = "my_plugin" version = "0.1.0" edition = "2021"
[lib] crate-type = ["cdylib"]
[dependencies] luo9_sdk = "0.6.0" serde_json = "1"
|
src/lib.rs
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
| mod core;
use luo9_sdk::Bot; use luo9_sdk::bus::Bus; use luo9_sdk::command::{Command, PrefixMode}; use luo9_sdk::payload::*; use std::ffi::CString;
#[unsafe(no_mangle)] pub extern "C" fn plugin_main() { let msg_sub = Bus::topic("luo9_message").subscribe().unwrap(); let ver_sub = Bus::topic("luo9_version").subscribe().unwrap();
let msg_topic = Bus::topic("luo9_message"); let ver_topic = Bus::topic("luo9_version");
println!("[my_plugin] 插件已启动");
loop { if let Some(json) = msg_topic.pop(msg_sub) { if let Some(BusPayload::Message(msg)) = BusPayload::parse(&json) { match msg.message_type { MsgType::Group => { handle_group_msg(msg.group_id.unwrap_or(0), msg.user_id, &msg.message); } MsgType::Private => { handle_private_msg(msg.user_id, &msg.message); } _ => {} } } }
if let Some(json) = ver_topic.pop(ver_sub) { if luo9_sdk::version::is_version_query(&json) { luo9_sdk::version::reply_version( env!("CARGO_PKG_NAME"), env!("CARGO_PKG_VERSION"), ); } }
std::thread::sleep(std::time::Duration::from_millis(1)); } }
fn handle_group_msg(group_id: u64, _user_id: u64, msg: &str) { if let Some(cmd) = Command::parse(msg, "echo", PrefixMode::Required('/')) { Bot::send_group_msg(group_id, CString::new(cmd.args().join(" ")).unwrap()); } }
fn handle_private_msg(user_id: u64, msg: &str) { if let Some(cmd) = Command::parse(msg, "echo", PrefixMode::Required('/')) { Bot::send_private_msg(user_id, CString::new(cmd.args().join(" ")).unwrap()); } }
|
构建
FFI 接口
插件必须导出:
1
| extern "C" fn plugin_main();
|
宿主通过 luo9_init_subscribers 传递预分配的 subscriber ID:
1 2 3 4 5 6 7 8
| #[repr(C)] pub struct PluginSubscribers { pub message_sub_id: i32, pub meta_event_sub_id: i32, pub notice_sub_id: i32, pub task_sub_id: i32, pub send_sub_id: i32, }
|
luo9_core 暴露的核心 FFI 函数:
| 函数 |
用途 |
luo9_bus_init() |
初始化总线单例 |
luo9_bus_subscribe(topic) |
订阅 topic,返回 subscriber_id |
luo9_bus_unsubscribe(topic, sub_id) |
取消订阅,唤醒阻塞线程(sentinel 机制) |
luo9_bus_publish(topic, payload) |
广播消息给所有 subscriber |
luo9_bus_publish_to(topic, payload, ids, len) |
定向推送消息给指定 subscriber |
luo9_bus_pop(topic, sub_id) |
非阻塞取消息 |
luo9_bus_wait_pop(topic, sub_id) |
阻塞取消息(dead subscriber 返回 sentinel) |
luo9_bus_free_string(ptr) |
释放 bus 返回的字符串 |
模块总览
| 模块 |
说明 |
bus |
消息总线封装:订阅、发布、定向推送、取消订阅 |
command |
指令解析:前缀匹配、链式匹配、模式匹配 |
message |
MsgBuilder:流式构建 CQ Code 消息 |
payload |
消息反序列化:Message / MetaEvent / Notice |
send |
消息发送:send_group_msg / send_private_msg |
pattern |
模式匹配:带命名捕获的字符串模板 |
version |
版本查询协议 |
Bot |
消息发送 + 版本查询的高层封装 |
Msg |
MsgBuilder 的入口点 |