feat: Phase 9 — developer experience, extensibility, and community growth
New crates: - quicproquo-bot: Bot SDK with polling API + JSON pipe mode - quicproquo-kt: Key Transparency Merkle log (RFC 9162 subset) - quicproquo-plugin-api: no_std C-compatible plugin vtable API - quicproquo-gen: scaffolding tool (qpq-gen plugin/bot/rpc/hook) Server features: - ServerHooks trait wired into all RPC handlers (enqueue, fetch, auth, channel, registration) with plugin rejection support - Dynamic plugin loader (libloading) with --plugin-dir config - Delivery proof canary tokens (Ed25519 server signatures on enqueue) - Key Transparency Merkle log with inclusion proofs on resolveUser Core library: - Safety numbers (60-digit HMAC-SHA256 key verification codes) - Verifiable transcript archive (CBOR + ChaCha20-Poly1305 + hash chain) - Delivery proof verification utility - Criterion benchmarks (hybrid KEM, MLS, identity, sealed sender, padding) Client: - /verify REPL command for out-of-band key verification - Full-screen TUI via Ratatui (feature-gated --features tui) - qpq export / qpq export-verify CLI subcommands - KT inclusion proof verification on user resolution Also: ROADMAP Phase 9 added, bot SDK docs, server hooks docs, crate-responsibilities updated, example plugins (rate_limit, logging).
This commit is contained in:
13
crates/quicproquo-gen/Cargo.toml
Normal file
13
crates/quicproquo-gen/Cargo.toml
Normal file
@@ -0,0 +1,13 @@
|
||||
[package]
|
||||
name = "quicproquo-gen"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
description = "Code generators for quicproquo — scaffold plugins, bots, RPC methods, and hooks."
|
||||
license = "MIT"
|
||||
|
||||
[[bin]]
|
||||
name = "qpq-gen"
|
||||
path = "src/main.rs"
|
||||
|
||||
[dependencies]
|
||||
clap = { workspace = true }
|
||||
212
crates/quicproquo-gen/src/generators/bot.rs
Normal file
212
crates/quicproquo-gen/src/generators/bot.rs
Normal file
@@ -0,0 +1,212 @@
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
|
||||
pub fn generate(name: &str, output: &Path) -> Result<(), String> {
|
||||
let crate_name = sanitize_name(name);
|
||||
let dir = output.join(&crate_name);
|
||||
|
||||
if dir.exists() {
|
||||
return Err(format!("directory already exists: {}", dir.display()));
|
||||
}
|
||||
|
||||
let src_dir = dir.join("src");
|
||||
fs::create_dir_all(&src_dir).map_err(|e| format!("create dir: {e}"))?;
|
||||
|
||||
// Cargo.toml
|
||||
let cargo_toml = format!(
|
||||
r#"[package]
|
||||
name = "{crate_name}"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
description = "quicproquo bot: {name}"
|
||||
license = "MIT"
|
||||
|
||||
[dependencies]
|
||||
quicproquo-bot = {{ git = "https://github.com/nickvidal/quicproquo" }}
|
||||
tokio = {{ version = "1", features = ["macros", "rt-multi-thread"] }}
|
||||
anyhow = "1"
|
||||
tracing = "0.1"
|
||||
tracing-subscriber = {{ version = "0.3", features = ["env-filter"] }}
|
||||
"#,
|
||||
crate_name = crate_name,
|
||||
name = name,
|
||||
);
|
||||
write_file(&dir.join("Cargo.toml"), &cargo_toml)?;
|
||||
|
||||
// src/main.rs
|
||||
let main_rs = format!(
|
||||
r#"//! quicproquo bot: {name}
|
||||
//!
|
||||
//! A bot that connects to a quicproquo server and responds to messages.
|
||||
//!
|
||||
//! Usage:
|
||||
//! {crate_name} --server 127.0.0.1:7000 --username my-bot --password secret
|
||||
//!
|
||||
//! Environment variables (alternative to CLI args):
|
||||
//! QPQ_SERVER, QPQ_USERNAME, QPQ_PASSWORD, QPQ_CA_CERT, QPQ_STATE_PATH
|
||||
|
||||
use quicproquo_bot::{{Bot, BotConfig}};
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> anyhow::Result<()> {{
|
||||
tracing_subscriber::fmt()
|
||||
.with_env_filter(
|
||||
tracing_subscriber::EnvFilter::try_from_default_env()
|
||||
.unwrap_or_else(|_| "info".into()),
|
||||
)
|
||||
.init();
|
||||
|
||||
// --- Configuration ---
|
||||
let server = env_or("QPQ_SERVER", "127.0.0.1:7000");
|
||||
let username = env_or("QPQ_USERNAME", "{crate_name}");
|
||||
let password = env_or("QPQ_PASSWORD", "changeme");
|
||||
let ca_cert = env_or("QPQ_CA_CERT", "server-cert.der");
|
||||
let state_path = env_or("QPQ_STATE_PATH", "{crate_name}-state.bin");
|
||||
|
||||
let config = BotConfig::new(&server, &username, &password)
|
||||
.ca_cert(&ca_cert)
|
||||
.state_path(&state_path);
|
||||
|
||||
// --- Connect and authenticate ---
|
||||
tracing::info!("connecting to {{server}} as {{username}}...");
|
||||
let bot = Bot::connect(config).await?;
|
||||
tracing::info!("authenticated as {{}} (key: {{}})", bot.username(), &bot.identity_key_hex()[..16]);
|
||||
|
||||
// --- Main loop: poll for messages and respond ---
|
||||
tracing::info!("listening for messages (Ctrl+C to stop)...");
|
||||
loop {{
|
||||
let messages = bot.receive(5000).await?;
|
||||
for msg in messages {{
|
||||
tracing::info!("[{{}}] {{}}", msg.sender, msg.text);
|
||||
|
||||
// --- Add your command handlers here ---
|
||||
if let Some(response) = handle_message(&msg.sender, &msg.text) {{
|
||||
bot.send_dm(&msg.sender, &response).await?;
|
||||
}}
|
||||
}}
|
||||
}}
|
||||
}}
|
||||
|
||||
/// Process an incoming message and optionally return a response.
|
||||
///
|
||||
/// Add your bot's command logic here.
|
||||
fn handle_message(sender: &str, text: &str) -> Option<String> {{
|
||||
let text = text.trim();
|
||||
|
||||
// !help — list available commands
|
||||
if text == "!help" {{
|
||||
return Some(
|
||||
"Available commands:\n\
|
||||
!help — show this message\n\
|
||||
!echo <text> — echo back the text\n\
|
||||
!whoami — show your username\n\
|
||||
!ping — pong!"
|
||||
.to_string(),
|
||||
);
|
||||
}}
|
||||
|
||||
// !echo <text> — echo back
|
||||
if let Some(rest) = text.strip_prefix("!echo ") {{
|
||||
return Some(rest.to_string());
|
||||
}}
|
||||
|
||||
// !whoami — tell the sender their username
|
||||
if text == "!whoami" {{
|
||||
return Some(format!("You are {{sender}}"));
|
||||
}}
|
||||
|
||||
// !ping — respond with pong
|
||||
if text == "!ping" {{
|
||||
return Some("pong!".to_string());
|
||||
}}
|
||||
|
||||
// Unknown command or regular message — no response
|
||||
None
|
||||
}}
|
||||
|
||||
fn env_or(key: &str, default: &str) -> String {{
|
||||
std::env::var(key).unwrap_or_else(|_| default.to_string())
|
||||
}}
|
||||
"#,
|
||||
name = name,
|
||||
crate_name = crate_name,
|
||||
);
|
||||
write_file(&src_dir.join("main.rs"), &main_rs)?;
|
||||
|
||||
// README
|
||||
let readme = format!(
|
||||
r#"# {name} — quicproquo bot
|
||||
|
||||
## Quick start
|
||||
|
||||
```bash
|
||||
# Build
|
||||
cargo build
|
||||
|
||||
# Run (make sure a qpq server is running)
|
||||
QPQ_SERVER=127.0.0.1:7000 \
|
||||
QPQ_USERNAME={crate_name} \
|
||||
QPQ_PASSWORD=changeme \
|
||||
QPQ_CA_CERT=path/to/server-cert.der \
|
||||
cargo run
|
||||
```
|
||||
|
||||
## Commands
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `!help` | Show available commands |
|
||||
| `!echo <text>` | Echo back the text |
|
||||
| `!whoami` | Show your username |
|
||||
| `!ping` | Respond with "pong!" |
|
||||
|
||||
## Adding commands
|
||||
|
||||
Edit the `handle_message` function in `src/main.rs`:
|
||||
|
||||
```rust
|
||||
fn handle_message(sender: &str, text: &str) -> Option<String> {{
|
||||
if text == "!mycommand" {{
|
||||
return Some("my response".to_string());
|
||||
}}
|
||||
None
|
||||
}}
|
||||
```
|
||||
|
||||
## Pipe mode
|
||||
|
||||
For shell integration, use the Bot SDK's JSON pipe mode:
|
||||
|
||||
```bash
|
||||
echo '{{"action":"send","to":"alice","text":"hello"}}' | my-bot
|
||||
echo '{{"action":"recv","timeout_ms":5000}}' | my-bot
|
||||
```
|
||||
|
||||
## Documentation
|
||||
|
||||
- [Bot SDK docs](https://github.com/nickvidal/quicproquo/blob/main/docs/src/getting-started/bot-sdk.md)
|
||||
- [Server Hooks](https://github.com/nickvidal/quicproquo/blob/main/docs/src/internals/server-hooks.md)
|
||||
"#,
|
||||
name = name,
|
||||
crate_name = crate_name,
|
||||
);
|
||||
write_file(&dir.join("README.md"), &readme)?;
|
||||
|
||||
println!("Created bot project: {}", dir.display());
|
||||
println!();
|
||||
println!(" cd {crate_name}");
|
||||
println!(" # Edit src/main.rs to add your commands");
|
||||
println!(" QPQ_SERVER=127.0.0.1:7000 QPQ_PASSWORD=secret cargo run");
|
||||
println!();
|
||||
println!("The bot responds to !help, !echo, !whoami, !ping out of the box.");
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn sanitize_name(name: &str) -> String {
|
||||
name.replace(['-', ' '], "_")
|
||||
}
|
||||
|
||||
fn write_file(path: &Path, content: &str) -> Result<(), String> {
|
||||
fs::write(path, content).map_err(|e| format!("write {}: {e}", path.display()))
|
||||
}
|
||||
134
crates/quicproquo-gen/src/generators/hook.rs
Normal file
134
crates/quicproquo-gen/src/generators/hook.rs
Normal file
@@ -0,0 +1,134 @@
|
||||
pub fn generate(name: &str) -> Result<(), String> {
|
||||
let snake = name.to_lowercase().replace(['-', ' '], "_");
|
||||
let pascal = to_pascal_case(&snake);
|
||||
|
||||
println!("=== Adding hook event: on_{snake} ===");
|
||||
println!();
|
||||
println!("Follow these steps to add a new `on_{snake}` hook event.");
|
||||
println!();
|
||||
|
||||
// Step 1: Event struct
|
||||
println!("--- Step 1: Event struct ---");
|
||||
println!("File: crates/quicproquo-server/src/hooks.rs");
|
||||
println!();
|
||||
println!(
|
||||
r#"/// Event data for {snake} operations.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct {pascal}Event {{
|
||||
// TODO: add your event fields here
|
||||
// Example:
|
||||
// pub channel_id: Vec<u8>,
|
||||
// pub user_key: Vec<u8>,
|
||||
}}
|
||||
"#,
|
||||
);
|
||||
|
||||
// Step 2: Trait method
|
||||
println!("--- Step 2: Trait method ---");
|
||||
println!("File: crates/quicproquo-server/src/hooks.rs");
|
||||
println!();
|
||||
println!("Add to the `ServerHooks` trait:");
|
||||
println!();
|
||||
println!(
|
||||
r#" /// Called when {snake} occurs.
|
||||
fn on_{snake}(&self, _event: &{pascal}Event) {{
|
||||
// Default: no-op
|
||||
}}
|
||||
"#,
|
||||
);
|
||||
|
||||
// Step 3: TracingHooks implementation
|
||||
println!("--- Step 3: TracingHooks implementation ---");
|
||||
println!("File: crates/quicproquo-server/src/hooks.rs");
|
||||
println!();
|
||||
println!("Add to `impl ServerHooks for TracingHooks`:");
|
||||
println!();
|
||||
println!(
|
||||
r#" fn on_{snake}(&self, _event: &{pascal}Event) {{
|
||||
tracing::info!("hook: {snake}");
|
||||
}}
|
||||
"#,
|
||||
);
|
||||
|
||||
// Step 4: Plugin API (C-compatible struct)
|
||||
println!("--- Step 4: Plugin API ---");
|
||||
println!("File: crates/quicproquo-plugin-api/src/lib.rs");
|
||||
println!();
|
||||
println!("Add a C-compatible event struct:");
|
||||
println!();
|
||||
println!(
|
||||
r#"#[repr(C)]
|
||||
pub struct C{pascal}Event {{
|
||||
// TODO: mirror the fields from {pascal}Event using C-compatible types
|
||||
// Use *const u8 + len for byte slices, *const c_char for strings
|
||||
}}
|
||||
"#,
|
||||
);
|
||||
println!("Add to `HookVTable`:");
|
||||
println!();
|
||||
println!(
|
||||
r#" pub on_{snake}: Option<extern "C" fn(*mut c_void, *const C{pascal}Event)>,
|
||||
"#,
|
||||
);
|
||||
|
||||
// Step 5: Wire into PluginHooks
|
||||
println!("--- Step 5: PluginHooks dispatch ---");
|
||||
println!("File: crates/quicproquo-server/src/plugin_loader.rs");
|
||||
println!();
|
||||
println!("Add to `impl ServerHooks for PluginHooks`:");
|
||||
println!();
|
||||
println!(
|
||||
r#" fn on_{snake}(&self, event: &{pascal}Event) {{
|
||||
if let Some(hook_fn) = self.vtable.on_{snake} {{
|
||||
let c_event = C{pascal}Event {{
|
||||
// TODO: convert fields
|
||||
}};
|
||||
hook_fn(self.vtable.user_data, &c_event);
|
||||
}}
|
||||
}}
|
||||
"#,
|
||||
);
|
||||
|
||||
// Step 6: Call the hook
|
||||
println!("--- Step 6: Call the hook in the RPC handler ---");
|
||||
println!("In the relevant handler file under crates/quicproquo-server/src/node_service/:");
|
||||
println!();
|
||||
println!(
|
||||
r#" use crate::hooks::{pascal}Event;
|
||||
|
||||
// At the appropriate point in the handler:
|
||||
self.hooks.on_{snake}(&{pascal}Event {{
|
||||
// fill in fields
|
||||
}});
|
||||
"#,
|
||||
);
|
||||
|
||||
// Step 7: Verify
|
||||
println!("--- Step 7: Verify ---");
|
||||
println!(" cargo build -p quicproquo-plugin-api");
|
||||
println!(" cargo build -p quicproquo-server");
|
||||
println!(" cargo test -p quicproquo-server");
|
||||
println!();
|
||||
|
||||
// Summary
|
||||
println!("=== Files to modify ===");
|
||||
println!(" [modify] crates/quicproquo-server/src/hooks.rs");
|
||||
println!(" [modify] crates/quicproquo-plugin-api/src/lib.rs");
|
||||
println!(" [modify] crates/quicproquo-server/src/plugin_loader.rs");
|
||||
println!(" [modify] crates/quicproquo-server/src/node_service/<handler>.rs");
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn to_pascal_case(snake: &str) -> String {
|
||||
snake
|
||||
.split('_')
|
||||
.map(|word| {
|
||||
let mut chars = word.chars();
|
||||
match chars.next() {
|
||||
None => String::new(),
|
||||
Some(c) => c.to_uppercase().to_string() + chars.as_str(),
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
4
crates/quicproquo-gen/src/generators/mod.rs
Normal file
4
crates/quicproquo-gen/src/generators/mod.rs
Normal file
@@ -0,0 +1,4 @@
|
||||
pub mod bot;
|
||||
pub mod hook;
|
||||
pub mod plugin;
|
||||
pub mod rpc;
|
||||
186
crates/quicproquo-gen/src/generators/plugin.rs
Normal file
186
crates/quicproquo-gen/src/generators/plugin.rs
Normal file
@@ -0,0 +1,186 @@
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
|
||||
pub fn generate(name: &str, output: &Path) -> Result<(), String> {
|
||||
let crate_name = sanitize_name(name);
|
||||
let dir = output.join(&crate_name);
|
||||
|
||||
if dir.exists() {
|
||||
return Err(format!("directory already exists: {}", dir.display()));
|
||||
}
|
||||
|
||||
let src_dir = dir.join("src");
|
||||
fs::create_dir_all(&src_dir).map_err(|e| format!("create dir: {e}"))?;
|
||||
|
||||
// Cargo.toml
|
||||
let cargo_toml = format!(
|
||||
r#"[package]
|
||||
name = "{crate_name}"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
description = "quicproquo server plugin: {name}"
|
||||
license = "MIT"
|
||||
|
||||
[lib]
|
||||
crate-type = ["cdylib"]
|
||||
|
||||
# Empty workspace — this plugin builds independently of the qpq workspace.
|
||||
[workspace]
|
||||
|
||||
[dependencies]
|
||||
quicproquo-plugin-api = {{ git = "https://github.com/nickvidal/quicproquo", default-features = false }}
|
||||
"#,
|
||||
crate_name = crate_name,
|
||||
name = name,
|
||||
);
|
||||
write_file(&dir.join("Cargo.toml"), &cargo_toml)?;
|
||||
|
||||
// src/lib.rs
|
||||
let lib_rs = format!(
|
||||
r#"//! quicproquo server plugin: {name}
|
||||
//!
|
||||
//! Build with: cargo build --release
|
||||
//! Install: cp target/release/lib{crate_name}.so /path/to/plugins/
|
||||
//! The server loads it automatically when started with --plugin-dir.
|
||||
|
||||
use quicproquo_plugin_api::{{HookVTable, CMessageEvent, HOOK_CONTINUE, HOOK_REJECT, PLUGIN_OK}};
|
||||
use std::ffi::CString;
|
||||
use std::os::raw::c_int;
|
||||
|
||||
/// Plugin state — allocate on the heap in init, free in destroy.
|
||||
struct PluginState {{
|
||||
/// Example: maximum allowed payload size in bytes.
|
||||
max_payload_bytes: usize,
|
||||
/// Stored rejection message (must outlive the hook call).
|
||||
reject_msg: Option<CString>,
|
||||
}}
|
||||
|
||||
/// Called by the server on plugin load.
|
||||
///
|
||||
/// Fill the vtable with your hook implementations. Return PLUGIN_OK on success.
|
||||
#[no_mangle]
|
||||
pub extern "C" fn qpq_plugin_init(vtable: *mut HookVTable) -> c_int {{
|
||||
let state = Box::new(PluginState {{
|
||||
max_payload_bytes: 1_000_000, // 1 MB limit
|
||||
reject_msg: None,
|
||||
}});
|
||||
|
||||
let vt = unsafe {{ &mut *vtable }};
|
||||
vt.user_data = Box::into_raw(state) as *mut _;
|
||||
vt.on_message_enqueue = Some(on_message_enqueue);
|
||||
vt.error_message = Some(error_message);
|
||||
vt.destroy = Some(destroy);
|
||||
|
||||
eprintln!("[{name}] plugin loaded");
|
||||
PLUGIN_OK
|
||||
}}
|
||||
|
||||
/// Hook: called before each message is stored in the delivery queue.
|
||||
///
|
||||
/// Return HOOK_CONTINUE to allow, HOOK_REJECT to block.
|
||||
extern "C" fn on_message_enqueue(
|
||||
user_data: *mut std::ffi::c_void,
|
||||
event: *const CMessageEvent,
|
||||
) -> c_int {{
|
||||
let state = unsafe {{ &mut *(user_data as *mut PluginState) }};
|
||||
let event = unsafe {{ &*event }};
|
||||
|
||||
if event.payload_len > state.max_payload_bytes {{
|
||||
let msg = format!(
|
||||
"payload too large: {{}} > {{}} bytes",
|
||||
event.payload_len, state.max_payload_bytes
|
||||
);
|
||||
state.reject_msg = CString::new(msg).ok();
|
||||
return HOOK_REJECT;
|
||||
}}
|
||||
|
||||
HOOK_CONTINUE
|
||||
}}
|
||||
|
||||
/// Return a pointer to the rejection error message (valid until next hook call).
|
||||
extern "C" fn error_message(
|
||||
user_data: *mut std::ffi::c_void,
|
||||
) -> *const std::os::raw::c_char {{
|
||||
let state = unsafe {{ &*(user_data as *const PluginState) }};
|
||||
match &state.reject_msg {{
|
||||
Some(msg) => msg.as_ptr(),
|
||||
None => std::ptr::null(),
|
||||
}}
|
||||
}}
|
||||
|
||||
/// Cleanup: free the plugin state.
|
||||
extern "C" fn destroy(user_data: *mut std::ffi::c_void) {{
|
||||
if !user_data.is_null() {{
|
||||
unsafe {{ drop(Box::from_raw(user_data as *mut PluginState)) }};
|
||||
}}
|
||||
eprintln!("[{name}] plugin unloaded");
|
||||
}}
|
||||
"#,
|
||||
name = name,
|
||||
crate_name = crate_name,
|
||||
);
|
||||
write_file(&src_dir.join("lib.rs"), &lib_rs)?;
|
||||
|
||||
// README
|
||||
let readme = format!(
|
||||
r#"# {name} — quicproquo server plugin
|
||||
|
||||
## Build
|
||||
|
||||
```bash
|
||||
cargo build --release
|
||||
```
|
||||
|
||||
## Install
|
||||
|
||||
Copy the shared library to the server's plugin directory:
|
||||
|
||||
```bash
|
||||
cp target/release/lib{crate_name}.so /path/to/plugins/
|
||||
```
|
||||
|
||||
Start the server with:
|
||||
|
||||
```bash
|
||||
qpq-server --plugin-dir /path/to/plugins/
|
||||
```
|
||||
|
||||
## Hooks
|
||||
|
||||
This plugin implements `on_message_enqueue` to reject oversized payloads.
|
||||
Edit `src/lib.rs` to add your own logic. Available hooks:
|
||||
|
||||
| Hook | Purpose |
|
||||
|------|---------|
|
||||
| `on_message_enqueue` | Inspect/reject messages before delivery (return `HOOK_REJECT`) |
|
||||
| `on_batch_enqueue` | Observe batch message delivery |
|
||||
| `on_auth` | Observe login success/failure |
|
||||
| `on_channel_created` | Observe channel creation |
|
||||
| `on_fetch` | Observe message fetch operations |
|
||||
| `on_user_registered` | Observe new user registration |
|
||||
|
||||
See the [Server Hooks documentation](https://github.com/nickvidal/quicproquo/blob/main/docs/src/internals/server-hooks.md) for details.
|
||||
"#,
|
||||
name = name,
|
||||
crate_name = crate_name,
|
||||
);
|
||||
write_file(&dir.join("README.md"), &readme)?;
|
||||
|
||||
println!("Created plugin project: {}", dir.display());
|
||||
println!();
|
||||
println!(" cd {crate_name}");
|
||||
println!(" cargo build --release");
|
||||
println!(" cp target/release/lib{crate_name}.so /path/to/plugins/");
|
||||
println!();
|
||||
println!("Edit src/lib.rs to implement your hook logic.");
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn sanitize_name(name: &str) -> String {
|
||||
name.replace(['-', ' '], "_")
|
||||
}
|
||||
|
||||
fn write_file(path: &Path, content: &str) -> Result<(), String> {
|
||||
fs::write(path, content).map_err(|e| format!("write {}: {e}", path.display()))
|
||||
}
|
||||
129
crates/quicproquo-gen/src/generators/rpc.rs
Normal file
129
crates/quicproquo-gen/src/generators/rpc.rs
Normal file
@@ -0,0 +1,129 @@
|
||||
pub fn generate(name: &str) -> Result<(), String> {
|
||||
let snake = to_snake_case(name);
|
||||
let camel = name.to_string();
|
||||
println!("=== Adding RPC method: {camel} ===");
|
||||
println!();
|
||||
println!("Follow these steps to add a new `{camel}` RPC method.");
|
||||
println!("Each step shows the file and the code to add.");
|
||||
println!();
|
||||
|
||||
// Step 1: Schema
|
||||
println!("--- Step 1: Cap'n Proto schema ---");
|
||||
println!("File: schemas/node.capnp");
|
||||
println!();
|
||||
println!("Add to the `interface NodeService` block:");
|
||||
println!();
|
||||
println!(
|
||||
r#" {camel} @N (auth :AuthContext, <your params here>) -> (<your results here>);
|
||||
"#,
|
||||
);
|
||||
println!(" (Replace @N with the next ordinal number in the interface.)");
|
||||
println!();
|
||||
println!("Then rebuild the proto crate:");
|
||||
println!(" cargo build -p quicproquo-proto");
|
||||
println!();
|
||||
|
||||
// Step 2: Handler module
|
||||
println!("--- Step 2: Handler module ---");
|
||||
println!("File: crates/quicproquo-server/src/node_service/{snake}.rs");
|
||||
println!();
|
||||
println!(
|
||||
r#"use capnp::capability::Promise;
|
||||
use quicproquo_proto::node_capnp::node_service;
|
||||
|
||||
use crate::auth::{{coded_error, validate_auth_context}};
|
||||
use crate::error_codes::*;
|
||||
|
||||
use super::NodeServiceImpl;
|
||||
|
||||
impl NodeServiceImpl {{
|
||||
pub fn handle_{snake}(
|
||||
&mut self,
|
||||
params: node_service::{camel}Params,
|
||||
mut results: node_service::{camel}Results,
|
||||
) -> Promise<(), capnp::Error> {{
|
||||
let p = match params.get() {{
|
||||
Ok(p) => p,
|
||||
Err(e) => return Promise::err(coded_error(E020_BAD_PARAMS, e)),
|
||||
}};
|
||||
let auth_ctx = match validate_auth_context(&self.auth_cfg, &self.sessions, p.get_auth()) {{
|
||||
Ok(ctx) => ctx,
|
||||
Err(e) => return Promise::err(e),
|
||||
}};
|
||||
|
||||
// TODO: implement your logic here
|
||||
|
||||
Promise::ok(())
|
||||
}}
|
||||
}}
|
||||
"#,
|
||||
);
|
||||
|
||||
// Step 3: Wire into mod.rs
|
||||
println!("--- Step 3: Register in mod.rs ---");
|
||||
println!("File: crates/quicproquo-server/src/node_service/mod.rs");
|
||||
println!();
|
||||
println!("Add to the module declarations at the top:");
|
||||
println!(" mod {snake};");
|
||||
println!();
|
||||
println!("Add to the `impl node_service::Server for NodeServiceImpl` block:");
|
||||
println!();
|
||||
println!(
|
||||
r#" fn {snake}(
|
||||
&mut self,
|
||||
params: node_service::{camel}Params,
|
||||
results: node_service::{camel}Results,
|
||||
) -> capnp::capability::Promise<(), capnp::Error> {{
|
||||
self.handle_{snake}(params, results)
|
||||
}}
|
||||
"#,
|
||||
);
|
||||
|
||||
// Step 4: Storage (if needed)
|
||||
println!("--- Step 4: Storage trait (if needed) ---");
|
||||
println!("File: crates/quicproquo-server/src/storage.rs");
|
||||
println!();
|
||||
println!("If your RPC method needs persistent storage, add a method to the Store trait:");
|
||||
println!();
|
||||
println!(
|
||||
r#" fn {snake}(&self, /* params */) -> Result</* return */, StorageError>;
|
||||
"#,
|
||||
);
|
||||
println!("Then implement it in:");
|
||||
println!(" - crates/quicproquo-server/src/sql_store.rs (SQLite backend)");
|
||||
println!(" - crates/quicproquo-server/src/storage.rs (FileBackedStore)");
|
||||
println!();
|
||||
|
||||
// Step 5: Hook (if needed)
|
||||
println!("--- Step 5: Hook event (optional) ---");
|
||||
println!("If you want plugins to observe this RPC, run:");
|
||||
println!(" qpq-gen hook {snake}");
|
||||
println!();
|
||||
|
||||
// Step 6: Verify
|
||||
println!("--- Step 6: Verify ---");
|
||||
println!(" cargo build -p quicproquo-server");
|
||||
println!(" cargo test -p quicproquo-server");
|
||||
println!();
|
||||
|
||||
// Summary
|
||||
println!("=== Files to create/modify ===");
|
||||
println!(" [modify] schemas/node.capnp");
|
||||
println!(" [create] crates/quicproquo-server/src/node_service/{snake}.rs");
|
||||
println!(" [modify] crates/quicproquo-server/src/node_service/mod.rs");
|
||||
println!(" [modify] crates/quicproquo-server/src/storage.rs (if needed)");
|
||||
println!(" [modify] crates/quicproquo-server/src/sql_store.rs (if needed)");
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn to_snake_case(s: &str) -> String {
|
||||
let mut result = String::with_capacity(s.len() + 4);
|
||||
for (i, ch) in s.chars().enumerate() {
|
||||
if ch.is_uppercase() && i > 0 {
|
||||
result.push('_');
|
||||
}
|
||||
result.push(ch.to_ascii_lowercase());
|
||||
}
|
||||
result
|
||||
}
|
||||
55
crates/quicproquo-gen/src/main.rs
Normal file
55
crates/quicproquo-gen/src/main.rs
Normal file
@@ -0,0 +1,55 @@
|
||||
use clap::{Parser, Subcommand};
|
||||
use std::path::PathBuf;
|
||||
|
||||
mod generators;
|
||||
|
||||
#[derive(Parser)]
|
||||
#[command(name = "qpq-gen", about = "Code generators for quicproquo")]
|
||||
struct Cli {
|
||||
#[command(subcommand)]
|
||||
command: Command,
|
||||
}
|
||||
|
||||
#[derive(Subcommand)]
|
||||
enum Command {
|
||||
/// Scaffold a new server plugin (dynamic .so/.dylib)
|
||||
Plugin {
|
||||
/// Plugin name (e.g. "rate-limiter", "audit-log")
|
||||
name: String,
|
||||
/// Output directory (default: current directory)
|
||||
#[arg(short, long, default_value = ".")]
|
||||
output: PathBuf,
|
||||
},
|
||||
/// Scaffold a new bot project using the Bot SDK
|
||||
Bot {
|
||||
/// Bot name (e.g. "echo-bot", "moderation-bot")
|
||||
name: String,
|
||||
/// Output directory (default: current directory)
|
||||
#[arg(short, long, default_value = ".")]
|
||||
output: PathBuf,
|
||||
},
|
||||
/// Show instructions for adding a new Cap'n Proto RPC method
|
||||
Rpc {
|
||||
/// RPC method name in camelCase (e.g. "listChannels")
|
||||
name: String,
|
||||
},
|
||||
/// Show instructions for adding a new server hook event
|
||||
Hook {
|
||||
/// Hook event name in snake_case (e.g. "message_deleted")
|
||||
name: String,
|
||||
},
|
||||
}
|
||||
|
||||
fn main() {
|
||||
let cli = Cli::parse();
|
||||
let result = match cli.command {
|
||||
Command::Plugin { name, output } => generators::plugin::generate(&name, &output),
|
||||
Command::Bot { name, output } => generators::bot::generate(&name, &output),
|
||||
Command::Rpc { name } => generators::rpc::generate(&name),
|
||||
Command::Hook { name } => generators::hook::generate(&name),
|
||||
};
|
||||
if let Err(e) = result {
|
||||
eprintln!("error: {e}");
|
||||
std::process::exit(1);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user