SmartAssist
High-performance Rust implementation of the SmartAssist AI agent gateway.
SmartAssist is a multi-channel AI agent platform: a single gateway process runs an agent runtime with a large built-in tool library, exposes a JSON-RPC API over WebSocket, and bridges out to messaging channels (Telegram, Discord, Slack, Signal, WhatsApp, iMessage, LINE, and a browser-based web chat) while talking to pluggable model providers (Anthropic, OpenAI, Google).
Key metrics: 167 source files, 725 tests passing, 101 agent tools, 46 gateway RPC methods, 11 workspace crates.
Workspace crates
| Crate | Description |
|---|---|
smartassist-core | Core types, configuration, and shared utilities |
smartassist-sandbox | Command execution sandboxing with platform-specific profiles |
smartassist-channels | Messaging channel abstractions (Telegram, Discord, Slack, etc.) |
smartassist-agent | Agent runtime with tool execution framework |
smartassist-memory | Memory and context management for agents |
smartassist-gateway | JSON-RPC gateway server over WebSocket |
smartassist-cli | Command-line interface |
smartassist-plugin-sdk | Plugin development kit for extensions |
smartassist-providers | Model provider integrations (Anthropic, OpenAI, Google) |
smartassist-secrets | Encrypted secrets and credential management |
smartassist-security | Guardrails, sandboxing policy, and security tooling |
smartassist-learnings | Learnings/improvement engine for agent self-tuning |
smartassist-browser | Browser automation |
smartassist-canvas | Canvas/UI surface support |
smartassist-cron | Scheduled task execution |
smartassist-transcription | Audio transcription |
smartassist-talk | Voice/talk interface |
smartassist-mcp | Model Context Protocol integration |
Architecture
┌─────────────────────────────────────────────────────────────────┐
│ smartassist-cli │
│ (Command-line interface) │
└─────────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────┐
│ smartassist-gateway │
│ (JSON-RPC over WebSocket server) │
│ │
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
│ │ Health │ │ Chat │ │ Sessions │ │ Config │ ... │
│ │ Handler │ │ Handler │ │ Handler │ │ Handler │ │
│ └──────────┘ └──────────┘ └──────────┘ └──────────┘ │
└─────────────────────────────────────────────────────────────────┘
│
┌────────────────────┼────────────────────┐
▼ ▼ ▼
┌──────────────────┐ ┌──────────────────┐ ┌──────────────────┐
│ smartassist-agent│ │smartassist- │ │smartassist-memory│
│ (Agent runtime) │ │ channels │ │ (Context store) │
│ │ │ (Messaging) │ │ │
│ ┌────────────┐ │ │ ┌──────────┐ │ │ ┌────────────┐ │
│ │ Tools │ │ │ │ Telegram │ │ │ │ Embeddings │ │
│ ├────────────┤ │ │ ├──────────┤ │ │ ├────────────┤ │
│ │ Sessions │ │ │ │ Discord │ │ │ │ Vector DB │ │
│ ├────────────┤ │ │ ├──────────┤ │ │ └────────────┘ │
│ │ Streaming │ │ │ │ Slack │ │ │ │
│ └────────────┘ │ │ ├──────────┤ │ └──────────────────┘
│ │ │ │ Signal │ │
│ │ │ ├──────────┤ │
│ │ │ │ WhatsApp │ │
│ │ │ ├──────────┤ │
│ │ │ │ Web +more│ │
│ │ │ └──────────┘ │
└──────────────────┘ └──────────────────┘
│
▼
┌──────────────────┐ ┌──────────────────┐ ┌──────────────────┐
│smartassist- │ │smartassist- │ │ smartassist- │
│ sandbox │ │ providers │ │ secrets │
│ (Cmd execution) │ │ (AI models) │ │ (Credentials) │
└──────────────────┘ └──────────────────┘ └──────────────────┘
│ │ │
▼ ▼ ▼
┌─────────────────────────────────────────────────────────────────┐
│ smartassist-core │
│ (Types & configuration) │
└─────────────────────────────────────────────────────────────────┘
Implementation status
All four specification phases are complete:
| Phase | Focus | Status |
|---|---|---|
| Phase 1 | Core Infrastructure (providers, gateway, tools) | Complete |
| Phase 2 | Channels (Telegram, Discord, Slack, Signal, WhatsApp, iMessage, LINE, Web) | Complete |
| Phase 3 | Advanced Features (Plugin SDK, browser automation, memory/embeddings) | Complete |
| Phase 4 | Platform & Polish (CLI, config validation, comprehensive testing) | Complete |
Where to go next
- New to the project? Start with Building and Running.
- Want the system design? See Architecture Overview.
- Looking for a specific tool or RPC method? See the Reference section.
Building
SmartAssist is a Cargo workspace. The pinned toolchain is declared in
rust-toolchain.toml
(minimum supported Rust version: 1.75).
# Build all crates
cargo build --workspace
# Build with release optimizations
cargo build --workspace --release
# Build the CLI only
cargo build -p smartassist-cli
Testing
# Run all tests
cargo test --workspace
# Run tests for a specific crate
cargo test -p smartassist-agent
# Run tests with specific channel features
cargo test -p smartassist-channels --features telegram
The workspace also has BDD (cucumber) and end-to-end suites under
tests/bdd and
tests/e2e, and an
integration suite under
tests/integration.
CI runs cargo test --test cucumber for the BDD suite; see
.github/workflows/ci.yml
for the full pipeline (format, clippy, test, BDD, build, coverage).
Channel features
Messaging channels are feature-gated on smartassist-channels to reduce compile
time and dependencies:
# Build with Telegram support
cargo build -p smartassist-channels --features telegram
# Build with Discord support
cargo build -p smartassist-channels --features discord
# Build with Slack support
cargo build -p smartassist-channels --features slack
# Build with WebSocket support
cargo build -p smartassist-channels --features web
# Build with Signal support
cargo build -p smartassist-channels --features signal
# Build with iMessage support (macOS only)
cargo build -p smartassist-channels --features imessage
# Build with WhatsApp support
cargo build -p smartassist-channels --features whatsapp
# Build with LINE support
cargo build -p smartassist-channels --features line
# Build with all channels
cargo build -p smartassist-channels --features "telegram,discord,slack,web,signal,imessage,whatsapp,line"
See Channel Traits for how channels plug into the gateway, and the per-channel pages for protocol details.
Running
# Start the gateway with Anthropic (default)
ANTHROPIC_API_KEY=your-key cargo run -p smartassist-cli -- gateway run
# Start with OpenAI
OPENAI_API_KEY=your-key cargo run -p smartassist-cli -- gateway run --provider openai
# Start with Google Gemini
GOOGLE_API_KEY=your-key cargo run -p smartassist-cli -- gateway run --provider google
# Start on a specific port with a custom model
ANTHROPIC_API_KEY=xxx cargo run -p smartassist-cli -- gateway run --port 18789 --model claude-opus-4-20250514
# Show help
cargo run -p smartassist-cli -- --help
Once running, the gateway exposes a JSON-RPC API over WebSocket — see Gateway RPC Methods for the full method list and Gateway Overview for the protocol design.
Docker
A Dockerfile and
docker-compose.yml
are provided for containerized deployment.
Desktop control app
smartassist-control
is a Tauri + Vite desktop application for controlling and monitoring a running
gateway.
Configuration
SmartAssist is configured primarily through environment variables, with
per-session overrides available through CLI flags and the gateway’s
config.* RPC methods (see Gateway RPC Methods).
Environment variables
| Variable | Purpose |
|---|---|
ANTHROPIC_API_KEY | Anthropic Claude API key |
OPENAI_API_KEY | OpenAI API key |
GOOGLE_API_KEY / GEMINI_API_KEY | Google Gemini API key |
SMARTASSIST_PROVIDER | Default model provider (anthropic, openai, google) |
SMARTASSIST_MODEL | Default model to use |
Model providers
See Model Providers for the full provider specification, including streaming, tool calling, and vision support per provider.
Secrets
Credentials and tokens (channel bot tokens, provider keys) can be stored using
smartassist-secrets, which provides encrypted-at-rest credential management
rather than relying solely on plaintext environment variables.
SmartAssist Rust Implementation - Full Parity Specification
Overview
This specification defines the complete implementation requirements for achieving 100% feature parity between the Rust and Node.js implementations of SmartAssist.
Implementation Status
All 4 phases are complete. The implementation exceeds the original specification:
| Metric | Target | Actual |
|---|---|---|
| Workspace Crates | 9 | 11 (+ smartassist-secrets, examples/hello-plugin) |
| Channels | 8 | 8 (all complete) |
| Providers | 3 | 3 (all complete) |
| Gateway RPC Methods | 34+ | 46 |
| Agent Tools | 37+ | 101 |
| Test Coverage | Comprehensive | 725 tests passing |
| Source Files | — | 167 .rs files |
Target Architecture
smartassist-rs/
├── crates/
│ ├── smartassist-core/ # Core types, config, utilities
│ ├── smartassist-sandbox/ # Secure command execution
│ ├── smartassist-channels/ # All channel implementations (feature-gated)
│ ├── smartassist-agent/ # Agent runtime, sessions, and 101 tools
│ ├── smartassist-memory/ # Vector store and embeddings
│ ├── smartassist-gateway/ # WebSocket/HTTP gateway (46 RPC methods)
│ ├── smartassist-cli/ # Command-line interface
│ ├── smartassist-plugin-sdk/ # Plugin development kit
│ ├── smartassist-providers/ # Model providers (Anthropic, OpenAI, Google)
│ └── smartassist-secrets/ # Encrypted credential management
├── examples/
│ └── hello-plugin/ # Example plugin
└── tests/
└── integration/ # Integration test suite
Feature Categories
1. Channels (8 Core)
- Telegram, Discord, Slack, Signal, iMessage, WhatsApp, Line, Web
- See:
channels/directory for individual specs
2. Tools (37+)
- File system, execution, web, channel actions, agent, media, memory
- See:
tools/directory for individual specs
3. Gateway Methods (34 RPC Endpoints)
- Chat, agent, channel, session, node, exec, health, config, system
- See:
gateway/directory for specs
4. Model Providers
- Anthropic, OpenAI, Google, local LLMs
- See:
providers/directory for specs
5. Plugin SDK
- Plugin traits, lifecycle, configuration, registration
- See:
plugins/directory for specs
Implementation Priority
Phase 1: Core Infrastructure (Weeks 1-4) ✓
- Model providers (Anthropic, OpenAI)
- Gateway RPC methods (chat., agent.)
- Core tools (bash, read, write, edit, glob, grep)
Phase 2: Channels (Weeks 5-10) ✓
- Telegram (full implementation)
- Discord (full implementation)
- Slack (full implementation)
- Signal, WhatsApp, iMessage, Line, Web
Phase 3: Advanced Features (Weeks 11-16) ✓
- Plugin SDK
- Browser automation tools
- Memory/vector search
- All remaining tools
Phase 4: Platform & Polish (Weeks 17-20) ✓
- CLI completeness
- Configuration validation
- Comprehensive testing
- Documentation
Type Conventions
All specifications use these Rust type conventions:
Stringfor owned strings&strfor borrowed stringsOption<T>for optional fieldsVec<T>for arraysHashMap<K, V>for objects/mapsserde_json::Valuefor dynamic JSONchrono::DateTime<Utc>for timestampsuuid::Uuidfor unique identifiers
Error Handling
All operations return Result<T, Error> where appropriate. Errors should:
- Be typed using
thiserror - Include context for debugging
- Be serializable for RPC responses
- Support retry logic where applicable
Async Runtime
All async code uses tokio runtime with:
async-traitfor async traitsfuturesfor stream utilitiestokio-streamfor async iterators
Gateway Specification Overview
Protocol
JSON-RPC 2.0 over WebSocket with the following frame types:
#![allow(unused)]
fn main() {
/// Request frame from client.
#[derive(Debug, Serialize, Deserialize)]
pub struct RequestFrame {
#[serde(rename = "type")]
pub frame_type: String, // "req"
pub id: String,
pub method: String,
pub params: Option<serde_json::Value>,
}
/// Response frame from server.
#[derive(Debug, Serialize, Deserialize)]
pub struct ResponseFrame {
#[serde(rename = "type")]
pub frame_type: String, // "res"
pub id: String,
pub ok: bool,
pub payload: Option<serde_json::Value>,
pub error: Option<ErrorShape>,
}
/// Event frame from server.
#[derive(Debug, Serialize, Deserialize)]
pub struct EventFrame {
#[serde(rename = "type")]
pub frame_type: String, // "event"
pub event: String,
pub payload: Option<serde_json::Value>,
pub seq: Option<u64>,
#[serde(rename = "stateVersion")]
pub state_version: Option<StateVersion>,
}
/// Error shape for failed responses.
#[derive(Debug, Serialize, Deserialize)]
pub struct ErrorShape {
pub code: String,
pub message: String,
pub details: Option<serde_json::Value>,
pub retryable: Option<bool>,
#[serde(rename = "retryAfterMs")]
pub retry_after_ms: Option<u64>,
}
/// State version for optimistic updates.
#[derive(Debug, Serialize, Deserialize)]
pub struct StateVersion {
pub presence: Option<u64>,
pub health: Option<u64>,
}
}
Method Categories
46 registered RPC methods across 15 handler categories:
1. Chat Methods
chat- Send a chat message and get responsechat.history- Get conversation historychat.abort- Abort ongoing chat
2. Agent Methods
agent- Run agent turnagent.stream- Streaming agent turn
3. Session Methods
sessions.list- List sessionssessions.resolve- Resolve session label to keysessions.patch- Update session configsessions.delete- Delete session
4. Node Methods
node.pair.request- Request node pairingnode.pair.approve- Approve pairingnode.pair.reject- Reject pairingnode.unpair- Unpair nodenode.rename- Rename nodenode.list- List nodesnode.describe- Get node detailsnode.invoke- Invoke node commandnode.invoke.result- Node command resultnode.event- Node event
5. Device Methods
device.pair.list- List paired devicesdevice.pair.approve- Approve pairingdevice.pair.reject- Reject pairingdevice.token.rotate- Rotate device tokendevice.token.revoke- Revoke device token
6. Exec Approval Methods
exec.approvals.get- Get approval configexec.approvals.set- Set approval configexec.approvals.node.get- Get node approval configexec.approvals.node.set- Set node approval configexec.approval.request- Request exec approvalexec.approval.resolve- Resolve approval
7. Config Methods
config.get- Get configconfig.set- Set configconfig.apply- Apply config with restartconfig.patch- Patch configconfig.schema- Get config schema
8. Cron Methods
cron.list- List cron jobscron.status- Get cron statuscron.add- Add cron jobcron.update- Update cron jobcron.remove- Remove cron jobcron.run- Run cron jobcron.runs- Get cron run historywake- Send wake event
9. Skill Methods
skills.status- Get skills statusskills.bins- Get skill binariesskills.install- Install skillskills.update- Update skill
10. Model Methods
models.list- List available models
11. Health Methods
health- Get health statusstatus- Get system statuslogs.tail- Tail logs
12. System Methods
system-presence- Get system presencesystem-event- Send system eventlast-heartbeat- Get last heartbeatset-heartbeats- Enable/disable heartbeats
13. Wizard Methods
wizard.start- Start setup wizardwizard.next- Next wizard stepwizard.cancel- Cancel wizardwizard.status- Get wizard status
14. Send Methods
send- Send messagesend.poll- Send poll
15. Browser Methods
browser.request- Browser HTTP request
Gateway Server
#![allow(unused)]
fn main() {
pub struct GatewayServer {
config: GatewayConfig,
methods: HashMap<String, Box<dyn GatewayMethod>>,
connections: RwLock<HashMap<String, GatewayConnection>>,
event_tx: broadcast::Sender<EventFrame>,
}
#[async_trait]
pub trait GatewayMethod: Send + Sync {
fn name(&self) -> &str;
async fn handle(
&self,
params: serde_json::Value,
ctx: &MethodContext,
) -> Result<serde_json::Value, GatewayError>;
}
pub struct MethodContext {
pub connection_id: String,
pub client_info: ClientInfo,
pub config: Arc<SmartAssistConfig>,
pub agent_runtime: Arc<AgentRuntime>,
pub session_manager: Arc<SessionManager>,
pub channel_registry: Arc<ChannelRegistry>,
pub cron_scheduler: Arc<CronScheduler>,
pub node_manager: Arc<NodeManager>,
}
pub struct GatewayConnection {
pub id: String,
pub client_info: ClientInfo,
pub tx: mpsc::Sender<String>,
pub subscriptions: HashSet<String>,
pub created_at: DateTime<Utc>,
pub last_activity: DateTime<Utc>,
}
pub struct ClientInfo {
pub client_id: String,
pub client_display_name: String,
pub mode: ClientMode,
pub version: Option<String>,
pub device_id: Option<String>,
pub roles: Vec<String>,
pub scopes: Vec<String>,
}
pub enum ClientMode {
Backend,
Frontend,
}
}
Error Codes
#![allow(unused)]
fn main() {
pub enum GatewayErrorCode {
InvalidRequest,
MethodNotFound,
InvalidParams,
InternalError,
Unauthorized,
Forbidden,
NotFound,
Conflict,
RateLimited,
Unavailable,
AgentTimeout,
NotPaired,
}
impl GatewayErrorCode {
pub fn as_str(&self) -> &'static str {
match self {
Self::InvalidRequest => "INVALID_REQUEST",
Self::MethodNotFound => "METHOD_NOT_FOUND",
Self::InvalidParams => "INVALID_PARAMS",
Self::InternalError => "INTERNAL_ERROR",
Self::Unauthorized => "UNAUTHORIZED",
Self::Forbidden => "FORBIDDEN",
Self::NotFound => "NOT_FOUND",
Self::Conflict => "CONFLICT",
Self::RateLimited => "RATE_LIMITED",
Self::Unavailable => "UNAVAILABLE",
Self::AgentTimeout => "AGENT_TIMEOUT",
Self::NotPaired => "NOT_PAIRED",
}
}
}
}
Events
#![allow(unused)]
fn main() {
pub enum GatewayEvent {
/// Device pairing challenge
ConnectChallenge(ConnectChallengePayload),
/// Agent execution event
Agent(AgentEventPayload),
/// Chat execution event
Chat(ChatEventPayload),
/// System presence update
Presence(PresencePayload),
/// Heartbeat tick
Tick(TickPayload),
/// Talk mode changed
TalkMode(TalkModePayload),
/// Gateway shutdown
Shutdown(ShutdownPayload),
/// Health status update
Health(HealthPayload),
/// Heartbeat event
Heartbeat(HeartbeatPayload),
/// Cron job execution
Cron(CronPayload),
/// Node pairing request
NodePairRequested(NodePairRequestPayload),
/// Node pairing resolved
NodePairResolved(NodePairResolvedPayload),
/// Node command invocation
NodeInvokeRequest(NodeInvokeRequestPayload),
/// Device pairing request
DevicePairRequested(DevicePairRequestPayload),
/// Device pairing resolved
DevicePairResolved(DevicePairResolvedPayload),
/// Voice wake triggers changed
VoiceWakeChanged(VoiceWakeChangedPayload),
/// Exec approval requested
ExecApprovalRequested(ExecApprovalRequestPayload),
/// Exec approval resolved
ExecApprovalResolved(ExecApprovalResolvedPayload),
}
}
Implementation
#![allow(unused)]
fn main() {
impl GatewayServer {
pub fn new(config: GatewayConfig) -> Self {
let (event_tx, _) = broadcast::channel(1024);
let mut server = Self {
config,
methods: HashMap::new(),
connections: RwLock::new(HashMap::new()),
event_tx,
};
// Register all methods
server.register_method(Box::new(ChatMethod::new()));
server.register_method(Box::new(ChatStreamMethod::new()));
server.register_method(Box::new(ChatHistoryMethod::new()));
server.register_method(Box::new(AgentMethod::new()));
server.register_method(Box::new(SessionsListMethod::new()));
server.register_method(Box::new(SessionsResolveMethod::new()));
server.register_method(Box::new(SessionsPatchMethod::new()));
server.register_method(Box::new(SessionsDeleteMethod::new()));
// ... register all other methods
server
}
pub fn register_method(&mut self, method: Box<dyn GatewayMethod>) {
self.methods.insert(method.name().to_string(), method);
}
pub async fn handle_request(
&self,
request: RequestFrame,
ctx: &MethodContext,
) -> ResponseFrame {
let method = match self.methods.get(&request.method) {
Some(m) => m,
None => {
return ResponseFrame {
frame_type: "res".to_string(),
id: request.id,
ok: false,
payload: None,
error: Some(ErrorShape {
code: GatewayErrorCode::MethodNotFound.as_str().to_string(),
message: format!("Method '{}' not found", request.method),
details: None,
retryable: Some(false),
retry_after_ms: None,
}),
};
}
};
match method.handle(request.params.unwrap_or_default(), ctx).await {
Ok(payload) => ResponseFrame {
frame_type: "res".to_string(),
id: request.id,
ok: true,
payload: Some(payload),
error: None,
},
Err(e) => ResponseFrame {
frame_type: "res".to_string(),
id: request.id,
ok: false,
payload: None,
error: Some(e.into()),
},
}
}
pub fn broadcast_event(&self, event: GatewayEvent) {
let frame = EventFrame {
frame_type: "event".to_string(),
event: event.event_name().to_string(),
payload: Some(event.to_payload()),
seq: None,
state_version: None,
};
let _ = self.event_tx.send(frame);
}
pub async fn run(&self) -> Result<(), GatewayError> {
let listener = TcpListener::bind(&self.config.bind_address).await?;
loop {
let (stream, addr) = listener.accept().await?;
let server = self.clone();
tokio::spawn(async move {
if let Err(e) = server.handle_connection(stream, addr).await {
tracing::error!("Connection error: {}", e);
}
});
}
}
async fn handle_connection(
&self,
stream: TcpStream,
addr: SocketAddr,
) -> Result<(), GatewayError> {
let ws_stream = tokio_tungstenite::accept_async(stream).await?;
let (write, read) = ws_stream.split();
let connection_id = Uuid::new_v4().to_string();
let (tx, rx) = mpsc::channel(256);
// Add connection
{
let mut connections = self.connections.write().await;
connections.insert(connection_id.clone(), GatewayConnection {
id: connection_id.clone(),
client_info: ClientInfo::default(),
tx,
subscriptions: HashSet::new(),
created_at: Utc::now(),
last_activity: Utc::now(),
});
}
// Handle messages
let read_task = self.handle_incoming(connection_id.clone(), read);
let write_task = Self::handle_outgoing(rx, write);
tokio::select! {
_ = read_task => {}
_ = write_task => {}
}
// Remove connection
{
let mut connections = self.connections.write().await;
connections.remove(&connection_id);
}
Ok(())
}
}
}
Model Providers Specification
Overview
Model providers enable SmartAssist to interact with various LLM APIs including Anthropic, OpenAI, Google, and others.
Provider Trait
#![allow(unused)]
fn main() {
use async_trait::async_trait;
use futures::Stream;
use std::pin::Pin;
/// Model provider trait.
#[async_trait]
pub trait ModelProvider: Send + Sync {
/// Provider identifier.
fn id(&self) -> &str;
/// Provider display name.
fn name(&self) -> &str;
/// List available models.
fn models(&self) -> &[ModelDefinition];
/// Check if provider is configured.
fn is_configured(&self) -> bool;
/// Send a chat completion request.
async fn chat(
&self,
request: ChatRequest,
) -> Result<ChatResponse, ProviderError>;
/// Send a streaming chat request.
async fn chat_stream(
&self,
request: ChatRequest,
) -> Result<Pin<Box<dyn Stream<Item = Result<ChatChunk, ProviderError>> + Send>>, ProviderError>;
}
/// Model definition.
#[derive(Debug, Clone)]
pub struct ModelDefinition {
/// Model identifier.
pub id: String,
/// Display name.
pub name: String,
/// API type.
pub api: ModelApi,
/// Whether model supports reasoning/thinking.
pub reasoning: bool,
/// Supported input types.
pub input_types: Vec<InputType>,
/// Cost per 1M tokens.
pub cost: ModelCost,
/// Context window size.
pub context_window: usize,
/// Maximum output tokens.
pub max_tokens: usize,
/// Custom headers for this model.
pub headers: HashMap<String, String>,
/// Compatibility settings.
pub compat: ModelCompat,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ModelApi {
AnthropicMessages,
OpenAiCompletions,
OpenAiResponses,
GoogleGenerativeAi,
GithubCopilot,
BedrockConverseStream,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum InputType {
Text,
Image,
}
#[derive(Debug, Clone, Copy)]
pub struct ModelCost {
/// Input cost per 1M tokens.
pub input: f64,
/// Output cost per 1M tokens.
pub output: f64,
/// Cache read cost per 1M tokens.
pub cache_read: f64,
/// Cache write cost per 1M tokens.
pub cache_write: f64,
}
#[derive(Debug, Clone, Default)]
pub struct ModelCompat {
/// Supports prompt caching.
pub supports_store: bool,
/// Supports developer/system role.
pub supports_developer_role: bool,
/// Supports reasoning effort control.
pub supports_reasoning_effort: bool,
/// Field name for max tokens.
pub max_tokens_field: MaxTokensField,
}
#[derive(Debug, Clone, Copy, Default)]
pub enum MaxTokensField {
#[default]
MaxTokens,
MaxCompletionTokens,
}
}
Chat Request/Response
#![allow(unused)]
fn main() {
/// Chat completion request.
#[derive(Debug, Clone)]
pub struct ChatRequest {
/// Model identifier.
pub model: String,
/// Conversation messages.
pub messages: Vec<ChatMessage>,
/// System prompt.
pub system: Option<String>,
/// Maximum tokens to generate.
pub max_tokens: Option<usize>,
/// Temperature (0.0 - 1.0).
pub temperature: Option<f32>,
/// Top-p sampling.
pub top_p: Option<f32>,
/// Stop sequences.
pub stop: Option<Vec<String>>,
/// Available tools.
pub tools: Option<Vec<ToolDefinition>>,
/// Tool choice behavior.
pub tool_choice: Option<ToolChoice>,
/// Thinking/reasoning configuration.
pub thinking: Option<ThinkingConfig>,
/// Request timeout.
pub timeout_ms: Option<u64>,
}
/// Chat message.
#[derive(Debug, Clone)]
pub struct ChatMessage {
pub role: ChatRole,
pub content: ChatContent,
pub name: Option<String>,
pub tool_call_id: Option<String>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ChatRole {
System,
User,
Assistant,
Tool,
}
#[derive(Debug, Clone)]
pub enum ChatContent {
Text(String),
Blocks(Vec<ContentBlock>),
}
#[derive(Debug, Clone)]
pub enum ContentBlock {
Text { text: String },
Image { source: ImageSource },
ToolUse { id: String, name: String, input: serde_json::Value },
ToolResult { tool_use_id: String, content: String, is_error: bool },
Thinking { thinking: String },
}
#[derive(Debug, Clone)]
pub enum ImageSource {
Base64 { media_type: String, data: String },
Url { url: String },
}
/// Tool definition for LLM.
#[derive(Debug, Clone)]
pub struct ToolDefinition {
pub name: String,
pub description: String,
pub input_schema: serde_json::Value,
}
#[derive(Debug, Clone)]
pub enum ToolChoice {
Auto,
None,
Required,
Tool { name: String },
}
/// Thinking/reasoning configuration.
#[derive(Debug, Clone)]
pub struct ThinkingConfig {
pub enabled: bool,
pub effort: ThinkingEffort,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ThinkingEffort {
Off,
Minimal,
Low,
Medium,
High,
ExtraHigh,
}
/// Chat completion response.
#[derive(Debug, Clone)]
pub struct ChatResponse {
/// Response ID.
pub id: String,
/// Model used.
pub model: String,
/// Generated content.
pub content: Vec<ContentBlock>,
/// Stop reason.
pub stop_reason: StopReason,
/// Token usage.
pub usage: TokenUsage,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum StopReason {
EndTurn,
MaxTokens,
StopSequence,
ToolUse,
}
/// Token usage statistics.
#[derive(Debug, Clone, Copy, Default)]
pub struct TokenUsage {
pub input_tokens: u64,
pub output_tokens: u64,
pub cache_read_tokens: u64,
pub cache_write_tokens: u64,
}
/// Streaming chat chunk.
#[derive(Debug, Clone)]
pub struct ChatChunk {
pub delta: ChunkDelta,
pub usage: Option<TokenUsage>,
}
#[derive(Debug, Clone)]
pub enum ChunkDelta {
Text { text: String },
ToolUseStart { id: String, name: String },
ToolUseInput { input: String },
Thinking { thinking: String },
Stop { reason: StopReason },
}
}
Provider Configuration
#![allow(unused)]
fn main() {
/// Provider configuration.
#[derive(Debug, Clone)]
pub struct ProviderConfig {
/// Base URL for API.
pub base_url: String,
/// API key.
pub api_key: Option<SecretString>,
/// Authentication mode.
pub auth: AuthMode,
/// API type.
pub api: ModelApi,
/// Custom headers.
pub headers: HashMap<String, String>,
/// Whether to include auth header.
pub auth_header: bool,
/// Available models.
pub models: Vec<ModelDefinition>,
}
#[derive(Debug, Clone, Copy, Default)]
pub enum AuthMode {
#[default]
ApiKey,
AwsSdk,
OAuth,
Token,
}
}
Built-in Providers
Anthropic Provider
#![allow(unused)]
fn main() {
pub struct AnthropicProvider {
config: ProviderConfig,
client: reqwest::Client,
}
impl AnthropicProvider {
pub fn new(api_key: SecretString) -> Self {
Self {
config: ProviderConfig {
base_url: "https://api.anthropic.com".to_string(),
api_key: Some(api_key),
auth: AuthMode::ApiKey,
api: ModelApi::AnthropicMessages,
headers: HashMap::from([
("anthropic-version".to_string(), "2023-06-01".to_string()),
]),
auth_header: true,
models: vec![
ModelDefinition {
id: "claude-sonnet-4-20250514".to_string(),
name: "Claude Sonnet 4".to_string(),
api: ModelApi::AnthropicMessages,
reasoning: true,
input_types: vec![InputType::Text, InputType::Image],
cost: ModelCost {
input: 3.0,
output: 15.0,
cache_read: 0.3,
cache_write: 3.75,
},
context_window: 200000,
max_tokens: 16000,
headers: HashMap::new(),
compat: ModelCompat {
supports_store: true,
supports_developer_role: true,
supports_reasoning_effort: true,
..Default::default()
},
},
ModelDefinition {
id: "claude-opus-4-20250514".to_string(),
name: "Claude Opus 4".to_string(),
api: ModelApi::AnthropicMessages,
reasoning: true,
input_types: vec![InputType::Text, InputType::Image],
cost: ModelCost {
input: 15.0,
output: 75.0,
cache_read: 1.5,
cache_write: 18.75,
},
context_window: 200000,
max_tokens: 32000,
headers: HashMap::new(),
compat: ModelCompat {
supports_store: true,
supports_developer_role: true,
supports_reasoning_effort: true,
..Default::default()
},
},
ModelDefinition {
id: "claude-3-5-haiku-20241022".to_string(),
name: "Claude 3.5 Haiku".to_string(),
api: ModelApi::AnthropicMessages,
reasoning: true,
input_types: vec![InputType::Text, InputType::Image],
cost: ModelCost {
input: 1.0,
output: 5.0,
cache_read: 0.1,
cache_write: 1.25,
},
context_window: 200000,
max_tokens: 8192,
headers: HashMap::new(),
compat: ModelCompat {
supports_store: true,
supports_developer_role: true,
supports_reasoning_effort: true,
..Default::default()
},
},
],
},
client: reqwest::Client::new(),
}
}
}
#[async_trait]
impl ModelProvider for AnthropicProvider {
fn id(&self) -> &str { "anthropic" }
fn name(&self) -> &str { "Anthropic" }
fn models(&self) -> &[ModelDefinition] { &self.config.models }
fn is_configured(&self) -> bool { self.config.api_key.is_some() }
async fn chat(&self, request: ChatRequest) -> Result<ChatResponse, ProviderError> {
let api_key = self.config.api_key.as_ref()
.ok_or(ProviderError::NotConfigured)?;
let body = self.build_request_body(&request)?;
let response = self.client
.post(format!("{}/v1/messages", self.config.base_url))
.header("x-api-key", api_key.expose_secret())
.header("anthropic-version", "2023-06-01")
.json(&body)
.send()
.await?;
if !response.status().is_success() {
let error: serde_json::Value = response.json().await?;
return Err(ProviderError::Api {
code: error["error"]["type"].as_str().unwrap_or("unknown").to_string(),
message: error["error"]["message"].as_str().unwrap_or("Unknown error").to_string(),
});
}
let result: serde_json::Value = response.json().await?;
self.parse_response(result)
}
async fn chat_stream(
&self,
request: ChatRequest,
) -> Result<Pin<Box<dyn Stream<Item = Result<ChatChunk, ProviderError>> + Send>>, ProviderError> {
// Streaming implementation parses SSE events from the provider API
}
}
}
OpenAI Provider
#![allow(unused)]
fn main() {
pub struct OpenAiProvider {
config: ProviderConfig,
client: reqwest::Client,
}
impl OpenAiProvider {
pub fn new(api_key: SecretString) -> Self {
Self {
config: ProviderConfig {
base_url: "https://api.openai.com/v1".to_string(),
api_key: Some(api_key),
auth: AuthMode::ApiKey,
api: ModelApi::OpenAiCompletions,
headers: HashMap::new(),
auth_header: true,
models: vec![
ModelDefinition {
id: "gpt-4o-mini".to_string(),
name: "GPT-4o Mini".to_string(),
api: ModelApi::OpenAiCompletions,
reasoning: false,
input_types: vec![InputType::Text, InputType::Image],
cost: ModelCost {
input: 0.15,
output: 0.6,
cache_read: 0.0,
cache_write: 0.0,
},
context_window: 128000,
max_tokens: 16384,
headers: HashMap::new(),
compat: ModelCompat {
max_tokens_field: MaxTokensField::MaxCompletionTokens,
..Default::default()
},
},
ModelDefinition {
id: "gpt-4o".to_string(),
name: "GPT-4o".to_string(),
api: ModelApi::OpenAiCompletions,
reasoning: false,
input_types: vec![InputType::Text, InputType::Image],
cost: ModelCost {
input: 2.5,
output: 10.0,
cache_read: 0.0,
cache_write: 0.0,
},
context_window: 128000,
max_tokens: 16384,
headers: HashMap::new(),
compat: ModelCompat {
max_tokens_field: MaxTokensField::MaxCompletionTokens,
..Default::default()
},
},
],
},
client: reqwest::Client::new(),
}
}
}
#[async_trait]
impl ModelProvider for OpenAiProvider {
fn id(&self) -> &str { "openai" }
fn name(&self) -> &str { "OpenAI" }
fn models(&self) -> &[ModelDefinition] { &self.config.models }
fn is_configured(&self) -> bool { self.config.api_key.is_some() }
async fn chat(&self, request: ChatRequest) -> Result<ChatResponse, ProviderError> {
let api_key = self.config.api_key.as_ref()
.ok_or(ProviderError::NotConfigured)?;
let body = self.build_request_body(&request)?;
let response = self.client
.post(format!("{}/chat/completions", self.config.base_url))
.bearer_auth(api_key.expose_secret())
.json(&body)
.send()
.await?;
if !response.status().is_success() {
let error: serde_json::Value = response.json().await?;
return Err(ProviderError::Api {
code: error["error"]["type"].as_str().unwrap_or("unknown").to_string(),
message: error["error"]["message"].as_str().unwrap_or("Unknown error").to_string(),
});
}
let result: serde_json::Value = response.json().await?;
self.parse_response(result)
}
async fn chat_stream(
&self,
request: ChatRequest,
) -> Result<Pin<Box<dyn Stream<Item = Result<ChatChunk, ProviderError>> + Send>>, ProviderError> {
// Streaming implementation parses SSE events from the provider API
}
}
}
Google Provider
#![allow(unused)]
fn main() {
pub struct GoogleProvider {
config: ProviderConfig,
client: reqwest::Client,
}
impl GoogleProvider {
pub fn new(api_key: SecretString) -> Self {
Self {
config: ProviderConfig {
base_url: "https://generativelanguage.googleapis.com/v1beta".to_string(),
api_key: Some(api_key),
auth: AuthMode::ApiKey,
api: ModelApi::GoogleGenerativeAi,
headers: HashMap::new(),
auth_header: false,
models: vec![
ModelDefinition {
id: "gemini-2.0-flash".to_string(),
name: "Gemini 2.0 Flash".to_string(),
api: ModelApi::GoogleGenerativeAi,
reasoning: false,
input_types: vec![InputType::Text, InputType::Image],
cost: ModelCost {
input: 0.1,
output: 0.4,
cache_read: 0.0,
cache_write: 0.0,
},
context_window: 1000000,
max_tokens: 8192,
headers: HashMap::new(),
compat: Default::default(),
},
ModelDefinition {
id: "gemini-1.5-pro".to_string(),
name: "Gemini 1.5 Pro".to_string(),
api: ModelApi::GoogleGenerativeAi,
reasoning: false,
input_types: vec![InputType::Text, InputType::Image],
cost: ModelCost {
input: 1.25,
output: 5.0,
cache_read: 0.0,
cache_write: 0.0,
},
context_window: 2000000,
max_tokens: 8192,
headers: HashMap::new(),
compat: Default::default(),
},
],
},
client: reqwest::Client::new(),
}
}
}
}
Provider Registry
#![allow(unused)]
fn main() {
/// Provider registry.
pub struct ProviderRegistry {
providers: HashMap<String, Arc<dyn ModelProvider>>,
}
impl ProviderRegistry {
pub fn new() -> Self {
Self {
providers: HashMap::new(),
}
}
pub fn register(&mut self, provider: Arc<dyn ModelProvider>) {
self.providers.insert(provider.id().to_string(), provider);
}
pub fn get(&self, id: &str) -> Option<Arc<dyn ModelProvider>> {
self.providers.get(id).cloned()
}
pub fn get_for_model(&self, model_id: &str) -> Option<Arc<dyn ModelProvider>> {
// Parse provider/model format
if let Some((provider_id, _)) = model_id.split_once('/') {
return self.get(provider_id);
}
// Search all providers for model
for provider in self.providers.values() {
if provider.models().iter().any(|m| m.id == model_id) {
return Some(provider.clone());
}
}
None
}
pub fn list_models(&self) -> Vec<&ModelDefinition> {
self.providers.values()
.flat_map(|p| p.models().iter())
.collect()
}
pub fn list_configured(&self) -> Vec<&str> {
self.providers.values()
.filter(|p| p.is_configured())
.map(|p| p.id())
.collect()
}
}
/// Create default provider registry.
pub fn create_default_registry(config: &SmartAssistConfig) -> ProviderRegistry {
let mut registry = ProviderRegistry::new();
// Anthropic
if let Some(key) = config.providers.anthropic.api_key.as_ref() {
registry.register(Arc::new(AnthropicProvider::new(key.clone())));
}
// OpenAI
if let Some(key) = config.providers.openai.api_key.as_ref() {
registry.register(Arc::new(OpenAiProvider::new(key.clone())));
}
// Google
if let Some(key) = config.providers.google.api_key.as_ref() {
registry.register(Arc::new(GoogleProvider::new(key.clone())));
}
// Add more providers...
registry
}
}
Error Handling
#![allow(unused)]
fn main() {
#[derive(Debug, thiserror::Error)]
pub enum ProviderError {
#[error("Provider not configured")]
NotConfigured,
#[error("Model not found: {0}")]
ModelNotFound(String),
#[error("API error: {code} - {message}")]
Api { code: String, message: String },
#[error("Rate limited, retry after {retry_after_ms}ms")]
RateLimited { retry_after_ms: u64 },
#[error("Auth error: {0}")]
Auth(String),
#[error("Timeout")]
Timeout,
#[error("Network error: {0}")]
Network(#[from] reqwest::Error),
#[error("Parse error: {0}")]
Parse(#[from] serde_json::Error),
}
}
Auth Profile Management
#![allow(unused)]
fn main() {
/// Auth profile store.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AuthProfileStore {
pub version: u32,
pub profiles: HashMap<String, AuthProfileCredential>,
pub order: Option<HashMap<String, Vec<String>>>,
pub last_good: Option<HashMap<String, String>>,
pub usage_stats: Option<HashMap<String, ProfileUsageStats>>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "type")]
pub enum AuthProfileCredential {
#[serde(rename = "api_key")]
ApiKey {
provider: String,
key: String,
email: Option<String>,
},
#[serde(rename = "token")]
Token {
provider: String,
token: String,
expires: Option<u64>,
email: Option<String>,
},
#[serde(rename = "oauth")]
OAuth {
provider: String,
access_token: Option<String>,
refresh_token: Option<String>,
expires_at: Option<u64>,
client_id: Option<String>,
email: Option<String>,
},
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ProfileUsageStats {
pub last_used: Option<u64>,
pub cooldown_until: Option<u64>,
pub disabled_until: Option<u64>,
pub disabled_reason: Option<FailureReason>,
pub error_count: Option<u32>,
pub failure_counts: Option<HashMap<FailureReason, u32>>,
pub last_failure_at: Option<u64>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum FailureReason {
Auth,
Format,
RateLimit,
Billing,
Timeout,
Unknown,
}
}
Tools Specification Overview
Tool Categories
101 built-in tools organized into 37 categories:
1. File System Tools
read- Read file contentswrite- Write file contentsedit- Edit file with search/replaceglob- Find files by patterngrep- Search file contents
2. Execution Tools
bash- Execute shell commands
3. Web Tools
web_fetch- Fetch and extract web contentweb_search- Search the web
4. Media Tools
image- Analyze images with vision modelstts- Text to speech conversion
5. Messaging Tools
message- Multi-channel messagingtelegram_actions- Telegram-specific actionsdiscord_actions- Discord-specific actionsslack_actions- Slack-specific actions
6. Session Tools
sessions_spawn- Create sub-agent sessionssessions_send- Send messages to sessionssessions_list- List active sessionssessions_history- Get session historysession_status- Get current session status
7. Memory Tools
memory_search- Semantic search of memorymemory_get- Read memory files
8. Automation Tools
cron- Manage scheduled jobsgateway- Gateway management
9. Node/Device Tools
nodes- Control paired devices
10. Browser Tools
browser- Browser automation
11. File Operations Tools
file_copy- Copy files to a new locationfile_move- Move or rename filesfile_stat- Get file/directory informationfile_delete- Delete files or directories
12. Archive Tools
zip- Create/extract zip archivestar- Create/extract tar archives
13. Checksum Tools
file_checksum- Compute file hash (MD5, SHA1, SHA256, SHA512)file_verify- Verify file matches expected hash
14. Template Tools
template- Substitute variables in template stringsformat- Format values (JSON, numbers, bytes, durations)
15. Process Tools
process_list- List running processesprocess_info- Get current process information
16. Utility Tools
sleep- Wait for a specified durationtemp_file- Create a temporary filetemp_dir- Create a temporary directoryecho- Echo a value back
17. Environment Tools
env_get- Get environment variable valueenv_list- List environment variablesenv_check- Check if environment variables exist
18. HTTP Tools
http_request- Make HTTP requests to APIsurl_parse- Parse URL into componentsurl_build- Build URL with query parameters
19. Network Tools
dns_lookup- DNS record lookupport_check- Check TCP port connectivityhttp_ping- Check HTTP/HTTPS endpoint reachabilitynet_info- Get network interface information
20. Notebook Tools
notebook_edit- Edit Jupyter notebook cells
21. Code Intelligence Tools
lsp- Language Server Protocol (go-to-definition, find-references, hover)
22. Task Management Tools
task_create- Create tasks to track worktask_list- List all taskstask_update- Update task statustask_get- Get task details
23. Interactive Tools
ask_user- Ask user questions with multiple choice optionsconfirm- Request user confirmation for actions
24. Planning Tools
enter_plan_mode- Enter planning mode for implementation designexit_plan_mode- Exit planning mode and submit plan
25. Skills Tools
skill- Invoke a registered skillskill_list- List available skills
26. Diagnostics Tools
system_info- Get system informationhealth_check- Check agent health and statusdiagnostic- Run diagnostics to troubleshoot issues
27. Context Management Tools
context_add- Add information to working contextcontext_get- Retrieve entries from working contextcontext_clear- Clear the working context
28. Diff & Patch Tools
diff- Generate diffs between text or filespatch- Preview and apply search/replace changes
29. Git Tools
git_status- Get repository statusgit_log- View commit historygit_diff- View changesgit_branch- List and manage branches
30. JSON/YAML Tools
json_query- Query JSON data using path expressionsjson_transform- Transform JSON (pick, omit, rename, flatten)yaml- Parse and convert between YAML and JSON
31. Encoding & Hashing Tools
base64- Base64 encode/decodehex- Hexadecimal encode/decodehash- Compute hashes (MD5, SHA1, SHA256, SHA512)url_encode- URL encode/decode
32. Time & Date Tools
now- Get current date and timedate_parse- Parse and format date stringsdate_calc- Date calculations (add, subtract, diff)
33. String Manipulation Tools
case- Convert case (upper, lower, camel, snake, kebab)split_join- Split and join stringsreplace- Text replacement with regex supporttrim_pad- Trim whitespace or pad strings
34. Math & Random Tools
calc- Mathematical calculationsrandom- Generate random numbers, strings, or pick itemsuuid- Generate UUIDs
35. Validation Tools
validate- Validate formats (email, URL, JSON, UUID, IP, etc.)is_empty- Check if value is empty, null, or blank
36. Comparison & Assertion Tools
compare- Compare two valuesassert- Assert a condition is truematch- Match text against regex patternsversion_compare- Compare semantic version strings
37. Canvas Tools
canvas- Visual canvas operations
Core Tool Trait
#![allow(unused)]
fn main() {
use async_trait::async_trait;
use serde::{de::DeserializeOwned, Serialize};
use serde_json::Value;
/// Result of a tool execution.
#[derive(Debug, Clone)]
pub struct ToolResult {
/// Whether the tool execution succeeded.
pub ok: bool,
/// Result content (for success).
pub content: Option<Vec<ToolContent>>,
/// Error reason (for failure).
pub error: Option<String>,
/// Structured details.
pub details: Option<Value>,
}
/// Content types that tools can return.
#[derive(Debug, Clone)]
pub enum ToolContent {
/// Text content.
Text(String),
/// Image content (base64 or path).
Image {
data: String,
mime_type: String,
},
/// File content.
File {
path: String,
mime_type: Option<String>,
},
}
impl ToolResult {
pub fn success(content: impl Into<String>) -> Self {
Self {
ok: true,
content: Some(vec![ToolContent::Text(content.into())]),
error: None,
details: None,
}
}
pub fn failure(error: impl Into<String>) -> Self {
Self {
ok: false,
content: None,
error: Some(error.into()),
details: None,
}
}
pub fn with_details(mut self, details: Value) -> Self {
self.details = Some(details);
self
}
}
/// Tool definition trait.
#[async_trait]
pub trait Tool: Send + Sync {
/// Tool name (unique identifier).
fn name(&self) -> &str;
/// Tool description for the LLM.
fn description(&self) -> &str;
/// JSON Schema for input parameters.
fn input_schema(&self) -> Value;
/// Execute the tool.
async fn execute(&self, params: Value, ctx: &ToolContext) -> ToolResult;
/// Whether this tool requires approval for given params.
fn requires_approval(&self, params: &Value) -> bool {
false
}
/// Tool group for categorization.
fn group(&self) -> ToolGroup {
ToolGroup::General
}
}
/// Tool execution context.
#[derive(Debug, Clone)]
pub struct ToolContext {
/// Current working directory.
pub cwd: PathBuf,
/// Sandbox root directory.
pub sandbox_root: PathBuf,
/// Agent ID.
pub agent_id: String,
/// Session key.
pub session_key: String,
/// Channel name.
pub channel: Option<String>,
/// Account ID.
pub account_id: Option<String>,
/// Whether sandboxing is enabled.
pub sandboxed: bool,
/// Configuration reference.
pub config: Arc<SmartAssistConfig>,
}
/// Tool group categories.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ToolGroup {
FileSystem,
Execution,
Web,
Media,
Messaging,
Session,
Memory,
Automation,
Device,
Browser,
General,
}
}
Tool Registry
#![allow(unused)]
fn main() {
pub struct ToolRegistry {
tools: HashMap<String, Arc<dyn Tool>>,
groups: HashMap<ToolGroup, Vec<String>>,
}
impl ToolRegistry {
pub fn new() -> Self {
Self {
tools: HashMap::new(),
groups: HashMap::new(),
}
}
pub fn register(&mut self, tool: Arc<dyn Tool>) {
let name = tool.name().to_string();
let group = tool.group();
self.tools.insert(name.clone(), tool);
self.groups.entry(group).or_default().push(name);
}
pub fn get(&self, name: &str) -> Option<Arc<dyn Tool>> {
self.tools.get(name).cloned()
}
pub fn list(&self) -> Vec<&str> {
self.tools.keys().map(|s| s.as_str()).collect()
}
pub fn list_by_group(&self, group: ToolGroup) -> Vec<&str> {
self.groups.get(&group)
.map(|v| v.iter().map(|s| s.as_str()).collect())
.unwrap_or_default()
}
pub fn to_openai_tools(&self) -> Vec<Value> {
self.tools.values().map(|t| {
serde_json::json!({
"type": "function",
"function": {
"name": t.name(),
"description": t.description(),
"parameters": t.input_schema(),
}
})
}).collect()
}
pub fn to_anthropic_tools(&self) -> Vec<Value> {
self.tools.values().map(|t| {
serde_json::json!({
"name": t.name(),
"description": t.description(),
"input_schema": t.input_schema(),
})
}).collect()
}
}
/// Create the default tool registry with all built-in tools.
pub fn create_default_registry(config: &SmartAssistConfig) -> ToolRegistry {
let mut registry = ToolRegistry::new();
// File system tools
registry.register(Arc::new(ReadTool::new()));
registry.register(Arc::new(WriteTool::new()));
registry.register(Arc::new(EditTool::new()));
registry.register(Arc::new(GlobTool::new()));
registry.register(Arc::new(GrepTool::new()));
// Execution tools
registry.register(Arc::new(BashTool::new(config)));
// Web tools
if config.tools.web.fetch.enabled.unwrap_or(true) {
registry.register(Arc::new(WebFetchTool::new(config)));
}
if config.tools.web.search.enabled.unwrap_or(true) {
registry.register(Arc::new(WebSearchTool::new(config)));
}
// Media tools
registry.register(Arc::new(ImageTool::new(config)));
if config.tools.tts.enabled.unwrap_or(false) {
registry.register(Arc::new(TtsTool::new(config)));
}
// Session tools
registry.register(Arc::new(SessionsSpawnTool::new()));
registry.register(Arc::new(SessionsSendTool::new()));
registry.register(Arc::new(SessionsListTool::new()));
registry.register(Arc::new(SessionsHistoryTool::new()));
registry.register(Arc::new(SessionStatusTool::new()));
// Memory tools
if config.memory.enabled.unwrap_or(true) {
registry.register(Arc::new(MemorySearchTool::new(config)));
registry.register(Arc::new(MemoryGetTool::new(config)));
}
// Automation tools
registry.register(Arc::new(CronTool::new()));
registry.register(Arc::new(GatewayTool::new()));
// Device tools
registry.register(Arc::new(NodesTool::new()));
// Browser tools
if config.tools.browser.enabled.unwrap_or(false) {
registry.register(Arc::new(BrowserTool::new(config)));
}
// Channel-specific tools based on enabled channels
for channel_id in config.channels.enabled_channels() {
match channel_id.as_str() {
"telegram" => registry.register(Arc::new(TelegramActionsTool::new())),
"discord" => registry.register(Arc::new(DiscordActionsTool::new())),
"slack" => registry.register(Arc::new(SlackActionsTool::new())),
_ => {}
}
}
// Universal message tool
registry.register(Arc::new(MessageTool::new(config)));
registry
}
}
Approval System
#![allow(unused)]
fn main() {
/// Approval request for tool execution.
#[derive(Debug, Clone)]
pub struct ApprovalRequest {
pub id: String,
pub tool_name: String,
pub params: Value,
pub command: Option<String>,
pub cwd: Option<String>,
pub agent_id: String,
pub session_key: String,
pub timeout_ms: u64,
}
/// Approval decision.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ApprovalDecision {
Approved,
Denied,
TimedOut,
}
/// Approval handler trait.
#[async_trait]
pub trait ApprovalHandler: Send + Sync {
async fn request_approval(&self, request: ApprovalRequest) -> ApprovalDecision;
}
/// Check if a command requires approval.
pub fn command_requires_approval(command: &str, config: &ApprovalConfig) -> bool {
// Check allowlist
for pattern in &config.allowlist {
if pattern_matches(command, pattern) {
return false;
}
}
// Check dangerous commands
let dangerous_patterns = [
r"rm\s+-rf",
r"sudo\s+",
r"chmod\s+",
r"chown\s+",
r"mkfs",
r"dd\s+",
r">\s*/dev/",
r"curl.*\|\s*sh",
r"wget.*\|\s*sh",
];
for pattern in &dangerous_patterns {
if regex::Regex::new(pattern).unwrap().is_match(command) {
return true;
}
}
// Default based on security level
match config.security_level {
SecurityLevel::Strict => true,
SecurityLevel::Normal => false,
SecurityLevel::Permissive => false,
}
}
#[derive(Debug, Clone, Copy)]
pub enum SecurityLevel {
Strict,
Normal,
Permissive,
}
}
Parameter Utilities
#![allow(unused)]
fn main() {
/// Read a required string parameter.
pub fn read_string_param(params: &Value, key: &str) -> Result<String, ToolError> {
params.get(key)
.and_then(|v| v.as_str())
.map(|s| s.to_string())
.ok_or_else(|| ToolError::MissingParam(key.to_string()))
}
/// Read an optional string parameter.
pub fn read_optional_string(params: &Value, key: &str) -> Option<String> {
params.get(key)
.and_then(|v| v.as_str())
.map(|s| s.to_string())
}
/// Read an optional number parameter.
pub fn read_optional_number(params: &Value, key: &str) -> Option<f64> {
params.get(key).and_then(|v| v.as_f64())
}
/// Read an optional boolean parameter.
pub fn read_optional_bool(params: &Value, key: &str) -> Option<bool> {
params.get(key).and_then(|v| v.as_bool())
}
/// Read an optional array parameter.
pub fn read_optional_array<T: DeserializeOwned>(params: &Value, key: &str) -> Option<Vec<T>> {
params.get(key)
.and_then(|v| serde_json::from_value(v.clone()).ok())
}
#[derive(Debug, thiserror::Error)]
pub enum ToolError {
#[error("Missing required parameter: {0}")]
MissingParam(String),
#[error("Invalid parameter type for {0}: expected {1}")]
InvalidType(String, String),
#[error("Execution failed: {0}")]
ExecutionFailed(String),
#[error("Sandbox violation: {0}")]
SandboxViolation(String),
#[error("Timeout")]
Timeout,
#[error("Approval denied")]
ApprovalDenied,
#[error("Not found: {0}")]
NotFound(String),
#[error("IO error: {0}")]
Io(#[from] std::io::Error),
}
}
Timeout Handling
#![allow(unused)]
fn main() {
/// Default timeouts by tool category.
pub const DEFAULT_TIMEOUTS: &[(ToolGroup, u64)] = &[
(ToolGroup::FileSystem, 30_000),
(ToolGroup::Execution, 120_000),
(ToolGroup::Web, 60_000),
(ToolGroup::Media, 60_000),
(ToolGroup::Messaging, 30_000),
(ToolGroup::Session, 30_000),
(ToolGroup::Memory, 30_000),
(ToolGroup::Automation, 30_000),
(ToolGroup::Device, 30_000),
(ToolGroup::Browser, 120_000),
(ToolGroup::General, 30_000),
];
/// Execute a tool with timeout.
pub async fn execute_with_timeout<F, T>(
future: F,
timeout_ms: u64,
) -> Result<T, ToolError>
where
F: std::future::Future<Output = T>,
{
tokio::time::timeout(
Duration::from_millis(timeout_ms),
future,
)
.await
.map_err(|_| ToolError::Timeout)
}
}
Plugin SDK Specification
Overview
The Plugin SDK allows extending SmartAssist with custom functionality including tools, hooks, channels, providers, and HTTP routes.
Plugin Definition
#![allow(unused)]
fn main() {
/// Plugin definition structure.
pub struct PluginDefinition {
/// Unique plugin identifier.
pub id: String,
/// Human-readable name.
pub name: String,
/// Plugin description.
pub description: Option<String>,
/// Version string.
pub version: Option<String>,
/// Plugin kind.
pub kind: Option<PluginKind>,
/// Configuration schema.
pub config_schema: Option<PluginConfigSchema>,
}
/// Plugin kinds.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PluginKind {
Memory,
Channel,
Provider,
Tool,
General,
}
/// Plugin manifest (from smartassist.plugin.json).
#[derive(Debug, Deserialize)]
pub struct PluginManifest {
pub id: String,
pub name: Option<String>,
pub description: Option<String>,
pub version: Option<String>,
pub kind: Option<String>,
pub channels: Option<Vec<String>>,
pub providers: Option<Vec<String>>,
pub skills: Option<Vec<String>>,
#[serde(rename = "configSchema")]
pub config_schema: Option<serde_json::Value>,
#[serde(rename = "uiHints")]
pub ui_hints: Option<HashMap<String, PluginConfigUiHint>>,
}
/// UI hints for configuration fields.
#[derive(Debug, Clone, Deserialize)]
pub struct PluginConfigUiHint {
pub label: Option<String>,
pub help: Option<String>,
pub advanced: Option<bool>,
pub sensitive: Option<bool>,
pub placeholder: Option<String>,
}
}
Plugin API
#![allow(unused)]
fn main() {
/// The main plugin API provided to plugins.
pub struct PluginApi {
/// Plugin identifier.
pub id: String,
/// Plugin name.
pub name: String,
/// Plugin version.
pub version: Option<String>,
/// Plugin description.
pub description: Option<String>,
/// Source path.
pub source: PathBuf,
/// Current configuration.
pub config: Arc<SmartAssistConfig>,
/// Plugin-specific configuration.
pub plugin_config: Option<serde_json::Value>,
/// Runtime reference.
pub runtime: Arc<PluginRuntime>,
/// Logger instance.
pub logger: PluginLogger,
/// Internal registrations.
registrations: Arc<Mutex<PluginRegistrations>>,
}
impl PluginApi {
/// Register a tool.
pub fn register_tool<T: Tool + 'static>(&self, tool: T) {
self.registrations.lock().unwrap().tools.push(Arc::new(tool));
}
/// Register a tool factory.
pub fn register_tool_factory(&self, factory: Box<dyn PluginToolFactory>) {
self.registrations.lock().unwrap().tool_factories.push(factory);
}
/// Register a lifecycle hook.
pub fn register_hook(&self, events: Vec<HookEvent>, handler: Box<dyn HookHandler>) {
let mut regs = self.registrations.lock().unwrap();
for event in events {
regs.hooks.entry(event).or_default().push(handler.clone());
}
}
/// Register an HTTP handler.
pub fn register_http_handler(&self, handler: Box<dyn HttpHandler>) {
self.registrations.lock().unwrap().http_handlers.push(handler);
}
/// Register an HTTP route.
pub fn register_http_route(&self, path: &str, handler: Box<dyn HttpRouteHandler>) {
self.registrations.lock().unwrap().http_routes.insert(path.to_string(), handler);
}
/// Register a channel.
pub fn register_channel(&self, channel: Box<dyn Channel>) {
self.registrations.lock().unwrap().channels.push(channel);
}
/// Register a gateway method.
pub fn register_gateway_method(&self, method: Box<dyn GatewayMethod>) {
self.registrations.lock().unwrap().gateway_methods.push(method);
}
/// Register a CLI command.
pub fn register_cli(&self, command: PluginCliCommand) {
self.registrations.lock().unwrap().cli_commands.push(command);
}
/// Register a service.
pub fn register_service(&self, service: Box<dyn PluginService>) {
self.registrations.lock().unwrap().services.push(service);
}
/// Register a provider.
pub fn register_provider(&self, provider: ProviderPlugin) {
self.registrations.lock().unwrap().providers.push(provider);
}
/// Register a slash command.
pub fn register_command(&self, command: PluginCommandDefinition) {
self.registrations.lock().unwrap().commands.push(command);
}
/// Resolve a path relative to plugin source.
pub fn resolve_path(&self, input: &str) -> PathBuf {
if Path::new(input).is_absolute() {
PathBuf::from(input)
} else {
self.source.join(input)
}
}
/// Register a hook with the on() syntax.
pub fn on<H: HookHandler + 'static>(&self, event: HookEvent, handler: H) {
self.register_hook(vec![event], Box::new(handler));
}
}
struct PluginRegistrations {
tools: Vec<Arc<dyn Tool>>,
tool_factories: Vec<Box<dyn PluginToolFactory>>,
hooks: HashMap<HookEvent, Vec<Box<dyn HookHandler>>>,
http_handlers: Vec<Box<dyn HttpHandler>>,
http_routes: HashMap<String, Box<dyn HttpRouteHandler>>,
channels: Vec<Box<dyn Channel>>,
gateway_methods: Vec<Box<dyn GatewayMethod>>,
cli_commands: Vec<PluginCliCommand>,
services: Vec<Box<dyn PluginService>>,
providers: Vec<ProviderPlugin>,
commands: Vec<PluginCommandDefinition>,
}
}
Lifecycle Hooks
#![allow(unused)]
fn main() {
/// Hook events.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum HookEvent {
/// Before agent starts processing.
BeforeAgentStart,
/// After agent finishes.
AgentEnd,
/// Before context compaction.
BeforeCompaction,
/// After context compaction.
AfterCompaction,
/// Message received from channel.
MessageReceived,
/// Message about to be sent.
MessageSending,
/// Message sent successfully.
MessageSent,
/// Before tool is called.
BeforeToolCall,
/// After tool completes.
AfterToolCall,
/// Tool result being persisted.
ToolResultPersist,
/// Session started.
SessionStart,
/// Session ended.
SessionEnd,
/// Gateway starting.
GatewayStart,
/// Gateway stopping.
GatewayStop,
}
/// Hook handler trait.
#[async_trait]
pub trait HookHandler: Send + Sync {
async fn handle(&self, event: &HookEventData) -> HookResult;
}
/// Hook event data.
#[derive(Debug)]
pub enum HookEventData {
BeforeAgentStart(BeforeAgentStartEvent),
AgentEnd(AgentEndEvent),
BeforeCompaction(CompactionEvent),
AfterCompaction(CompactionEvent),
MessageReceived(MessageEvent),
MessageSending(MessageEvent),
MessageSent(MessageEvent),
BeforeToolCall(ToolCallEvent),
AfterToolCall(AfterToolCallEvent),
ToolResultPersist(ToolResultEvent),
SessionStart(SessionEvent),
SessionEnd(SessionEvent),
GatewayStart(GatewayEvent),
GatewayStop(GatewayEvent),
}
/// Hook result.
pub struct HookResult {
/// Whether to continue processing.
pub continue_processing: bool,
/// Modified data (if applicable).
pub modified_data: Option<serde_json::Value>,
/// Error message (if hook failed).
pub error: Option<String>,
}
/// Before agent start event.
#[derive(Debug)]
pub struct BeforeAgentStartEvent {
pub agent_id: String,
pub session_key: String,
pub system_prompt: String,
pub model: String,
}
/// Tool call event.
#[derive(Debug)]
pub struct ToolCallEvent {
pub tool_name: String,
pub params: serde_json::Value,
pub session_key: String,
}
/// After tool call event.
#[derive(Debug)]
pub struct AfterToolCallEvent {
pub tool_name: String,
pub params: serde_json::Value,
pub result: Option<serde_json::Value>,
pub error: Option<String>,
pub duration_ms: u64,
}
}
Plugin Services
#![allow(unused)]
fn main() {
/// Plugin service trait.
#[async_trait]
pub trait PluginService: Send + Sync {
/// Service identifier.
fn id(&self) -> &str;
/// Start the service.
async fn start(&self, ctx: &PluginServiceContext) -> Result<(), PluginError>;
/// Stop the service.
async fn stop(&self, ctx: &PluginServiceContext) -> Result<(), PluginError>;
}
/// Service context.
pub struct PluginServiceContext {
pub config: Arc<SmartAssistConfig>,
pub plugin_config: Option<serde_json::Value>,
pub runtime: Arc<PluginRuntime>,
pub logger: PluginLogger,
}
}
HTTP Routes
#![allow(unused)]
fn main() {
/// HTTP route handler trait.
#[async_trait]
pub trait HttpRouteHandler: Send + Sync {
async fn handle(
&self,
request: HttpRequest,
response: &mut HttpResponse,
) -> Result<(), PluginError>;
}
/// Generic HTTP handler for all requests.
#[async_trait]
pub trait HttpHandler: Send + Sync {
/// Returns true if this handler handled the request.
async fn handle(
&self,
request: &HttpRequest,
response: &mut HttpResponse,
) -> Result<bool, PluginError>;
}
/// HTTP request wrapper.
pub struct HttpRequest {
pub method: String,
pub path: String,
pub headers: HashMap<String, String>,
pub query: HashMap<String, String>,
pub body: Vec<u8>,
}
/// HTTP response wrapper.
pub struct HttpResponse {
pub status: u16,
pub headers: HashMap<String, String>,
pub body: Vec<u8>,
}
impl HttpResponse {
pub fn json<T: Serialize>(&mut self, value: &T) -> Result<(), serde_json::Error> {
self.headers.insert("Content-Type".to_string(), "application/json".to_string());
self.body = serde_json::to_vec(value)?;
Ok(())
}
pub fn text(&mut self, text: impl Into<String>) {
self.headers.insert("Content-Type".to_string(), "text/plain".to_string());
self.body = text.into().into_bytes();
}
}
}
Plugin Commands
#![allow(unused)]
fn main() {
/// Slash command definition.
pub struct PluginCommandDefinition {
/// Command name (without leading slash).
pub name: String,
/// Command description.
pub description: String,
/// Whether command accepts arguments.
pub accepts_args: bool,
/// Whether auth is required.
pub require_auth: bool,
/// Command handler.
pub handler: Box<dyn PluginCommandHandler>,
}
/// Command handler trait.
#[async_trait]
pub trait PluginCommandHandler: Send + Sync {
async fn handle(&self, ctx: &PluginCommandContext) -> PluginCommandResult;
}
/// Command context.
pub struct PluginCommandContext {
pub sender_id: Option<String>,
pub channel: String,
pub is_authorized_sender: bool,
pub args: Option<String>,
pub command_body: String,
pub config: Arc<SmartAssistConfig>,
}
/// Command result.
pub struct PluginCommandResult {
pub text: Option<String>,
pub error: Option<String>,
pub handled: bool,
}
}
Plugin Runtime
#![allow(unused)]
fn main() {
/// Runtime capabilities exposed to plugins.
pub struct PluginRuntime {
config_loader: Arc<ConfigLoader>,
media_handler: Arc<MediaHandler>,
channel_registry: Arc<ChannelRegistry>,
session_manager: Arc<SessionManager>,
}
impl PluginRuntime {
/// Load configuration.
pub async fn load_config(&self) -> Result<SmartAssistConfig, PluginError> {
self.config_loader.load().await
}
/// Write configuration.
pub async fn write_config(&self, config: &SmartAssistConfig) -> Result<(), PluginError> {
self.config_loader.write(config).await
}
/// Send a system event.
pub async fn enqueue_system_event(&self, text: &str) -> Result<(), PluginError> {
// ...
Ok(())
}
/// Run a command with timeout.
pub async fn run_command_with_timeout(
&self,
command: &str,
timeout_ms: u64,
) -> Result<CommandOutput, PluginError> {
// ...
todo!()
}
/// Load web media.
pub async fn load_web_media(&self, url: &str) -> Result<Vec<u8>, PluginError> {
self.media_handler.load_from_url(url).await
}
/// Detect MIME type.
pub fn detect_mime(&self, data: &[u8]) -> Option<String> {
self.media_handler.detect_mime(data)
}
/// Resize image to JPEG.
pub async fn resize_to_jpeg(
&self,
data: &[u8],
max_width: u32,
max_height: u32,
) -> Result<Vec<u8>, PluginError> {
self.media_handler.resize_to_jpeg(data, max_width, max_height).await
}
/// Chunk markdown text.
pub fn chunk_markdown_text(&self, text: &str, max_length: usize) -> Vec<String> {
// ...
todo!()
}
/// Get channel by ID.
pub fn get_channel(&self, id: &str) -> Option<Arc<dyn Channel>> {
self.channel_registry.get(id)
}
/// Send message via channel.
pub async fn send_message(
&self,
channel_id: &str,
message: OutboundMessage,
) -> Result<SendResult, PluginError> {
let channel = self.channel_registry.get(channel_id)
.ok_or(PluginError::ChannelNotFound(channel_id.to_string()))?;
channel.send(message).await.map_err(Into::into)
}
/// Get session.
pub async fn get_session(&self, key: &SessionKey) -> Result<Session, PluginError> {
self.session_manager.load(key).await.map_err(Into::into)
}
/// Resolve state directory.
pub fn resolve_state_dir(&self) -> PathBuf {
dirs::data_dir()
.unwrap_or_else(|| PathBuf::from("."))
.join("smartassist")
}
}
}
Plugin Loader
#![allow(unused)]
fn main() {
/// Plugin loader.
pub struct PluginLoader {
plugin_dirs: Vec<PathBuf>,
loaded: HashMap<String, LoadedPlugin>,
}
/// Loaded plugin.
pub struct LoadedPlugin {
pub manifest: PluginManifest,
pub source: PathBuf,
pub registrations: PluginRegistrations,
pub enabled: bool,
}
impl PluginLoader {
pub fn new(plugin_dirs: Vec<PathBuf>) -> Self {
Self {
plugin_dirs,
loaded: HashMap::new(),
}
}
/// Discover available plugins.
pub async fn discover(&mut self) -> Result<Vec<PluginManifest>, PluginError> {
let mut manifests = Vec::new();
for dir in &self.plugin_dirs {
if !dir.exists() {
continue;
}
let mut entries = tokio::fs::read_dir(dir).await?;
while let Some(entry) = entries.next_entry().await? {
let path = entry.path();
let manifest_path = path.join("smartassist.plugin.json");
if manifest_path.exists() {
let content = tokio::fs::read_to_string(&manifest_path).await?;
let manifest: PluginManifest = serde_json::from_str(&content)?;
manifests.push(manifest);
}
}
}
Ok(manifests)
}
/// Load a plugin.
pub async fn load(
&mut self,
plugin_id: &str,
config: &SmartAssistConfig,
runtime: Arc<PluginRuntime>,
) -> Result<(), PluginError> {
// Find plugin
let source = self.find_plugin_source(plugin_id)?;
// Load manifest
let manifest_path = source.join("smartassist.plugin.json");
let manifest: PluginManifest = serde_json::from_str(
&tokio::fs::read_to_string(&manifest_path).await?
)?;
// Get plugin-specific config
let plugin_config = config.plugins.get(plugin_id).cloned();
// Validate config against schema if present
if let Some(schema) = &manifest.config_schema {
if let Some(ref pc) = plugin_config {
// Validate...
}
}
// Create API
let api = PluginApi {
id: plugin_id.to_string(),
name: manifest.name.clone().unwrap_or(plugin_id.to_string()),
version: manifest.version.clone(),
description: manifest.description.clone(),
source: source.clone(),
config: Arc::new(config.clone()),
plugin_config,
runtime,
logger: PluginLogger::new(plugin_id),
registrations: Arc::new(Mutex::new(PluginRegistrations::default())),
};
// Load and execute plugin entry point
// (In Rust, plugins would be dynamic libraries or WASM modules)
// ...
// Store loaded plugin
self.loaded.insert(plugin_id.to_string(), LoadedPlugin {
manifest,
source,
registrations: api.registrations.lock().unwrap().clone(),
enabled: true,
});
Ok(())
}
fn find_plugin_source(&self, plugin_id: &str) -> Result<PathBuf, PluginError> {
for dir in &self.plugin_dirs {
let path = dir.join(plugin_id);
if path.exists() {
return Ok(path);
}
}
Err(PluginError::NotFound(plugin_id.to_string()))
}
}
}
Error Handling
#![allow(unused)]
fn main() {
#[derive(Debug, thiserror::Error)]
pub enum PluginError {
#[error("Plugin not found: {0}")]
NotFound(String),
#[error("Plugin load failed: {0}")]
LoadFailed(String),
#[error("Config validation failed: {0}")]
ConfigValidation(String),
#[error("Hook failed: {0}")]
HookFailed(String),
#[error("Channel not found: {0}")]
ChannelNotFound(String),
#[error("IO error: {0}")]
Io(#[from] std::io::Error),
#[error("JSON error: {0}")]
Json(#[from] serde_json::Error),
}
}
Channel Traits Specification
Core Channel Trait
#![allow(unused)]
fn main() {
#[async_trait]
pub trait Channel: Send + Sync {
/// Unique channel identifier (e.g., "telegram", "discord")
fn id(&self) -> &str;
/// Human-readable channel name
fn name(&self) -> &str;
/// Channel capabilities
fn capabilities(&self) -> ChannelCapabilities;
/// Connect to the channel service
async fn connect(&mut self) -> Result<()>;
/// Disconnect from the channel service
async fn disconnect(&mut self) -> Result<()>;
/// Check if connected
fn is_connected(&self) -> bool;
/// Get channel health status
async fn health(&self) -> Result<ChannelHealth>;
/// Send an outbound message
async fn send(&self, message: OutboundMessage) -> Result<SendResult>;
/// Edit a previously sent message
async fn edit(&self, target: MessageTarget, new_content: &str) -> Result<()>;
/// Delete a message
async fn delete(&self, target: MessageTarget) -> Result<()>;
/// React to a message
async fn react(&self, target: MessageTarget, emoji: &str) -> Result<()>;
/// Remove a reaction
async fn unreact(&self, target: MessageTarget, emoji: &str) -> Result<()>;
/// Start typing indicator
async fn typing(&self, chat_id: &str) -> Result<()>;
/// Get message handler stream
fn messages(&self) -> Pin<Box<dyn Stream<Item = InboundMessage> + Send>>;
}
}
Channel Capabilities
#![allow(unused)]
fn main() {
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ChannelCapabilities {
/// Supported chat types
pub chat_types: Vec<ChatType>,
/// Media capabilities
pub media: MediaCapabilities,
/// Feature flags
pub features: ChannelFeatures,
/// Message limits
pub limits: ChannelLimits,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MediaCapabilities {
pub images: bool,
pub audio: bool,
pub video: bool,
pub files: bool,
pub stickers: bool,
pub voice_notes: bool,
pub max_file_size_mb: u32,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ChannelFeatures {
pub reactions: bool,
pub threads: bool,
pub edits: bool,
pub deletes: bool,
pub typing_indicators: bool,
pub read_receipts: bool,
pub mentions: bool,
pub polls: bool,
pub buttons: bool,
pub inline_queries: bool,
pub commands: bool,
pub markdown: bool,
pub html: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ChannelLimits {
pub max_message_length: usize,
pub max_caption_length: usize,
pub max_buttons_per_row: usize,
pub max_button_rows: usize,
}
}
Message Types
#![allow(unused)]
fn main() {
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct InboundMessage {
/// Unique message ID
pub id: MessageId,
/// Timestamp
pub timestamp: DateTime<Utc>,
/// Channel identifier
pub channel: String,
/// Account/bot identifier
pub account_id: String,
/// Sender information
pub sender: SenderInfo,
/// Chat information
pub chat: ChatInfo,
/// Message text content
pub text: String,
/// Media attachments
pub media: Vec<MediaAttachment>,
/// Quoted/replied message
pub quote: Option<QuotedMessage>,
/// Thread information
pub thread: Option<ThreadInfo>,
/// Channel-specific metadata
pub metadata: serde_json::Value,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct OutboundMessage {
/// Target chat
pub chat_id: String,
/// Message text
pub text: String,
/// Media attachments
pub media: Vec<MediaAttachment>,
/// Reply to message ID
pub reply_to: Option<String>,
/// Thread ID
pub thread_id: Option<String>,
/// Inline buttons
pub buttons: Option<Vec<Vec<Button>>>,
/// Parse mode (markdown, html, none)
pub parse_mode: Option<ParseMode>,
/// Disable link previews
pub disable_preview: bool,
/// Silent/notification-less
pub silent: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SenderInfo {
pub id: String,
pub username: Option<String>,
pub display_name: Option<String>,
pub phone_number: Option<String>,
pub is_bot: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ChatInfo {
pub id: String,
pub chat_type: ChatType,
pub title: Option<String>,
pub guild_id: Option<String>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum ChatType {
Direct,
Group,
Channel,
Thread,
Forum,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MediaAttachment {
pub attachment_type: MediaType,
pub url: Option<String>,
pub file_id: Option<String>,
pub file_path: Option<PathBuf>,
pub mime_type: Option<String>,
pub file_name: Option<String>,
pub file_size: Option<u64>,
pub caption: Option<String>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum MediaType {
Image,
Audio,
Video,
Document,
Sticker,
Voice,
Animation,
}
}
Channel Configuration
#![allow(unused)]
fn main() {
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ChannelConfig {
/// Whether channel is enabled
pub enabled: bool,
/// Account configurations
pub accounts: Vec<AccountConfig>,
/// Default agent for this channel
pub default_agent: Option<String>,
/// Allowlist configuration
pub allowlist: AllowlistConfig,
/// DM policy
pub dm_policy: MessagePolicy,
/// Group policy
pub group_policy: MessagePolicy,
/// Channel-specific settings
pub settings: serde_json::Value,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AccountConfig {
pub id: String,
pub token: Option<SecretString>,
pub api_key: Option<SecretString>,
pub phone_number: Option<String>,
pub enabled: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AllowlistConfig {
pub enabled: bool,
pub mode: AllowlistMode,
pub users: Vec<String>,
pub chats: Vec<String>,
pub guilds: Vec<String>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum AllowlistMode {
Allow,
Deny,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum MessagePolicy {
Allow,
Deny,
RequireAllowlist,
}
}
Channel Registry
#![allow(unused)]
fn main() {
pub struct ChannelRegistry {
channels: HashMap<String, Arc<dyn Channel>>,
configs: HashMap<String, ChannelConfig>,
}
impl ChannelRegistry {
pub fn new() -> Self;
pub fn register(&mut self, channel: Arc<dyn Channel>, config: ChannelConfig);
pub fn unregister(&mut self, channel_id: &str);
pub fn get(&self, channel_id: &str) -> Option<Arc<dyn Channel>>;
pub fn list(&self) -> Vec<&str>;
pub fn list_enabled(&self) -> Vec<&str>;
pub async fn connect_all(&self) -> Result<()>;
pub async fn disconnect_all(&self) -> Result<()>;
pub async fn health_all(&self) -> HashMap<String, ChannelHealth>;
}
}
Message Router
#![allow(unused)]
fn main() {
pub struct MessageRouter {
rules: Vec<RoutingRule>,
default_agent: Option<AgentId>,
}
#[derive(Debug, Clone)]
pub struct RoutingRule {
pub id: String,
pub priority: i32,
pub conditions: RoutingConditions,
pub agent_id: AgentId,
}
#[derive(Debug, Clone, Default)]
pub struct RoutingConditions {
pub channel: Option<String>,
pub account: Option<String>,
pub chat_id: Option<String>,
pub chat_type: Option<ChatType>,
pub sender_id: Option<String>,
pub guild_id: Option<String>,
pub text_pattern: Option<String>,
}
impl MessageRouter {
pub fn new() -> Self;
pub fn with_default_agent(self, agent: AgentId) -> Self;
pub fn add_rule(&mut self, rule: RoutingRule);
pub fn route(&self, message: &InboundMessage) -> Result<AgentId>;
}
}
Delivery Queue
#![allow(unused)]
fn main() {
pub struct DeliveryQueue {
queue: VecDeque<QueuedMessage>,
retry_policy: RetryPolicy,
max_queue_size: usize,
}
#[derive(Debug, Clone)]
pub struct QueuedMessage {
pub id: String,
pub message: OutboundMessage,
pub channel_id: String,
pub account_id: String,
pub attempts: u32,
pub created_at: DateTime<Utc>,
pub next_retry_at: Option<DateTime<Utc>>,
pub status: DeliveryStatus,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DeliveryStatus {
Pending,
InFlight,
Delivered,
Failed,
Cancelled,
}
#[derive(Debug, Clone)]
pub struct RetryPolicy {
pub max_attempts: u32,
pub base_delay_ms: u64,
pub max_delay_ms: u64,
pub exponential_backoff: bool,
}
impl DeliveryQueue {
pub fn new(policy: RetryPolicy) -> Self;
pub async fn enqueue(&mut self, message: OutboundMessage, channel: &str, account: &str) -> String;
pub async fn process(&mut self, registry: &ChannelRegistry) -> Result<()>;
pub fn cancel(&mut self, message_id: &str) -> bool;
pub fn stats(&self) -> QueueStats;
}
}
Telegram Channel Specification
Overview
Full Telegram Bot API implementation using the teloxide crate for Rust.
Dependencies
[dependencies]
teloxide = { version = "0.12", features = ["macros", "auto-send"] }
tokio = { version = "1", features = ["full"] }
Authentication
#![allow(unused)]
fn main() {
pub struct TelegramConfig {
/// Bot token from @BotFather
pub token: SecretString,
/// Webhook URL (optional, for webhook mode)
pub webhook_url: Option<String>,
/// Allowed updates to receive
pub allowed_updates: Vec<UpdateType>,
/// Drop pending updates on start
pub drop_pending_updates: bool,
/// Parse mode default
pub default_parse_mode: ParseMode,
}
#[derive(Debug, Clone, Copy)]
pub enum UpdateType {
Message,
EditedMessage,
ChannelPost,
EditedChannelPost,
InlineQuery,
CallbackQuery,
ShippingQuery,
PreCheckoutQuery,
Poll,
PollAnswer,
MyChatMember,
ChatMember,
ChatJoinRequest,
}
}
Channel Implementation
#![allow(unused)]
fn main() {
pub struct TelegramChannel {
config: TelegramConfig,
bot: Option<Bot>,
dispatcher: Option<Dispatcher<Bot, Error>>,
message_tx: mpsc::Sender<InboundMessage>,
message_rx: mpsc::Receiver<InboundMessage>,
connected: AtomicBool,
}
#[async_trait]
impl Channel for TelegramChannel {
fn id(&self) -> &str { "telegram" }
fn name(&self) -> &str { "Telegram" }
fn capabilities(&self) -> ChannelCapabilities {
ChannelCapabilities {
chat_types: vec![ChatType::Direct, ChatType::Group, ChatType::Channel],
media: MediaCapabilities {
images: true,
audio: true,
video: true,
files: true,
stickers: true,
voice_notes: true,
max_file_size_mb: 50,
},
features: ChannelFeatures {
reactions: true,
threads: true,
edits: true,
deletes: true,
typing_indicators: true,
read_receipts: false,
mentions: true,
polls: true,
buttons: true,
inline_queries: true,
commands: true,
markdown: true,
html: true,
},
limits: ChannelLimits {
max_message_length: 4096,
max_caption_length: 1024,
max_buttons_per_row: 8,
max_button_rows: 100,
},
}
}
async fn connect(&mut self) -> Result<()>;
async fn disconnect(&mut self) -> Result<()>;
fn is_connected(&self) -> bool;
async fn health(&self) -> Result<ChannelHealth>;
async fn send(&self, message: OutboundMessage) -> Result<SendResult>;
async fn edit(&self, target: MessageTarget, new_content: &str) -> Result<()>;
async fn delete(&self, target: MessageTarget) -> Result<()>;
async fn react(&self, target: MessageTarget, emoji: &str) -> Result<()>;
async fn unreact(&self, target: MessageTarget, emoji: &str) -> Result<()>;
async fn typing(&self, chat_id: &str) -> Result<()>;
fn messages(&self) -> Pin<Box<dyn Stream<Item = InboundMessage> + Send>>;
}
}
Message Handling
Inbound Message Conversion
#![allow(unused)]
fn main() {
impl TelegramChannel {
fn convert_message(&self, msg: teloxide::types::Message) -> InboundMessage {
InboundMessage {
id: MessageId::new(msg.id.to_string()),
timestamp: DateTime::from_timestamp(msg.date.timestamp(), 0).unwrap_or_default(),
channel: "telegram".to_string(),
account_id: self.bot_id(),
sender: SenderInfo {
id: msg.from().map(|u| u.id.to_string()).unwrap_or_default(),
username: msg.from().and_then(|u| u.username.clone()),
display_name: msg.from().map(|u| u.full_name()),
phone_number: None,
is_bot: msg.from().map(|u| u.is_bot).unwrap_or(false),
},
chat: ChatInfo {
id: msg.chat.id.to_string(),
chat_type: match msg.chat.kind {
ChatKind::Private(_) => ChatType::Direct,
ChatKind::Group(_) | ChatKind::Supergroup(_) => ChatType::Group,
ChatKind::Channel(_) => ChatType::Channel,
},
title: msg.chat.title().map(|s| s.to_string()),
guild_id: None,
},
text: msg.text().unwrap_or_default().to_string(),
media: self.extract_media(&msg),
quote: msg.reply_to_message().map(|r| QuotedMessage {
id: r.id.to_string(),
text: r.text().map(|s| s.to_string()),
sender_id: r.from().map(|u| u.id.to_string()),
}),
thread: msg.thread_id.map(|id| ThreadInfo {
id: id.to_string(),
name: None,
}),
metadata: serde_json::json!({
"message_thread_id": msg.thread_id,
"is_topic_message": msg.is_topic_message,
"forward_origin": msg.forward_origin().map(|f| format!("{:?}", f)),
}),
}
}
fn extract_media(&self, msg: &teloxide::types::Message) -> Vec<MediaAttachment> {
let mut media = Vec::new();
if let Some(photo) = msg.photo() {
if let Some(largest) = photo.last() {
media.push(MediaAttachment {
attachment_type: MediaType::Image,
url: None,
file_id: Some(largest.file.id.clone()),
file_path: None,
mime_type: Some("image/jpeg".to_string()),
file_name: None,
file_size: largest.file.size.map(|s| s as u64),
caption: msg.caption().map(|s| s.to_string()),
});
}
}
if let Some(doc) = msg.document() {
media.push(MediaAttachment {
attachment_type: MediaType::Document,
url: None,
file_id: Some(doc.file.id.clone()),
file_path: None,
mime_type: doc.mime_type.as_ref().map(|m| m.to_string()),
file_name: doc.file_name.clone(),
file_size: doc.file.size.map(|s| s as u64),
caption: msg.caption().map(|s| s.to_string()),
});
}
if let Some(audio) = msg.audio() {
media.push(MediaAttachment {
attachment_type: MediaType::Audio,
url: None,
file_id: Some(audio.file.id.clone()),
file_path: None,
mime_type: audio.mime_type.as_ref().map(|m| m.to_string()),
file_name: audio.file_name.clone(),
file_size: audio.file.size.map(|s| s as u64),
caption: msg.caption().map(|s| s.to_string()),
});
}
if let Some(video) = msg.video() {
media.push(MediaAttachment {
attachment_type: MediaType::Video,
url: None,
file_id: Some(video.file.id.clone()),
file_path: None,
mime_type: video.mime_type.as_ref().map(|m| m.to_string()),
file_name: video.file_name.clone(),
file_size: video.file.size.map(|s| s as u64),
caption: msg.caption().map(|s| s.to_string()),
});
}
if let Some(voice) = msg.voice() {
media.push(MediaAttachment {
attachment_type: MediaType::Voice,
url: None,
file_id: Some(voice.file.id.clone()),
file_path: None,
mime_type: voice.mime_type.as_ref().map(|m| m.to_string()),
file_name: None,
file_size: voice.file.size.map(|s| s as u64),
caption: msg.caption().map(|s| s.to_string()),
});
}
if let Some(sticker) = msg.sticker() {
media.push(MediaAttachment {
attachment_type: MediaType::Sticker,
url: None,
file_id: Some(sticker.file.id.clone()),
file_path: None,
mime_type: Some("image/webp".to_string()),
file_name: None,
file_size: sticker.file.size.map(|s| s as u64),
caption: None,
});
}
media
}
}
}
Outbound Message Sending
#![allow(unused)]
fn main() {
impl TelegramChannel {
async fn send_text(&self, chat_id: ChatId, message: &OutboundMessage) -> Result<Message> {
let mut request = self.bot.as_ref().unwrap()
.send_message(chat_id, &message.text);
if let Some(parse_mode) = &message.parse_mode {
request = request.parse_mode(match parse_mode {
ParseMode::Markdown => teloxide::types::ParseMode::MarkdownV2,
ParseMode::Html => teloxide::types::ParseMode::Html,
ParseMode::None => teloxide::types::ParseMode::Html,
});
}
if let Some(reply_to) = &message.reply_to {
if let Ok(msg_id) = reply_to.parse::<i32>() {
request = request.reply_to_message_id(MessageId(msg_id));
}
}
if let Some(thread_id) = &message.thread_id {
if let Ok(tid) = thread_id.parse::<i32>() {
request = request.message_thread_id(ThreadId(tid));
}
}
if message.disable_preview {
request = request.disable_web_page_preview(true);
}
if message.silent {
request = request.disable_notification(true);
}
if let Some(buttons) = &message.buttons {
let keyboard = self.build_inline_keyboard(buttons);
request = request.reply_markup(keyboard);
}
request.await.map_err(|e| ChannelError::SendFailed(e.to_string()))
}
async fn send_media(&self, chat_id: ChatId, message: &OutboundMessage) -> Result<Message> {
// Handle single media
if message.media.len() == 1 {
let media = &message.media[0];
return self.send_single_media(chat_id, media, message).await;
}
// Handle media group
if message.media.len() > 1 {
return self.send_media_group(chat_id, &message.media, message).await;
}
Err(ChannelError::InvalidMessage("No media to send".to_string()))
}
fn build_inline_keyboard(&self, buttons: &[Vec<Button>]) -> InlineKeyboardMarkup {
let rows: Vec<Vec<InlineKeyboardButton>> = buttons
.iter()
.map(|row| {
row.iter()
.map(|btn| match &btn.action {
ButtonAction::Callback(data) => {
InlineKeyboardButton::callback(&btn.text, data)
}
ButtonAction::Url(url) => {
InlineKeyboardButton::url(&btn.text, url.parse().unwrap())
}
})
.collect()
})
.collect();
InlineKeyboardMarkup::new(rows)
}
}
}
Telegram-Specific Actions
#![allow(unused)]
fn main() {
impl TelegramChannel {
/// Forward a message
pub async fn forward(
&self,
from_chat_id: ChatId,
message_id: MessageId,
to_chat_id: ChatId,
) -> Result<Message> {
self.bot.as_ref().unwrap()
.forward_message(to_chat_id, from_chat_id, message_id)
.await
.map_err(|e| ChannelError::ActionFailed(e.to_string()))
}
/// Pin a message
pub async fn pin(&self, chat_id: ChatId, message_id: MessageId) -> Result<()> {
self.bot.as_ref().unwrap()
.pin_chat_message(chat_id, message_id)
.await
.map_err(|e| ChannelError::ActionFailed(e.to_string()))?;
Ok(())
}
/// Unpin a message
pub async fn unpin(&self, chat_id: ChatId, message_id: MessageId) -> Result<()> {
self.bot.as_ref().unwrap()
.unpin_chat_message(chat_id)
.message_id(message_id)
.await
.map_err(|e| ChannelError::ActionFailed(e.to_string()))?;
Ok(())
}
/// Get chat information
pub async fn get_chat(&self, chat_id: ChatId) -> Result<Chat> {
self.bot.as_ref().unwrap()
.get_chat(chat_id)
.await
.map_err(|e| ChannelError::ActionFailed(e.to_string()))
}
/// Get chat member information
pub async fn get_chat_member(&self, chat_id: ChatId, user_id: UserId) -> Result<ChatMember> {
self.bot.as_ref().unwrap()
.get_chat_member(chat_id, user_id)
.await
.map_err(|e| ChannelError::ActionFailed(e.to_string()))
}
/// Answer callback query
pub async fn answer_callback(
&self,
callback_id: &str,
text: Option<&str>,
show_alert: bool,
) -> Result<()> {
let mut request = self.bot.as_ref().unwrap()
.answer_callback_query(callback_id);
if let Some(text) = text {
request = request.text(text);
}
request = request.show_alert(show_alert);
request.await.map_err(|e| ChannelError::ActionFailed(e.to_string()))?;
Ok(())
}
/// Set chat action (typing, uploading, etc.)
pub async fn set_chat_action(&self, chat_id: ChatId, action: ChatAction) -> Result<()> {
self.bot.as_ref().unwrap()
.send_chat_action(chat_id, action)
.await
.map_err(|e| ChannelError::ActionFailed(e.to_string()))?;
Ok(())
}
/// Download file by file_id
pub async fn download_file(&self, file_id: &str) -> Result<Vec<u8>> {
let file = self.bot.as_ref().unwrap()
.get_file(file_id)
.await
.map_err(|e| ChannelError::ActionFailed(e.to_string()))?;
let mut data = Vec::new();
self.bot.as_ref().unwrap()
.download_file(&file.path, &mut data)
.await
.map_err(|e| ChannelError::ActionFailed(e.to_string()))?;
Ok(data)
}
}
}
Callback Query Handling
#![allow(unused)]
fn main() {
impl TelegramChannel {
fn setup_callback_handler(&mut self) {
// Handle inline button callbacks
self.dispatcher.as_mut().unwrap().callback_queries_handler(|bot, query| async move {
// Parse callback data
if let Some(data) = &query.data {
// Route to appropriate handler based on data prefix
match data.split(':').next() {
Some("approve") => handle_approval_callback(bot, query).await,
Some("deny") => handle_denial_callback(bot, query).await,
Some("action") => handle_action_callback(bot, query).await,
_ => {
bot.answer_callback_query(&query.id).await?;
}
}
}
Ok(())
});
}
}
}
Error Handling
#![allow(unused)]
fn main() {
#[derive(Debug, thiserror::Error)]
pub enum TelegramError {
#[error("Bot not connected")]
NotConnected,
#[error("Invalid chat ID: {0}")]
InvalidChatId(String),
#[error("Message not found: {0}")]
MessageNotFound(String),
#[error("Rate limited, retry after {0} seconds")]
RateLimited(u32),
#[error("Bot blocked by user")]
BotBlocked,
#[error("Chat not found")]
ChatNotFound,
#[error("Insufficient permissions")]
InsufficientPermissions,
#[error("API error: {0}")]
ApiError(String),
}
impl From<teloxide::RequestError> for TelegramError {
fn from(err: teloxide::RequestError) -> Self {
match err {
teloxide::RequestError::Api(api_err) => {
match api_err {
ApiError::BotBlocked => TelegramError::BotBlocked,
ApiError::ChatNotFound => TelegramError::ChatNotFound,
ApiError::MessageNotModified => TelegramError::MessageNotFound("not modified".to_string()),
_ => TelegramError::ApiError(api_err.to_string()),
}
}
_ => TelegramError::ApiError(err.to_string()),
}
}
}
}
Discord Channel Specification
Overview
Full Discord Bot API implementation using serenity crate for Rust.
Dependencies
[dependencies]
serenity = { version = "0.12", features = ["client", "gateway", "cache"] }
tokio = { version = "1", features = ["full"] }
Authentication
#![allow(unused)]
fn main() {
pub struct DiscordConfig {
/// Bot token from Discord Developer Portal
pub token: SecretString,
/// Application ID
pub application_id: u64,
/// Gateway intents
pub intents: GatewayIntents,
/// Guild IDs to listen to (empty = all)
pub guild_ids: Vec<u64>,
/// Whether to enable slash commands
pub slash_commands: bool,
}
impl Default for DiscordConfig {
fn default() -> Self {
Self {
token: SecretString::new(String::new()),
application_id: 0,
intents: GatewayIntents::GUILDS
| GatewayIntents::GUILD_MESSAGES
| GatewayIntents::DIRECT_MESSAGES
| GatewayIntents::MESSAGE_CONTENT,
guild_ids: vec![],
slash_commands: true,
}
}
}
}
Channel Implementation
#![allow(unused)]
fn main() {
pub struct DiscordChannel {
config: DiscordConfig,
client: Option<Client>,
http: Option<Arc<Http>>,
cache: Option<Arc<Cache>>,
message_tx: mpsc::Sender<InboundMessage>,
message_rx: mpsc::Receiver<InboundMessage>,
connected: AtomicBool,
}
#[async_trait]
impl Channel for DiscordChannel {
fn id(&self) -> &str { "discord" }
fn name(&self) -> &str { "Discord" }
fn capabilities(&self) -> ChannelCapabilities {
ChannelCapabilities {
chat_types: vec![ChatType::Direct, ChatType::Group, ChatType::Channel, ChatType::Thread],
media: MediaCapabilities {
images: true,
audio: true,
video: true,
files: true,
stickers: true,
voice_notes: false,
max_file_size_mb: 25, // 100MB for Nitro
},
features: ChannelFeatures {
reactions: true,
threads: true,
edits: true,
deletes: true,
typing_indicators: true,
read_receipts: false,
mentions: true,
polls: true,
buttons: true,
inline_queries: false,
commands: true,
markdown: true,
html: false,
},
limits: ChannelLimits {
max_message_length: 2000,
max_caption_length: 2000,
max_buttons_per_row: 5,
max_button_rows: 5,
},
}
}
async fn connect(&mut self) -> Result<()>;
async fn disconnect(&mut self) -> Result<()>;
fn is_connected(&self) -> bool;
async fn health(&self) -> Result<ChannelHealth>;
async fn send(&self, message: OutboundMessage) -> Result<SendResult>;
async fn edit(&self, target: MessageTarget, new_content: &str) -> Result<()>;
async fn delete(&self, target: MessageTarget) -> Result<()>;
async fn react(&self, target: MessageTarget, emoji: &str) -> Result<()>;
async fn unreact(&self, target: MessageTarget, emoji: &str) -> Result<()>;
async fn typing(&self, chat_id: &str) -> Result<()>;
fn messages(&self) -> Pin<Box<dyn Stream<Item = InboundMessage> + Send>>;
}
}
Message Handling
Inbound Message Conversion
#![allow(unused)]
fn main() {
impl DiscordChannel {
fn convert_message(&self, msg: serenity::model::channel::Message) -> InboundMessage {
InboundMessage {
id: MessageId::new(msg.id.to_string()),
timestamp: msg.timestamp.to_utc(),
channel: "discord".to_string(),
account_id: self.config.application_id.to_string(),
sender: SenderInfo {
id: msg.author.id.to_string(),
username: Some(msg.author.name.clone()),
display_name: msg.author.global_name.clone(),
phone_number: None,
is_bot: msg.author.bot,
},
chat: ChatInfo {
id: msg.channel_id.to_string(),
chat_type: self.determine_chat_type(&msg),
title: None, // Fetched separately for guilds
guild_id: msg.guild_id.map(|id| id.to_string()),
},
text: msg.content.clone(),
media: self.extract_media(&msg),
quote: msg.referenced_message.as_ref().map(|r| QuotedMessage {
id: r.id.to_string(),
text: Some(r.content.clone()),
sender_id: Some(r.author.id.to_string()),
}),
thread: msg.thread.as_ref().map(|t| ThreadInfo {
id: t.id.to_string(),
name: Some(t.name.clone()),
}),
metadata: serde_json::json!({
"guild_id": msg.guild_id,
"channel_type": format!("{:?}", msg.kind),
"webhook_id": msg.webhook_id,
"components": msg.components,
}),
}
}
fn determine_chat_type(&self, msg: &serenity::model::channel::Message) -> ChatType {
match msg.guild_id {
Some(_) => match msg.channel(&self.cache) {
Some(Channel::Guild(gc)) => match gc.kind {
ChannelType::Text => ChatType::Channel,
ChannelType::PublicThread | ChannelType::PrivateThread => ChatType::Thread,
_ => ChatType::Group,
},
_ => ChatType::Group,
},
None => ChatType::Direct,
}
}
fn extract_media(&self, msg: &serenity::model::channel::Message) -> Vec<MediaAttachment> {
let mut media = Vec::new();
for attachment in &msg.attachments {
let media_type = self.guess_media_type(&attachment.content_type);
media.push(MediaAttachment {
attachment_type: media_type,
url: Some(attachment.url.clone()),
file_id: Some(attachment.id.to_string()),
file_path: None,
mime_type: attachment.content_type.clone(),
file_name: Some(attachment.filename.clone()),
file_size: Some(attachment.size as u64),
caption: None,
});
}
for embed in &msg.embeds {
if let Some(ref image) = embed.image {
media.push(MediaAttachment {
attachment_type: MediaType::Image,
url: Some(image.url.clone()),
file_id: None,
file_path: None,
mime_type: None,
file_name: None,
file_size: None,
caption: embed.description.clone(),
});
}
}
media
}
fn guess_media_type(&self, content_type: &Option<String>) -> MediaType {
match content_type.as_deref() {
Some(ct) if ct.starts_with("image/") => MediaType::Image,
Some(ct) if ct.starts_with("video/") => MediaType::Video,
Some(ct) if ct.starts_with("audio/") => MediaType::Audio,
_ => MediaType::Document,
}
}
}
}
Outbound Message Sending
#![allow(unused)]
fn main() {
impl DiscordChannel {
async fn send_text(&self, channel_id: ChannelId, message: &OutboundMessage) -> Result<Message> {
let http = self.http.as_ref().ok_or(ChannelError::NotConnected)?;
channel_id.send_message(http, |m| {
m.content(&message.text);
if let Some(reply_to) = &message.reply_to {
if let Ok(msg_id) = reply_to.parse::<u64>() {
m.reference_message(MessageId::new(msg_id));
}
}
if let Some(buttons) = &message.buttons {
m.components(|c| {
for row in buttons {
c.create_action_row(|r| {
for btn in row {
r.create_button(|b| {
b.label(&btn.text);
match &btn.action {
ButtonAction::Callback(data) => {
b.custom_id(data);
b.style(ButtonStyle::Primary);
}
ButtonAction::Url(url) => {
b.url(url);
b.style(ButtonStyle::Link);
}
}
b
});
}
r
});
}
c
});
}
m
}).await.map_err(|e| ChannelError::SendFailed(e.to_string()))
}
async fn send_media(&self, channel_id: ChannelId, message: &OutboundMessage) -> Result<Message> {
let http = self.http.as_ref().ok_or(ChannelError::NotConnected)?;
let files: Vec<_> = message.media.iter()
.filter_map(|m| {
m.file_path.as_ref().map(|path| {
CreateAttachment::path(path)
})
})
.collect();
channel_id.send_files(http, files, |m| {
if !message.text.is_empty() {
m.content(&message.text);
}
m
}).await.map_err(|e| ChannelError::SendFailed(e.to_string()))
}
}
}
Discord-Specific Actions
#![allow(unused)]
fn main() {
impl DiscordChannel {
/// Create a thread from a message
pub async fn create_thread(
&self,
channel_id: ChannelId,
message_id: MessageId,
name: &str,
auto_archive_duration: AutoArchiveDuration,
) -> Result<GuildChannel> {
let http = self.http.as_ref().ok_or(ChannelError::NotConnected)?;
channel_id.create_public_thread(http, message_id, |t| {
t.name(name).auto_archive_duration(auto_archive_duration)
}).await.map_err(|e| ChannelError::ActionFailed(e.to_string()))
}
/// Get guild member information
pub async fn get_member(&self, guild_id: GuildId, user_id: UserId) -> Result<Member> {
let http = self.http.as_ref().ok_or(ChannelError::NotConnected)?;
guild_id.member(http, user_id)
.await
.map_err(|e| ChannelError::ActionFailed(e.to_string()))
}
/// Get guild role information
pub async fn get_roles(&self, guild_id: GuildId) -> Result<Vec<Role>> {
let http = self.http.as_ref().ok_or(ChannelError::NotConnected)?;
guild_id.roles(http)
.await
.map_err(|e| ChannelError::ActionFailed(e.to_string()))
}
/// Timeout a member
pub async fn timeout_member(
&self,
guild_id: GuildId,
user_id: UserId,
duration: Duration,
reason: Option<&str>,
) -> Result<()> {
let http = self.http.as_ref().ok_or(ChannelError::NotConnected)?;
let timeout_until = Utc::now() + duration;
guild_id.edit_member(http, user_id, |m| {
m.disable_communication_until(timeout_until.to_rfc3339());
if let Some(r) = reason {
m.audit_log_reason(r);
}
m
}).await.map_err(|e| ChannelError::ActionFailed(e.to_string()))?;
Ok(())
}
/// Kick a member
pub async fn kick_member(
&self,
guild_id: GuildId,
user_id: UserId,
reason: Option<&str>,
) -> Result<()> {
let http = self.http.as_ref().ok_or(ChannelError::NotConnected)?;
guild_id.kick_with_reason(http, user_id, reason.unwrap_or(""))
.await
.map_err(|e| ChannelError::ActionFailed(e.to_string()))
}
/// Ban a member
pub async fn ban_member(
&self,
guild_id: GuildId,
user_id: UserId,
delete_message_days: u8,
reason: Option<&str>,
) -> Result<()> {
let http = self.http.as_ref().ok_or(ChannelError::NotConnected)?;
guild_id.ban_with_reason(http, user_id, delete_message_days, reason.unwrap_or(""))
.await
.map_err(|e| ChannelError::ActionFailed(e.to_string()))
}
/// List guild channels
pub async fn list_channels(&self, guild_id: GuildId) -> Result<Vec<GuildChannel>> {
let http = self.http.as_ref().ok_or(ChannelError::NotConnected)?;
guild_id.channels(http)
.await
.map(|m| m.into_values().collect())
.map_err(|e| ChannelError::ActionFailed(e.to_string()))
}
/// Create a scheduled event
pub async fn create_event(
&self,
guild_id: GuildId,
name: &str,
description: Option<&str>,
start_time: DateTime<Utc>,
end_time: Option<DateTime<Utc>>,
location: Option<&str>,
) -> Result<ScheduledEvent> {
let http = self.http.as_ref().ok_or(ChannelError::NotConnected)?;
guild_id.create_scheduled_event(http, |e| {
e.name(name)
.start_time(start_time)
.entity_type(ScheduledEventType::External);
if let Some(desc) = description {
e.description(desc);
}
if let Some(end) = end_time {
e.end_time(end);
}
if let Some(loc) = location {
e.location(loc);
}
e
}).await.map_err(|e| ChannelError::ActionFailed(e.to_string()))
}
}
}
Slash Commands
#![allow(unused)]
fn main() {
impl DiscordChannel {
pub async fn register_slash_commands(&self, guild_id: Option<GuildId>) -> Result<()> {
let http = self.http.as_ref().ok_or(ChannelError::NotConnected)?;
let commands = vec![
CreateCommand::new("ask")
.description("Ask the AI a question")
.add_option(
CreateCommandOption::new(CommandOptionType::String, "question", "Your question")
.required(true)
),
CreateCommand::new("status")
.description("Get bot status"),
];
match guild_id {
Some(id) => {
id.set_commands(http, commands).await?;
}
None => {
Command::set_global_commands(http, commands).await?;
}
}
Ok(())
}
}
}
Error Handling
#![allow(unused)]
fn main() {
#[derive(Debug, thiserror::Error)]
pub enum DiscordError {
#[error("Bot not connected")]
NotConnected,
#[error("Invalid channel ID: {0}")]
InvalidChannelId(String),
#[error("Invalid guild ID: {0}")]
InvalidGuildId(String),
#[error("Message not found: {0}")]
MessageNotFound(String),
#[error("Rate limited, retry after {0} seconds")]
RateLimited(u32),
#[error("Insufficient permissions: {0}")]
InsufficientPermissions(String),
#[error("Guild not found")]
GuildNotFound,
#[error("Member not found")]
MemberNotFound,
#[error("API error: {0}")]
ApiError(String),
}
impl From<serenity::Error> for DiscordError {
fn from(err: serenity::Error) -> Self {
match err {
serenity::Error::Http(http_err) => {
DiscordError::ApiError(http_err.to_string())
}
serenity::Error::Model(model_err) => {
DiscordError::ApiError(model_err.to_string())
}
_ => DiscordError::ApiError(err.to_string()),
}
}
}
}
Slack Channel Specification
Overview
Full Slack API implementation using Slack Web API and Socket Mode.
Dependencies
[dependencies]
slack-morphism = { version = "2.0", features = ["hyper", "socket-mode"] }
tokio = { version = "1", features = ["full"] }
Authentication
#![allow(unused)]
fn main() {
pub struct SlackConfig {
/// Bot token (xoxb-...)
pub bot_token: SecretString,
/// App token for Socket Mode (xapp-...)
pub app_token: SecretString,
/// Signing secret for webhook verification
pub signing_secret: SecretString,
/// Workspace ID (optional, for multi-workspace)
pub workspace_id: Option<String>,
/// Use Socket Mode (vs webhook)
pub socket_mode: bool,
/// Webhook URL (if not using socket mode)
pub webhook_url: Option<String>,
}
}
Channel Implementation
#![allow(unused)]
fn main() {
pub struct SlackChannel {
config: SlackConfig,
client: Option<SlackClient<SlackClientHyperConnector>>,
socket: Option<SlackClientSocketModeListener>,
message_tx: mpsc::Sender<InboundMessage>,
message_rx: mpsc::Receiver<InboundMessage>,
connected: AtomicBool,
}
#[async_trait]
impl Channel for SlackChannel {
fn id(&self) -> &str { "slack" }
fn name(&self) -> &str { "Slack" }
fn capabilities(&self) -> ChannelCapabilities {
ChannelCapabilities {
chat_types: vec![ChatType::Direct, ChatType::Group, ChatType::Channel, ChatType::Thread],
media: MediaCapabilities {
images: true,
audio: true,
video: true,
files: true,
stickers: false,
voice_notes: false,
max_file_size_mb: 1000, // 1GB for paid workspaces
},
features: ChannelFeatures {
reactions: true,
threads: true,
edits: true,
deletes: true,
typing_indicators: false,
read_receipts: false,
mentions: true,
polls: false,
buttons: true,
inline_queries: false,
commands: true,
markdown: true, // mrkdwn
html: false,
},
limits: ChannelLimits {
max_message_length: 40000,
max_caption_length: 40000,
max_buttons_per_row: 5,
max_button_rows: 10,
},
}
}
async fn connect(&mut self) -> Result<()>;
async fn disconnect(&mut self) -> Result<()>;
fn is_connected(&self) -> bool;
async fn health(&self) -> Result<ChannelHealth>;
async fn send(&self, message: OutboundMessage) -> Result<SendResult>;
async fn edit(&self, target: MessageTarget, new_content: &str) -> Result<()>;
async fn delete(&self, target: MessageTarget) -> Result<()>;
async fn react(&self, target: MessageTarget, emoji: &str) -> Result<()>;
async fn unreact(&self, target: MessageTarget, emoji: &str) -> Result<()>;
async fn typing(&self, chat_id: &str) -> Result<()>;
fn messages(&self) -> Pin<Box<dyn Stream<Item = InboundMessage> + Send>>;
}
}
Message Handling
Inbound Message Conversion
#![allow(unused)]
fn main() {
impl SlackChannel {
fn convert_message(&self, event: SlackMessageEvent) -> InboundMessage {
InboundMessage {
id: MessageId::new(event.ts.clone()),
timestamp: self.parse_slack_ts(&event.ts),
channel: "slack".to_string(),
account_id: self.config.workspace_id.clone().unwrap_or_default(),
sender: SenderInfo {
id: event.user.clone().unwrap_or_default(),
username: None, // Fetched separately
display_name: None,
phone_number: None,
is_bot: event.bot_id.is_some(),
},
chat: ChatInfo {
id: event.channel.clone(),
chat_type: self.determine_chat_type(&event.channel_type),
title: None, // Fetched separately
guild_id: self.config.workspace_id.clone(),
},
text: event.text.clone().unwrap_or_default(),
media: self.extract_media(&event.files),
quote: None, // Slack uses threads differently
thread: event.thread_ts.map(|ts| ThreadInfo {
id: ts.clone(),
name: None,
}),
metadata: serde_json::json!({
"ts": event.ts,
"thread_ts": event.thread_ts,
"channel_type": event.channel_type,
"subtype": event.subtype,
"blocks": event.blocks,
}),
}
}
fn determine_chat_type(&self, channel_type: &Option<String>) -> ChatType {
match channel_type.as_deref() {
Some("im") => ChatType::Direct,
Some("mpim") => ChatType::Group,
Some("channel") | Some("group") => ChatType::Channel,
_ => ChatType::Channel,
}
}
fn extract_media(&self, files: &Option<Vec<SlackFile>>) -> Vec<MediaAttachment> {
files.as_ref().map(|fs| {
fs.iter().map(|f| MediaAttachment {
attachment_type: self.guess_media_type(&f.mimetype),
url: f.url_private.clone(),
file_id: Some(f.id.clone()),
file_path: None,
mime_type: f.mimetype.clone(),
file_name: f.name.clone(),
file_size: f.size.map(|s| s as u64),
caption: f.title.clone(),
}).collect()
}).unwrap_or_default()
}
fn parse_slack_ts(&self, ts: &str) -> DateTime<Utc> {
let secs: f64 = ts.parse().unwrap_or(0.0);
DateTime::from_timestamp(secs as i64, ((secs.fract() * 1_000_000_000.0) as u32))
.unwrap_or_default()
}
}
}
Outbound Message Sending
#![allow(unused)]
fn main() {
impl SlackChannel {
async fn send_text(&self, channel: &str, message: &OutboundMessage) -> Result<SlackMessageResponse> {
let client = self.client.as_ref().ok_or(ChannelError::NotConnected)?;
let session = client.open_session(&self.config.bot_token.expose_secret().into());
let mut request = SlackApiChatPostMessageRequest::new(
channel.into(),
SlackMessageContent::new().with_text(message.text.clone()),
);
if let Some(thread_ts) = &message.thread_id {
request = request.with_thread_ts(thread_ts.into());
}
if let Some(buttons) = &message.buttons {
let blocks = self.build_blocks(buttons);
request = request.with_blocks(blocks);
}
if message.silent {
// Slack doesn't have a direct silent mode
}
session.chat_post_message(&request)
.await
.map_err(|e| ChannelError::SendFailed(e.to_string()))
}
async fn send_media(&self, channel: &str, message: &OutboundMessage) -> Result<SlackFileResponse> {
let client = self.client.as_ref().ok_or(ChannelError::NotConnected)?;
let session = client.open_session(&self.config.bot_token.expose_secret().into());
for media in &message.media {
if let Some(path) = &media.file_path {
let content = tokio::fs::read(path).await?;
let request = SlackApiFilesUploadRequest::new(content)
.with_channels(vec![channel.into()])
.with_filename(media.file_name.clone().unwrap_or_else(|| "file".to_string()));
if let Some(caption) = &media.caption {
request = request.with_initial_comment(caption.clone());
}
session.files_upload(&request).await?;
}
}
Ok(SlackFileResponse::default())
}
fn build_blocks(&self, buttons: &[Vec<Button>]) -> Vec<SlackBlock> {
buttons.iter().map(|row| {
let elements: Vec<SlackBlockElement> = row.iter().map(|btn| {
match &btn.action {
ButtonAction::Callback(data) => {
SlackBlockElement::Button(SlackBlockButtonElement::new(
data.into(),
SlackBlockPlainText::new(btn.text.clone()),
))
}
ButtonAction::Url(url) => {
SlackBlockElement::Button(
SlackBlockButtonElement::new(
"url_button".into(),
SlackBlockPlainText::new(btn.text.clone()),
).with_url(url.into())
)
}
}
}).collect();
SlackBlock::Actions(SlackActionsBlock::new(elements))
}).collect()
}
}
}
Slack-Specific Actions
#![allow(unused)]
fn main() {
impl SlackChannel {
/// Pin a message
pub async fn pin(&self, channel: &str, ts: &str) -> Result<()> {
let session = self.get_session()?;
let request = SlackApiPinsAddRequest::new(channel.into(), ts.into());
session.pins_add(&request).await?;
Ok(())
}
/// Unpin a message
pub async fn unpin(&self, channel: &str, ts: &str) -> Result<()> {
let session = self.get_session()?;
let request = SlackApiPinsRemoveRequest::new(channel.into(), ts.into());
session.pins_remove(&request).await?;
Ok(())
}
/// Get conversation history
pub async fn get_history(
&self,
channel: &str,
limit: Option<u32>,
oldest: Option<&str>,
latest: Option<&str>,
) -> Result<Vec<SlackMessage>> {
let session = self.get_session()?;
let mut request = SlackApiConversationsHistoryRequest::new(channel.into());
if let Some(l) = limit {
request = request.with_limit(l);
}
if let Some(o) = oldest {
request = request.with_oldest(o.into());
}
if let Some(l) = latest {
request = request.with_latest(l.into());
}
let response = session.conversations_history(&request).await?;
Ok(response.messages)
}
/// Get user info
pub async fn get_user_info(&self, user_id: &str) -> Result<SlackUser> {
let session = self.get_session()?;
let request = SlackApiUsersInfoRequest::new(user_id.into());
let response = session.users_info(&request).await?;
Ok(response.user)
}
/// List channels
pub async fn list_channels(&self) -> Result<Vec<SlackChannel>> {
let session = self.get_session()?;
let request = SlackApiConversationsListRequest::new()
.with_types(vec!["public_channel".into(), "private_channel".into()]);
let response = session.conversations_list(&request).await?;
Ok(response.channels)
}
/// Open a DM with a user
pub async fn open_dm(&self, user_id: &str) -> Result<String> {
let session = self.get_session()?;
let request = SlackApiConversationsOpenRequest::new()
.with_users(vec![user_id.into()]);
let response = session.conversations_open(&request).await?;
Ok(response.channel.id)
}
/// Reply in thread
pub async fn reply_in_thread(
&self,
channel: &str,
thread_ts: &str,
text: &str,
) -> Result<SlackMessageResponse> {
let session = self.get_session()?;
let request = SlackApiChatPostMessageRequest::new(
channel.into(),
SlackMessageContent::new().with_text(text.to_string()),
).with_thread_ts(thread_ts.into());
session.chat_post_message(&request).await
.map_err(|e| ChannelError::ActionFailed(e.to_string()))
}
fn get_session(&self) -> Result<SlackClientSession> {
let client = self.client.as_ref().ok_or(ChannelError::NotConnected)?;
Ok(client.open_session(&self.config.bot_token.expose_secret().into()))
}
}
}
Block Kit Builder
#![allow(unused)]
fn main() {
pub struct BlockKitBuilder;
impl BlockKitBuilder {
pub fn section(text: &str) -> SlackBlock {
SlackBlock::Section(SlackSectionBlock::new()
.with_text(SlackBlockMarkDownText::new(text.to_string())))
}
pub fn divider() -> SlackBlock {
SlackBlock::Divider(SlackDividerBlock::new())
}
pub fn image(url: &str, alt_text: &str) -> SlackBlock {
SlackBlock::Image(SlackImageBlock::new(url.into(), alt_text.to_string()))
}
pub fn context(elements: Vec<SlackBlockElement>) -> SlackBlock {
SlackBlock::Context(SlackContextBlock::new(elements))
}
pub fn button(text: &str, action_id: &str, value: Option<&str>) -> SlackBlockElement {
let mut btn = SlackBlockButtonElement::new(
action_id.into(),
SlackBlockPlainText::new(text.to_string()),
);
if let Some(v) = value {
btn = btn.with_value(v.to_string());
}
SlackBlockElement::Button(btn)
}
}
}
Error Handling
#![allow(unused)]
fn main() {
#[derive(Debug, thiserror::Error)]
pub enum SlackError {
#[error("Client not connected")]
NotConnected,
#[error("Invalid channel: {0}")]
InvalidChannel(String),
#[error("User not found: {0}")]
UserNotFound(String),
#[error("Rate limited, retry after {0} seconds")]
RateLimited(u32),
#[error("Missing scope: {0}")]
MissingScope(String),
#[error("Channel not found")]
ChannelNotFound,
#[error("Message not found")]
MessageNotFound,
#[error("API error: {0}")]
ApiError(String),
}
impl From<SlackClientError> for SlackError {
fn from(err: SlackClientError) -> Self {
match err {
SlackClientError::RateLimitError(info) => {
SlackError::RateLimited(info.retry_after.unwrap_or(60))
}
SlackClientError::ApiError(api_err) => {
match api_err.code.as_str() {
"channel_not_found" => SlackError::ChannelNotFound,
"user_not_found" => SlackError::UserNotFound(String::new()),
"missing_scope" => SlackError::MissingScope(api_err.message.unwrap_or_default()),
_ => SlackError::ApiError(api_err.code),
}
}
_ => SlackError::ApiError(err.to_string()),
}
}
}
}
Signal Channel Specification
Overview
Signal channel implementation via signal-cli JSON-RPC interface.
Dependencies
[dependencies]
tokio = { version = "1", features = ["full", "process"] }
serde_json = "1.0"
Authentication
#![allow(unused)]
fn main() {
pub struct SignalConfig {
/// Phone number with country code (+1234567890)
pub phone_number: String,
/// signal-cli data directory
pub data_dir: PathBuf,
/// signal-cli binary path
pub signal_cli_path: PathBuf,
/// JSON-RPC socket path
pub socket_path: Option<PathBuf>,
/// Trust mode for safety numbers
pub trust_mode: TrustMode,
}
#[derive(Debug, Clone, Copy, Default)]
pub enum TrustMode {
/// Trust on first use
#[default]
Tofu,
/// Always trust
Always,
/// Never trust new keys automatically
Never,
}
}
Channel Implementation
#![allow(unused)]
fn main() {
pub struct SignalChannel {
config: SignalConfig,
process: Option<Child>,
stdin: Option<ChildStdin>,
message_tx: mpsc::Sender<InboundMessage>,
message_rx: mpsc::Receiver<InboundMessage>,
connected: AtomicBool,
request_id: AtomicU64,
}
#[async_trait]
impl Channel for SignalChannel {
fn id(&self) -> &str { "signal" }
fn name(&self) -> &str { "Signal" }
fn capabilities(&self) -> ChannelCapabilities {
ChannelCapabilities {
chat_types: vec![ChatType::Direct, ChatType::Group],
media: MediaCapabilities {
images: true,
audio: true,
video: true,
files: true,
stickers: true,
voice_notes: true,
max_file_size_mb: 100,
},
features: ChannelFeatures {
reactions: true,
threads: false,
edits: true,
deletes: true,
typing_indicators: true,
read_receipts: true,
mentions: true,
polls: false,
buttons: false,
inline_queries: false,
commands: false,
markdown: false,
html: false,
},
limits: ChannelLimits {
max_message_length: 65536,
max_caption_length: 2048,
max_buttons_per_row: 0,
max_button_rows: 0,
},
}
}
async fn connect(&mut self) -> Result<()>;
async fn disconnect(&mut self) -> Result<()>;
fn is_connected(&self) -> bool;
async fn health(&self) -> Result<ChannelHealth>;
async fn send(&self, message: OutboundMessage) -> Result<SendResult>;
async fn edit(&self, target: MessageTarget, new_content: &str) -> Result<()>;
async fn delete(&self, target: MessageTarget) -> Result<()>;
async fn react(&self, target: MessageTarget, emoji: &str) -> Result<()>;
async fn unreact(&self, target: MessageTarget, emoji: &str) -> Result<()>;
async fn typing(&self, chat_id: &str) -> Result<()>;
fn messages(&self) -> Pin<Box<dyn Stream<Item = InboundMessage> + Send>>;
}
}
JSON-RPC Protocol
#![allow(unused)]
fn main() {
#[derive(Debug, Serialize)]
struct JsonRpcRequest {
jsonrpc: &'static str,
id: u64,
method: String,
params: serde_json::Value,
}
#[derive(Debug, Deserialize)]
struct JsonRpcResponse {
jsonrpc: String,
id: Option<u64>,
result: Option<serde_json::Value>,
error: Option<JsonRpcError>,
}
#[derive(Debug, Deserialize)]
struct JsonRpcError {
code: i32,
message: String,
data: Option<serde_json::Value>,
}
impl SignalChannel {
async fn call_rpc<T: DeserializeOwned>(
&self,
method: &str,
params: serde_json::Value,
) -> Result<T> {
let id = self.request_id.fetch_add(1, Ordering::SeqCst);
let request = JsonRpcRequest {
jsonrpc: "2.0",
id,
method: method.to_string(),
params,
};
let stdin = self.stdin.as_ref().ok_or(ChannelError::NotConnected)?;
let request_line = serde_json::to_string(&request)? + "\n";
stdin.write_all(request_line.as_bytes()).await?;
// Response handling via stdout reader task
// ...
Ok(result)
}
}
}
Message Handling
Inbound Message Conversion
#![allow(unused)]
fn main() {
#[derive(Debug, Deserialize)]
struct SignalEnvelope {
source: String,
source_uuid: Option<String>,
source_device: u32,
timestamp: u64,
#[serde(rename = "dataMessage")]
data_message: Option<SignalDataMessage>,
#[serde(rename = "syncMessage")]
sync_message: Option<SignalSyncMessage>,
#[serde(rename = "receiptMessage")]
receipt_message: Option<SignalReceiptMessage>,
}
#[derive(Debug, Deserialize)]
struct SignalDataMessage {
timestamp: u64,
message: Option<String>,
#[serde(rename = "groupInfo")]
group_info: Option<SignalGroupInfo>,
attachments: Option<Vec<SignalAttachment>>,
quote: Option<SignalQuote>,
reaction: Option<SignalReaction>,
mentions: Option<Vec<SignalMention>>,
}
impl SignalChannel {
fn convert_message(&self, envelope: SignalEnvelope) -> Option<InboundMessage> {
let data = envelope.data_message.as_ref()?;
Some(InboundMessage {
id: MessageId::new(data.timestamp.to_string()),
timestamp: DateTime::from_timestamp_millis(data.timestamp as i64)?,
channel: "signal".to_string(),
account_id: self.config.phone_number.clone(),
sender: SenderInfo {
id: envelope.source_uuid.clone().unwrap_or(envelope.source.clone()),
username: None,
display_name: None,
phone_number: Some(envelope.source.clone()),
is_bot: false,
},
chat: ChatInfo {
id: data.group_info.as_ref()
.map(|g| g.group_id.clone())
.unwrap_or_else(|| envelope.source.clone()),
chat_type: if data.group_info.is_some() {
ChatType::Group
} else {
ChatType::Direct
},
title: data.group_info.as_ref().and_then(|g| g.name.clone()),
guild_id: None,
},
text: data.message.clone().unwrap_or_default(),
media: self.extract_media(&data.attachments),
quote: data.quote.as_ref().map(|q| QuotedMessage {
id: q.id.to_string(),
text: q.text.clone(),
sender_id: q.author_uuid.clone().or(q.author.clone()),
}),
thread: None,
metadata: serde_json::json!({
"source_device": envelope.source_device,
"mentions": data.mentions,
}),
})
}
fn extract_media(&self, attachments: &Option<Vec<SignalAttachment>>) -> Vec<MediaAttachment> {
attachments.as_ref().map(|atts| {
atts.iter().map(|a| MediaAttachment {
attachment_type: self.guess_media_type(&a.content_type),
url: None,
file_id: a.id.clone(),
file_path: a.filename.as_ref().map(PathBuf::from),
mime_type: Some(a.content_type.clone()),
file_name: a.filename.clone(),
file_size: a.size.map(|s| s as u64),
caption: None,
}).collect()
}).unwrap_or_default()
}
}
}
Outbound Message Sending
#![allow(unused)]
fn main() {
impl SignalChannel {
async fn send_message(
&self,
recipient: &str,
message: &OutboundMessage,
) -> Result<SendResult> {
let mut params = serde_json::json!({
"message": message.text,
});
// Determine if group or individual
if recipient.starts_with("group.") || recipient.len() == 44 {
params["groupId"] = serde_json::Value::String(recipient.to_string());
} else {
params["recipient"] = serde_json::Value::String(recipient.to_string());
}
// Handle quote
if let Some(reply_to) = &message.reply_to {
if let Ok(timestamp) = reply_to.parse::<u64>() {
params["quoteTimestamp"] = timestamp.into();
}
}
// Handle attachments
if !message.media.is_empty() {
let attachments: Vec<_> = message.media.iter()
.filter_map(|m| m.file_path.as_ref())
.map(|p| p.to_string_lossy().to_string())
.collect();
params["attachments"] = serde_json::to_value(attachments)?;
}
let result: serde_json::Value = self.call_rpc("send", params).await?;
Ok(SendResult {
message_id: result["timestamp"].as_u64()
.map(|t| t.to_string())
.unwrap_or_default(),
timestamp: Utc::now(),
})
}
}
}
Signal-Specific Actions
#![allow(unused)]
fn main() {
impl SignalChannel {
/// Send a reaction
pub async fn send_reaction(
&self,
recipient: &str,
target_author: &str,
target_timestamp: u64,
emoji: &str,
remove: bool,
) -> Result<()> {
let params = serde_json::json!({
"recipient": recipient,
"targetAuthor": target_author,
"targetTimestamp": target_timestamp,
"emoji": emoji,
"remove": remove,
});
self.call_rpc::<serde_json::Value>("sendReaction", params).await?;
Ok(())
}
/// Send typing indicator
pub async fn send_typing(&self, recipient: &str, stop: bool) -> Result<()> {
let params = serde_json::json!({
"recipient": recipient,
"stop": stop,
});
self.call_rpc::<serde_json::Value>("sendTyping", params).await?;
Ok(())
}
/// Send read receipt
pub async fn send_read_receipt(
&self,
recipient: &str,
timestamps: Vec<u64>,
) -> Result<()> {
let params = serde_json::json!({
"recipient": recipient,
"targetTimestamp": timestamps,
});
self.call_rpc::<serde_json::Value>("sendReceipt", params).await?;
Ok(())
}
/// Get group info
pub async fn get_group(&self, group_id: &str) -> Result<SignalGroupInfo> {
let params = serde_json::json!({
"groupId": group_id,
});
self.call_rpc("getGroup", params).await
}
/// List groups
pub async fn list_groups(&self) -> Result<Vec<SignalGroupInfo>> {
self.call_rpc("listGroups", serde_json::json!({})).await
}
/// Get contact name
pub async fn get_contact(&self, number: &str) -> Result<SignalContact> {
let params = serde_json::json!({
"recipient": number,
});
self.call_rpc("getContact", params).await
}
/// Update profile
pub async fn update_profile(
&self,
name: Option<&str>,
about: Option<&str>,
avatar: Option<&Path>,
) -> Result<()> {
let mut params = serde_json::json!({});
if let Some(n) = name {
params["name"] = n.into();
}
if let Some(a) = about {
params["about"] = a.into();
}
if let Some(av) = avatar {
params["avatar"] = av.to_string_lossy().into_owned().into();
}
self.call_rpc::<serde_json::Value>("updateProfile", params).await?;
Ok(())
}
/// Set disappearing messages timer
pub async fn set_expiration(
&self,
recipient: &str,
expiration_seconds: u32,
) -> Result<()> {
let params = serde_json::json!({
"recipient": recipient,
"expiration": expiration_seconds,
});
self.call_rpc::<serde_json::Value>("setExpiration", params).await?;
Ok(())
}
}
}
Error Handling
#![allow(unused)]
fn main() {
#[derive(Debug, thiserror::Error)]
pub enum SignalError {
#[error("signal-cli not connected")]
NotConnected,
#[error("Invalid phone number: {0}")]
InvalidPhoneNumber(String),
#[error("Recipient not found: {0}")]
RecipientNotFound(String),
#[error("Group not found: {0}")]
GroupNotFound(String),
#[error("Untrusted identity for {0}")]
UntrustedIdentity(String),
#[error("Rate limited")]
RateLimited,
#[error("Attachment too large")]
AttachmentTooLarge,
#[error("signal-cli error: {0}")]
CliError(String),
#[error("JSON-RPC error {code}: {message}")]
RpcError { code: i32, message: String },
}
impl From<JsonRpcError> for SignalError {
fn from(err: JsonRpcError) -> Self {
match err.code {
-1 => SignalError::CliError(err.message),
-2 => SignalError::UntrustedIdentity(err.message),
-3 => SignalError::RateLimited,
_ => SignalError::RpcError {
code: err.code,
message: err.message,
},
}
}
}
}
iMessage Channel Specification
Overview
iMessage channel implementation for macOS using AppleScript/JXA bridge.
Dependencies
[dependencies]
tokio = { version = "1", features = ["full", "process"] }
serde_json = "1.0"
Authentication
#![allow(unused)]
fn main() {
pub struct IMessageConfig {
/// Apple ID email (for identification)
pub apple_id: Option<String>,
/// BlueBubbles server URL (optional alternative backend)
pub bluebubbles_url: Option<String>,
/// BlueBubbles password
pub bluebubbles_password: Option<SecretString>,
/// Messages database path (for reading history)
pub messages_db_path: PathBuf,
/// Polling interval in milliseconds
pub poll_interval_ms: u64,
}
impl Default for IMessageConfig {
fn default() -> Self {
let home = dirs::home_dir().unwrap_or_default();
Self {
apple_id: None,
bluebubbles_url: None,
bluebubbles_password: None,
messages_db_path: home.join("Library/Messages/chat.db"),
poll_interval_ms: 1000,
}
}
}
}
Channel Implementation
#![allow(unused)]
fn main() {
pub struct IMessageChannel {
config: IMessageConfig,
message_tx: mpsc::Sender<InboundMessage>,
message_rx: mpsc::Receiver<InboundMessage>,
connected: AtomicBool,
last_message_id: AtomicI64,
poll_handle: Option<JoinHandle<()>>,
}
#[async_trait]
impl Channel for IMessageChannel {
fn id(&self) -> &str { "imessage" }
fn name(&self) -> &str { "iMessage" }
fn capabilities(&self) -> ChannelCapabilities {
ChannelCapabilities {
chat_types: vec![ChatType::Direct, ChatType::Group],
media: MediaCapabilities {
images: true,
audio: true,
video: true,
files: true,
stickers: false,
voice_notes: true,
max_file_size_mb: 100,
},
features: ChannelFeatures {
reactions: true,
threads: false,
edits: false,
deletes: false,
typing_indicators: true,
read_receipts: true,
mentions: false,
polls: false,
buttons: false,
inline_queries: false,
commands: false,
markdown: false,
html: false,
},
limits: ChannelLimits {
max_message_length: 20000,
max_caption_length: 20000,
max_buttons_per_row: 0,
max_button_rows: 0,
},
}
}
async fn connect(&mut self) -> Result<()>;
async fn disconnect(&mut self) -> Result<()>;
fn is_connected(&self) -> bool;
async fn health(&self) -> Result<ChannelHealth>;
async fn send(&self, message: OutboundMessage) -> Result<SendResult>;
async fn edit(&self, target: MessageTarget, new_content: &str) -> Result<()>;
async fn delete(&self, target: MessageTarget) -> Result<()>;
async fn react(&self, target: MessageTarget, emoji: &str) -> Result<()>;
async fn unreact(&self, target: MessageTarget, emoji: &str) -> Result<()>;
async fn typing(&self, chat_id: &str) -> Result<()>;
fn messages(&self) -> Pin<Box<dyn Stream<Item = InboundMessage> + Send>>;
}
}
AppleScript Bridge
#![allow(unused)]
fn main() {
impl IMessageChannel {
async fn run_applescript(&self, script: &str) -> Result<String> {
let output = Command::new("osascript")
.arg("-e")
.arg(script)
.output()
.await?;
if output.status.success() {
Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
} else {
Err(ChannelError::ActionFailed(
String::from_utf8_lossy(&output.stderr).to_string()
))
}
}
async fn run_jxa(&self, script: &str) -> Result<serde_json::Value> {
let output = Command::new("osascript")
.arg("-l")
.arg("JavaScript")
.arg("-e")
.arg(script)
.output()
.await?;
if output.status.success() {
let stdout = String::from_utf8_lossy(&output.stdout);
serde_json::from_str(&stdout)
.map_err(|e| ChannelError::ActionFailed(e.to_string()))
} else {
Err(ChannelError::ActionFailed(
String::from_utf8_lossy(&output.stderr).to_string()
))
}
}
}
}
Message Handling
Database Polling
#![allow(unused)]
fn main() {
impl IMessageChannel {
async fn poll_messages(&self) -> Result<Vec<InboundMessage>> {
let db = rusqlite::Connection::open(&self.config.messages_db_path)?;
let last_id = self.last_message_id.load(Ordering::SeqCst);
let mut stmt = db.prepare(r#"
SELECT
m.ROWID,
m.guid,
m.text,
m.date,
m.is_from_me,
m.handle_id,
h.id as sender_id,
c.chat_identifier,
c.display_name,
c.group_id
FROM message m
LEFT JOIN handle h ON m.handle_id = h.ROWID
LEFT JOIN chat_message_join cmj ON m.ROWID = cmj.message_id
LEFT JOIN chat c ON cmj.chat_id = c.ROWID
WHERE m.ROWID > ?
ORDER BY m.ROWID ASC
LIMIT 100
"#)?;
let messages: Vec<InboundMessage> = stmt.query_map([last_id], |row| {
Ok(self.row_to_message(row))
})?.filter_map(|r| r.ok()).collect();
if let Some(last) = messages.last() {
if let Ok(id) = last.id.as_str().parse::<i64>() {
self.last_message_id.store(id, Ordering::SeqCst);
}
}
Ok(messages)
}
fn row_to_message(&self, row: &rusqlite::Row) -> InboundMessage {
let rowid: i64 = row.get(0).unwrap_or(0);
let guid: String = row.get(1).unwrap_or_default();
let text: Option<String> = row.get(2).ok();
let date: i64 = row.get(3).unwrap_or(0);
let is_from_me: bool = row.get::<_, i32>(4).unwrap_or(0) == 1;
let sender_id: Option<String> = row.get(6).ok();
let chat_identifier: Option<String> = row.get(7).ok();
let display_name: Option<String> = row.get(8).ok();
let group_id: Option<String> = row.get(9).ok();
// Convert Apple's CoreData timestamp (nanoseconds since 2001-01-01)
let timestamp = self.apple_timestamp_to_datetime(date);
InboundMessage {
id: MessageId::new(rowid.to_string()),
timestamp,
channel: "imessage".to_string(),
account_id: self.config.apple_id.clone().unwrap_or_default(),
sender: SenderInfo {
id: if is_from_me {
self.config.apple_id.clone().unwrap_or_else(|| "me".to_string())
} else {
sender_id.clone().unwrap_or_default()
},
username: None,
display_name: None,
phone_number: sender_id,
is_bot: false,
},
chat: ChatInfo {
id: chat_identifier.clone().unwrap_or_default(),
chat_type: if group_id.is_some() {
ChatType::Group
} else {
ChatType::Direct
},
title: display_name,
guild_id: None,
},
text: text.unwrap_or_default(),
media: vec![], // Attachments fetched separately
quote: None,
thread: None,
metadata: serde_json::json!({
"guid": guid,
"is_from_me": is_from_me,
}),
}
}
fn apple_timestamp_to_datetime(&self, timestamp: i64) -> DateTime<Utc> {
// Apple timestamps are nanoseconds since 2001-01-01
let apple_epoch = DateTime::parse_from_rfc3339("2001-01-01T00:00:00Z")
.unwrap()
.with_timezone(&Utc);
let duration = chrono::Duration::nanoseconds(timestamp);
apple_epoch + duration
}
}
}
Outbound Message Sending
#![allow(unused)]
fn main() {
impl IMessageChannel {
async fn send_message(&self, recipient: &str, message: &OutboundMessage) -> Result<SendResult> {
let escaped_text = message.text.replace("\"", "\\\"").replace("\n", "\\n");
let escaped_recipient = recipient.replace("\"", "\\\"");
let script = format!(r#"
tell application "Messages"
set targetService to 1st service whose service type = iMessage
set targetBuddy to buddy "{}" of targetService
send "{}" to targetBuddy
end tell
"#, escaped_recipient, escaped_text);
self.run_applescript(&script).await?;
Ok(SendResult {
message_id: Utc::now().timestamp_millis().to_string(),
timestamp: Utc::now(),
})
}
async fn send_message_with_effect(
&self,
recipient: &str,
text: &str,
effect_id: &str,
) -> Result<SendResult> {
// Effects require JXA
let script = format!(r#"
(() => {{
const Messages = Application('Messages');
const service = Messages.services.byName('iMessage');
const buddy = service.buddies.byId('{}');
Messages.send('{}', {{
to: buddy,
withMessageEffect: '{}'
}});
return JSON.stringify({{ ok: true }});
}})()
"#, recipient, text.replace("'", "\\'"), effect_id);
self.run_jxa(&script).await?;
Ok(SendResult {
message_id: Utc::now().timestamp_millis().to_string(),
timestamp: Utc::now(),
})
}
async fn send_attachment(&self, recipient: &str, file_path: &Path) -> Result<()> {
let script = format!(r#"
tell application "Messages"
set targetService to 1st service whose service type = iMessage
set targetBuddy to buddy "{}" of targetService
send POSIX file "{}" to targetBuddy
end tell
"#, recipient, file_path.display());
self.run_applescript(&script).await?;
Ok(())
}
}
}
iMessage Effects
#![allow(unused)]
fn main() {
/// Available iMessage effects
#[derive(Debug, Clone, Copy)]
pub enum IMessageEffect {
Slam,
Loud,
Gentle,
InvisibleInk,
Echo,
Spotlight,
Balloons,
Confetti,
Love,
Lasers,
Fireworks,
Celebration,
}
impl IMessageEffect {
pub fn effect_id(&self) -> &'static str {
match self {
Self::Slam => "com.apple.MobileSMS.expressivesend.impact",
Self::Loud => "com.apple.MobileSMS.expressivesend.loud",
Self::Gentle => "com.apple.MobileSMS.expressivesend.gentle",
Self::InvisibleInk => "com.apple.MobileSMS.expressivesend.invisibleink",
Self::Echo => "com.apple.messages.effect.CKEchoEffect",
Self::Spotlight => "com.apple.messages.effect.CKSpotlightEffect",
Self::Balloons => "com.apple.messages.effect.CKHappyBirthdayEffect",
Self::Confetti => "com.apple.messages.effect.CKConfettiEffect",
Self::Love => "com.apple.messages.effect.CKHeartEffect",
Self::Lasers => "com.apple.messages.effect.CKLasersEffect",
Self::Fireworks => "com.apple.messages.effect.CKFireworksEffect",
Self::Celebration => "com.apple.messages.effect.CKSparklesEffect",
}
}
}
}
iMessage-Specific Actions
#![allow(unused)]
fn main() {
impl IMessageChannel {
/// Send a tapback reaction
pub async fn send_tapback(
&self,
chat_id: &str,
message_guid: &str,
tapback: Tapback,
) -> Result<()> {
// Tapbacks require direct SQLite manipulation or BlueBubbles
if let Some(ref bb_url) = self.config.bluebubbles_url {
self.send_tapback_bluebubbles(bb_url, chat_id, message_guid, tapback).await
} else {
Err(ChannelError::NotSupported("Tapbacks require BlueBubbles".into()))
}
}
/// Start typing indicator
pub async fn start_typing(&self, chat_id: &str) -> Result<()> {
// Typing indicators are automatic in Messages.app
// Can be simulated via BlueBubbles
Ok(())
}
/// Get chat participants
pub async fn get_chat_participants(&self, chat_id: &str) -> Result<Vec<String>> {
let db = rusqlite::Connection::open(&self.config.messages_db_path)?;
let mut stmt = db.prepare(r#"
SELECT h.id
FROM handle h
JOIN chat_handle_join chj ON h.ROWID = chj.handle_id
JOIN chat c ON chj.chat_id = c.ROWID
WHERE c.chat_identifier = ?
"#)?;
let participants: Vec<String> = stmt.query_map([chat_id], |row| {
row.get(0)
})?.filter_map(|r| r.ok()).collect();
Ok(participants)
}
/// Get recent chats
pub async fn get_recent_chats(&self, limit: usize) -> Result<Vec<ChatInfo>> {
let db = rusqlite::Connection::open(&self.config.messages_db_path)?;
let mut stmt = db.prepare(r#"
SELECT DISTINCT
c.chat_identifier,
c.display_name,
c.group_id,
MAX(m.date) as last_message
FROM chat c
JOIN chat_message_join cmj ON c.ROWID = cmj.chat_id
JOIN message m ON cmj.message_id = m.ROWID
GROUP BY c.ROWID
ORDER BY last_message DESC
LIMIT ?
"#)?;
let chats: Vec<ChatInfo> = stmt.query_map([limit], |row| {
let chat_identifier: String = row.get(0)?;
let display_name: Option<String> = row.get(1).ok();
let group_id: Option<String> = row.get(2).ok();
Ok(ChatInfo {
id: chat_identifier,
chat_type: if group_id.is_some() {
ChatType::Group
} else {
ChatType::Direct
},
title: display_name,
guild_id: None,
})
})?.filter_map(|r| r.ok()).collect();
Ok(chats)
}
}
#[derive(Debug, Clone, Copy)]
pub enum Tapback {
Love,
Like,
Dislike,
Laugh,
Emphasis,
Question,
}
}
Error Handling
#![allow(unused)]
fn main() {
#[derive(Debug, thiserror::Error)]
pub enum IMessageError {
#[error("Messages.app not available")]
MessagesNotAvailable,
#[error("Database not accessible: {0}")]
DatabaseError(String),
#[error("AppleScript error: {0}")]
AppleScriptError(String),
#[error("Recipient not found: {0}")]
RecipientNotFound(String),
#[error("iMessage not enabled for recipient")]
NotIMessage,
#[error("Effect not supported: {0}")]
EffectNotSupported(String),
#[error("BlueBubbles error: {0}")]
BlueBubblesError(String),
}
}
WhatsApp Channel Specification
Overview
WhatsApp channel implementation via WhatsApp Business API or whatsapp-web.js bridge.
Dependencies
[dependencies]
tokio = { version = "1", features = ["full"] }
reqwest = { version = "0.11", features = ["json"] }
serde_json = "1.0"
Authentication
#![allow(unused)]
fn main() {
pub struct WhatsAppConfig {
/// WhatsApp Business API mode or Web bridge
pub mode: WhatsAppMode,
/// Business API configuration
pub business: Option<WhatsAppBusinessConfig>,
/// Web bridge configuration
pub web: Option<WhatsAppWebConfig>,
}
#[derive(Debug, Clone)]
pub enum WhatsAppMode {
/// Official WhatsApp Business API
Business,
/// whatsapp-web.js bridge
Web,
}
#[derive(Debug, Clone)]
pub struct WhatsAppBusinessConfig {
/// Phone number ID
pub phone_number_id: String,
/// WhatsApp Business Account ID
pub waba_id: String,
/// Access token
pub access_token: SecretString,
/// API version
pub api_version: String,
/// Webhook verify token
pub verify_token: SecretString,
}
#[derive(Debug, Clone)]
pub struct WhatsAppWebConfig {
/// Bridge server URL
pub bridge_url: String,
/// Session name
pub session_name: String,
/// Data directory for session files
pub data_dir: PathBuf,
}
}
Channel Implementation
#![allow(unused)]
fn main() {
pub struct WhatsAppChannel {
config: WhatsAppConfig,
client: reqwest::Client,
message_tx: mpsc::Sender<InboundMessage>,
message_rx: mpsc::Receiver<InboundMessage>,
connected: AtomicBool,
}
#[async_trait]
impl Channel for WhatsAppChannel {
fn id(&self) -> &str { "whatsapp" }
fn name(&self) -> &str { "WhatsApp" }
fn capabilities(&self) -> ChannelCapabilities {
ChannelCapabilities {
chat_types: vec![ChatType::Direct, ChatType::Group],
media: MediaCapabilities {
images: true,
audio: true,
video: true,
files: true,
stickers: true,
voice_notes: true,
max_file_size_mb: 16, // 100MB for documents
},
features: ChannelFeatures {
reactions: true,
threads: false,
edits: false,
deletes: true,
typing_indicators: false,
read_receipts: true,
mentions: true,
polls: true,
buttons: true,
inline_queries: false,
commands: false,
markdown: true,
html: false,
},
limits: ChannelLimits {
max_message_length: 4096,
max_caption_length: 1024,
max_buttons_per_row: 3,
max_button_rows: 10,
},
}
}
async fn connect(&mut self) -> Result<()>;
async fn disconnect(&mut self) -> Result<()>;
fn is_connected(&self) -> bool;
async fn health(&self) -> Result<ChannelHealth>;
async fn send(&self, message: OutboundMessage) -> Result<SendResult>;
async fn edit(&self, target: MessageTarget, new_content: &str) -> Result<()>;
async fn delete(&self, target: MessageTarget) -> Result<()>;
async fn react(&self, target: MessageTarget, emoji: &str) -> Result<()>;
async fn unreact(&self, target: MessageTarget, emoji: &str) -> Result<()>;
async fn typing(&self, chat_id: &str) -> Result<()>;
fn messages(&self) -> Pin<Box<dyn Stream<Item = InboundMessage> + Send>>;
}
}
Business API Implementation
Webhook Handler
#![allow(unused)]
fn main() {
#[derive(Debug, Deserialize)]
pub struct WhatsAppWebhook {
pub object: String,
pub entry: Vec<WebhookEntry>,
}
#[derive(Debug, Deserialize)]
pub struct WebhookEntry {
pub id: String,
pub changes: Vec<WebhookChange>,
}
#[derive(Debug, Deserialize)]
pub struct WebhookChange {
pub field: String,
pub value: WebhookValue,
}
#[derive(Debug, Deserialize)]
pub struct WebhookValue {
pub messaging_product: String,
pub metadata: WebhookMetadata,
pub contacts: Option<Vec<WebhookContact>>,
pub messages: Option<Vec<WebhookMessage>>,
pub statuses: Option<Vec<WebhookStatus>>,
}
#[derive(Debug, Deserialize)]
pub struct WebhookMessage {
pub from: String,
pub id: String,
pub timestamp: String,
#[serde(rename = "type")]
pub message_type: String,
pub text: Option<TextMessage>,
pub image: Option<MediaMessage>,
pub audio: Option<MediaMessage>,
pub video: Option<MediaMessage>,
pub document: Option<DocumentMessage>,
pub sticker: Option<MediaMessage>,
pub location: Option<LocationMessage>,
pub contacts: Option<Vec<ContactMessage>>,
pub interactive: Option<InteractiveResponse>,
pub button: Option<ButtonResponse>,
pub context: Option<MessageContext>,
}
impl WhatsAppChannel {
fn convert_webhook_message(&self, msg: WebhookMessage) -> InboundMessage {
let text = msg.text.map(|t| t.body).unwrap_or_default();
InboundMessage {
id: MessageId::new(msg.id.clone()),
timestamp: DateTime::from_timestamp(msg.timestamp.parse().unwrap_or(0), 0)
.unwrap_or_default(),
channel: "whatsapp".to_string(),
account_id: self.phone_number_id(),
sender: SenderInfo {
id: msg.from.clone(),
username: None,
display_name: None,
phone_number: Some(msg.from),
is_bot: false,
},
chat: ChatInfo {
id: msg.from.clone(),
chat_type: ChatType::Direct, // Group info in context
title: None,
guild_id: None,
},
text,
media: self.extract_media(&msg),
quote: msg.context.map(|c| QuotedMessage {
id: c.id,
text: None,
sender_id: c.from,
}),
thread: None,
metadata: serde_json::json!({
"type": msg.message_type,
}),
}
}
}
}
Sending Messages
#![allow(unused)]
fn main() {
impl WhatsAppChannel {
async fn send_text(&self, to: &str, message: &OutboundMessage) -> Result<SendResult> {
let config = self.business_config()?;
let body = serde_json::json!({
"messaging_product": "whatsapp",
"recipient_type": "individual",
"to": to,
"type": "text",
"text": {
"preview_url": !message.disable_preview,
"body": message.text,
}
});
let response = self.client
.post(format!(
"https://graph.facebook.com/{}/{}/messages",
config.api_version,
config.phone_number_id
))
.bearer_auth(config.access_token.expose_secret())
.json(&body)
.send()
.await?;
let result: serde_json::Value = response.json().await?;
Ok(SendResult {
message_id: result["messages"][0]["id"]
.as_str()
.unwrap_or_default()
.to_string(),
timestamp: Utc::now(),
})
}
async fn send_media(&self, to: &str, media: &MediaAttachment) -> Result<SendResult> {
let config = self.business_config()?;
// First upload the media
let media_id = self.upload_media(media).await?;
let media_type = match media.attachment_type {
MediaType::Image => "image",
MediaType::Video => "video",
MediaType::Audio => "audio",
MediaType::Document => "document",
MediaType::Sticker => "sticker",
_ => "document",
};
let body = serde_json::json!({
"messaging_product": "whatsapp",
"recipient_type": "individual",
"to": to,
"type": media_type,
media_type: {
"id": media_id,
"caption": media.caption,
}
});
let response = self.client
.post(format!(
"https://graph.facebook.com/{}/{}/messages",
config.api_version,
config.phone_number_id
))
.bearer_auth(config.access_token.expose_secret())
.json(&body)
.send()
.await?;
let result: serde_json::Value = response.json().await?;
Ok(SendResult {
message_id: result["messages"][0]["id"]
.as_str()
.unwrap_or_default()
.to_string(),
timestamp: Utc::now(),
})
}
async fn send_interactive(&self, to: &str, interactive: InteractiveMessage) -> Result<SendResult> {
let config = self.business_config()?;
let body = serde_json::json!({
"messaging_product": "whatsapp",
"recipient_type": "individual",
"to": to,
"type": "interactive",
"interactive": interactive,
});
let response = self.client
.post(format!(
"https://graph.facebook.com/{}/{}/messages",
config.api_version,
config.phone_number_id
))
.bearer_auth(config.access_token.expose_secret())
.json(&body)
.send()
.await?;
let result: serde_json::Value = response.json().await?;
Ok(SendResult {
message_id: result["messages"][0]["id"]
.as_str()
.unwrap_or_default()
.to_string(),
timestamp: Utc::now(),
})
}
}
}
Interactive Messages
#![allow(unused)]
fn main() {
#[derive(Debug, Serialize)]
pub struct InteractiveMessage {
#[serde(rename = "type")]
pub interactive_type: String,
pub header: Option<InteractiveHeader>,
pub body: InteractiveBody,
pub footer: Option<InteractiveFooter>,
pub action: InteractiveAction,
}
#[derive(Debug, Serialize)]
pub struct InteractiveHeader {
#[serde(rename = "type")]
pub header_type: String,
pub text: Option<String>,
pub image: Option<MediaId>,
pub video: Option<MediaId>,
pub document: Option<MediaId>,
}
#[derive(Debug, Serialize)]
pub struct InteractiveBody {
pub text: String,
}
#[derive(Debug, Serialize)]
pub struct InteractiveFooter {
pub text: String,
}
#[derive(Debug, Serialize)]
#[serde(untagged)]
pub enum InteractiveAction {
Buttons { buttons: Vec<InteractiveButton> },
List { button: String, sections: Vec<ListSection> },
CatalogMessage { /* ... */ },
}
#[derive(Debug, Serialize)]
pub struct InteractiveButton {
#[serde(rename = "type")]
pub button_type: String, // "reply"
pub reply: ButtonReply,
}
#[derive(Debug, Serialize)]
pub struct ButtonReply {
pub id: String,
pub title: String,
}
#[derive(Debug, Serialize)]
pub struct ListSection {
pub title: String,
pub rows: Vec<ListRow>,
}
#[derive(Debug, Serialize)]
pub struct ListRow {
pub id: String,
pub title: String,
pub description: Option<String>,
}
}
WhatsApp-Specific Actions
#![allow(unused)]
fn main() {
impl WhatsAppChannel {
/// Send a template message
pub async fn send_template(
&self,
to: &str,
template_name: &str,
language_code: &str,
components: Vec<TemplateComponent>,
) -> Result<SendResult> {
let config = self.business_config()?;
let body = serde_json::json!({
"messaging_product": "whatsapp",
"to": to,
"type": "template",
"template": {
"name": template_name,
"language": {
"code": language_code,
},
"components": components,
}
});
// Send request...
todo!()
}
/// Mark message as read
pub async fn mark_read(&self, message_id: &str) -> Result<()> {
let config = self.business_config()?;
let body = serde_json::json!({
"messaging_product": "whatsapp",
"status": "read",
"message_id": message_id,
});
self.client
.post(format!(
"https://graph.facebook.com/{}/{}/messages",
config.api_version,
config.phone_number_id
))
.bearer_auth(config.access_token.expose_secret())
.json(&body)
.send()
.await?;
Ok(())
}
/// Upload media
pub async fn upload_media(&self, media: &MediaAttachment) -> Result<String> {
let config = self.business_config()?;
let file_path = media.file_path.as_ref()
.ok_or(ChannelError::InvalidMessage("No file path".into()))?;
let content = tokio::fs::read(file_path).await?;
let mime_type = media.mime_type.clone().unwrap_or_else(|| "application/octet-stream".into());
let form = reqwest::multipart::Form::new()
.text("messaging_product", "whatsapp")
.text("type", mime_type.clone())
.part("file", reqwest::multipart::Part::bytes(content)
.mime_str(&mime_type)?
.file_name(media.file_name.clone().unwrap_or_else(|| "file".into())));
let response = self.client
.post(format!(
"https://graph.facebook.com/{}/{}/media",
config.api_version,
config.phone_number_id
))
.bearer_auth(config.access_token.expose_secret())
.multipart(form)
.send()
.await?;
let result: serde_json::Value = response.json().await?;
result["id"].as_str()
.map(|s| s.to_string())
.ok_or_else(|| ChannelError::ActionFailed("No media ID returned".into()))
}
/// Get media URL
pub async fn get_media_url(&self, media_id: &str) -> Result<String> {
let config = self.business_config()?;
let response = self.client
.get(format!(
"https://graph.facebook.com/{}/{}",
config.api_version,
media_id
))
.bearer_auth(config.access_token.expose_secret())
.send()
.await?;
let result: serde_json::Value = response.json().await?;
result["url"].as_str()
.map(|s| s.to_string())
.ok_or_else(|| ChannelError::ActionFailed("No media URL".into()))
}
}
}
Error Handling
#![allow(unused)]
fn main() {
#[derive(Debug, thiserror::Error)]
pub enum WhatsAppError {
#[error("Not connected")]
NotConnected,
#[error("Invalid phone number: {0}")]
InvalidPhoneNumber(String),
#[error("Message failed: {0}")]
MessageFailed(String),
#[error("Template not approved: {0}")]
TemplateNotApproved(String),
#[error("Rate limited")]
RateLimited,
#[error("Media upload failed: {0}")]
MediaUploadFailed(String),
#[error("Webhook verification failed")]
WebhookVerificationFailed,
#[error("API error: {code} - {message}")]
ApiError { code: i32, message: String },
}
}
LINE Channel Specification
Overview
LINE channel implementation via LINE Messaging API.
Dependencies
[dependencies]
tokio = { version = "1", features = ["full"] }
reqwest = { version = "0.11", features = ["json"] }
hmac = "0.12"
sha2 = "0.10"
base64 = "0.21"
Authentication
#![allow(unused)]
fn main() {
pub struct LineConfig {
/// Channel access token
pub channel_access_token: SecretString,
/// Channel secret for webhook signature verification
pub channel_secret: SecretString,
/// Channel ID
pub channel_id: String,
/// Webhook URL
pub webhook_url: Option<String>,
}
}
Channel Implementation
#![allow(unused)]
fn main() {
pub struct LineChannel {
config: LineConfig,
client: reqwest::Client,
message_tx: mpsc::Sender<InboundMessage>,
message_rx: mpsc::Receiver<InboundMessage>,
connected: AtomicBool,
}
#[async_trait]
impl Channel for LineChannel {
fn id(&self) -> &str { "line" }
fn name(&self) -> &str { "LINE" }
fn capabilities(&self) -> ChannelCapabilities {
ChannelCapabilities {
chat_types: vec![ChatType::Direct, ChatType::Group],
media: MediaCapabilities {
images: true,
audio: true,
video: true,
files: true,
stickers: true,
voice_notes: false,
max_file_size_mb: 300,
},
features: ChannelFeatures {
reactions: false,
threads: false,
edits: false,
deletes: false,
typing_indicators: false,
read_receipts: false,
mentions: true,
polls: false,
buttons: true,
inline_queries: false,
commands: false,
markdown: false,
html: false,
},
limits: ChannelLimits {
max_message_length: 5000,
max_caption_length: 2000,
max_buttons_per_row: 3,
max_button_rows: 4,
},
}
}
async fn connect(&mut self) -> Result<()>;
async fn disconnect(&mut self) -> Result<()>;
fn is_connected(&self) -> bool;
async fn health(&self) -> Result<ChannelHealth>;
async fn send(&self, message: OutboundMessage) -> Result<SendResult>;
async fn edit(&self, target: MessageTarget, new_content: &str) -> Result<()>;
async fn delete(&self, target: MessageTarget) -> Result<()>;
async fn react(&self, target: MessageTarget, emoji: &str) -> Result<()>;
async fn unreact(&self, target: MessageTarget, emoji: &str) -> Result<()>;
async fn typing(&self, chat_id: &str) -> Result<()>;
fn messages(&self) -> Pin<Box<dyn Stream<Item = InboundMessage> + Send>>;
}
}
Webhook Handler
#![allow(unused)]
fn main() {
#[derive(Debug, Deserialize)]
pub struct LineWebhook {
pub destination: String,
pub events: Vec<LineEvent>,
}
#[derive(Debug, Deserialize)]
#[serde(tag = "type")]
pub enum LineEvent {
#[serde(rename = "message")]
Message(MessageEvent),
#[serde(rename = "follow")]
Follow(FollowEvent),
#[serde(rename = "unfollow")]
Unfollow(UnfollowEvent),
#[serde(rename = "join")]
Join(JoinEvent),
#[serde(rename = "leave")]
Leave(LeaveEvent),
#[serde(rename = "postback")]
Postback(PostbackEvent),
}
#[derive(Debug, Deserialize)]
pub struct MessageEvent {
#[serde(rename = "replyToken")]
pub reply_token: String,
pub source: EventSource,
pub timestamp: u64,
pub message: LineMessage,
}
#[derive(Debug, Deserialize)]
#[serde(tag = "type")]
pub enum EventSource {
#[serde(rename = "user")]
User { #[serde(rename = "userId")] user_id: String },
#[serde(rename = "group")]
Group {
#[serde(rename = "groupId")]
group_id: String,
#[serde(rename = "userId")]
user_id: Option<String>,
},
#[serde(rename = "room")]
Room {
#[serde(rename = "roomId")]
room_id: String,
#[serde(rename = "userId")]
user_id: Option<String>,
},
}
#[derive(Debug, Deserialize)]
#[serde(tag = "type")]
pub enum LineMessage {
#[serde(rename = "text")]
Text { id: String, text: String },
#[serde(rename = "image")]
Image { id: String, #[serde(rename = "contentProvider")] content_provider: ContentProvider },
#[serde(rename = "video")]
Video { id: String, duration: u64, #[serde(rename = "contentProvider")] content_provider: ContentProvider },
#[serde(rename = "audio")]
Audio { id: String, duration: u64, #[serde(rename = "contentProvider")] content_provider: ContentProvider },
#[serde(rename = "file")]
File { id: String, #[serde(rename = "fileName")] file_name: String, #[serde(rename = "fileSize")] file_size: u64 },
#[serde(rename = "location")]
Location { id: String, title: String, address: String, latitude: f64, longitude: f64 },
#[serde(rename = "sticker")]
Sticker { id: String, #[serde(rename = "packageId")] package_id: String, #[serde(rename = "stickerId")] sticker_id: String },
}
impl LineChannel {
pub fn verify_signature(&self, body: &[u8], signature: &str) -> bool {
use hmac::{Hmac, Mac};
use sha2::Sha256;
let mut mac = Hmac::<Sha256>::new_from_slice(
self.config.channel_secret.expose_secret().as_bytes()
).expect("HMAC can take key of any size");
mac.update(body);
let expected = base64::Engine::encode(&base64::engine::general_purpose::STANDARD, mac.finalize().into_bytes());
expected == signature
}
fn convert_message(&self, event: MessageEvent) -> InboundMessage {
let (sender_id, chat_id, chat_type) = match &event.source {
EventSource::User { user_id } => (
user_id.clone(),
user_id.clone(),
ChatType::Direct,
),
EventSource::Group { group_id, user_id } => (
user_id.clone().unwrap_or_default(),
group_id.clone(),
ChatType::Group,
),
EventSource::Room { room_id, user_id } => (
user_id.clone().unwrap_or_default(),
room_id.clone(),
ChatType::Group,
),
};
let (text, media) = match &event.message {
LineMessage::Text { text, .. } => (text.clone(), vec![]),
LineMessage::Image { id, .. } => (String::new(), vec![MediaAttachment {
attachment_type: MediaType::Image,
url: None,
file_id: Some(id.clone()),
file_path: None,
mime_type: Some("image/jpeg".into()),
file_name: None,
file_size: None,
caption: None,
}]),
LineMessage::Video { id, .. } => (String::new(), vec![MediaAttachment {
attachment_type: MediaType::Video,
url: None,
file_id: Some(id.clone()),
file_path: None,
mime_type: Some("video/mp4".into()),
file_name: None,
file_size: None,
caption: None,
}]),
LineMessage::Audio { id, .. } => (String::new(), vec![MediaAttachment {
attachment_type: MediaType::Audio,
url: None,
file_id: Some(id.clone()),
file_path: None,
mime_type: Some("audio/m4a".into()),
file_name: None,
file_size: None,
caption: None,
}]),
LineMessage::File { id, file_name, file_size } => (String::new(), vec![MediaAttachment {
attachment_type: MediaType::Document,
url: None,
file_id: Some(id.clone()),
file_path: None,
mime_type: None,
file_name: Some(file_name.clone()),
file_size: Some(*file_size),
caption: None,
}]),
LineMessage::Location { title, address, .. } => {
(format!("{}\n{}", title, address), vec![])
}
LineMessage::Sticker { package_id, sticker_id, .. } => {
(format!("[Sticker: {}/{}]", package_id, sticker_id), vec![])
}
};
InboundMessage {
id: MessageId::new(match &event.message {
LineMessage::Text { id, .. } => id.clone(),
LineMessage::Image { id, .. } => id.clone(),
LineMessage::Video { id, .. } => id.clone(),
LineMessage::Audio { id, .. } => id.clone(),
LineMessage::File { id, .. } => id.clone(),
LineMessage::Location { id, .. } => id.clone(),
LineMessage::Sticker { id, .. } => id.clone(),
}),
timestamp: DateTime::from_timestamp_millis(event.timestamp as i64).unwrap_or_default(),
channel: "line".to_string(),
account_id: self.config.channel_id.clone(),
sender: SenderInfo {
id: sender_id,
username: None,
display_name: None,
phone_number: None,
is_bot: false,
},
chat: ChatInfo {
id: chat_id,
chat_type,
title: None,
guild_id: None,
},
text,
media,
quote: None,
thread: None,
metadata: serde_json::json!({
"reply_token": event.reply_token,
}),
}
}
}
}
Sending Messages
#![allow(unused)]
fn main() {
impl LineChannel {
async fn reply(&self, reply_token: &str, messages: Vec<LineOutboundMessage>) -> Result<()> {
let body = serde_json::json!({
"replyToken": reply_token,
"messages": messages,
});
self.client
.post("https://api.line.me/v2/bot/message/reply")
.bearer_auth(self.config.channel_access_token.expose_secret())
.json(&body)
.send()
.await?;
Ok(())
}
async fn push(&self, to: &str, messages: Vec<LineOutboundMessage>) -> Result<()> {
let body = serde_json::json!({
"to": to,
"messages": messages,
});
self.client
.post("https://api.line.me/v2/bot/message/push")
.bearer_auth(self.config.channel_access_token.expose_secret())
.json(&body)
.send()
.await?;
Ok(())
}
fn build_text_message(&self, text: &str) -> LineOutboundMessage {
LineOutboundMessage::Text { text: text.to_string() }
}
fn build_image_message(&self, original_url: &str, preview_url: &str) -> LineOutboundMessage {
LineOutboundMessage::Image {
original_content_url: original_url.to_string(),
preview_image_url: preview_url.to_string(),
}
}
fn build_buttons_template(
&self,
title: &str,
text: &str,
actions: Vec<LineAction>,
) -> LineOutboundMessage {
LineOutboundMessage::Template {
alt_text: text.to_string(),
template: LineTemplate::Buttons {
title: Some(title.to_string()),
text: text.to_string(),
actions,
},
}
}
}
#[derive(Debug, Serialize)]
#[serde(tag = "type")]
pub enum LineOutboundMessage {
#[serde(rename = "text")]
Text { text: String },
#[serde(rename = "image")]
Image {
#[serde(rename = "originalContentUrl")]
original_content_url: String,
#[serde(rename = "previewImageUrl")]
preview_image_url: String,
},
#[serde(rename = "video")]
Video {
#[serde(rename = "originalContentUrl")]
original_content_url: String,
#[serde(rename = "previewImageUrl")]
preview_image_url: String,
},
#[serde(rename = "audio")]
Audio {
#[serde(rename = "originalContentUrl")]
original_content_url: String,
duration: u64,
},
#[serde(rename = "sticker")]
Sticker {
#[serde(rename = "packageId")]
package_id: String,
#[serde(rename = "stickerId")]
sticker_id: String,
},
#[serde(rename = "template")]
Template {
#[serde(rename = "altText")]
alt_text: String,
template: LineTemplate,
},
#[serde(rename = "flex")]
Flex {
#[serde(rename = "altText")]
alt_text: String,
contents: serde_json::Value,
},
}
#[derive(Debug, Serialize)]
#[serde(tag = "type")]
pub enum LineTemplate {
#[serde(rename = "buttons")]
Buttons {
title: Option<String>,
text: String,
actions: Vec<LineAction>,
},
#[serde(rename = "confirm")]
Confirm {
text: String,
actions: Vec<LineAction>,
},
#[serde(rename = "carousel")]
Carousel {
columns: Vec<CarouselColumn>,
},
}
#[derive(Debug, Serialize)]
#[serde(tag = "type")]
pub enum LineAction {
#[serde(rename = "message")]
Message { label: String, text: String },
#[serde(rename = "uri")]
Uri { label: String, uri: String },
#[serde(rename = "postback")]
Postback { label: String, data: String, #[serde(rename = "displayText")] display_text: Option<String> },
}
}
LINE-Specific Actions
#![allow(unused)]
fn main() {
impl LineChannel {
/// Get user profile
pub async fn get_profile(&self, user_id: &str) -> Result<LineProfile> {
let response = self.client
.get(format!("https://api.line.me/v2/bot/profile/{}", user_id))
.bearer_auth(self.config.channel_access_token.expose_secret())
.send()
.await?;
response.json().await.map_err(|e| ChannelError::ActionFailed(e.to_string()))
}
/// Get group summary
pub async fn get_group_summary(&self, group_id: &str) -> Result<LineGroupSummary> {
let response = self.client
.get(format!("https://api.line.me/v2/bot/group/{}/summary", group_id))
.bearer_auth(self.config.channel_access_token.expose_secret())
.send()
.await?;
response.json().await.map_err(|e| ChannelError::ActionFailed(e.to_string()))
}
/// Get message content
pub async fn get_content(&self, message_id: &str) -> Result<Vec<u8>> {
let response = self.client
.get(format!("https://api-data.line.me/v2/bot/message/{}/content", message_id))
.bearer_auth(self.config.channel_access_token.expose_secret())
.send()
.await?;
response.bytes().await.map(|b| b.to_vec()).map_err(|e| ChannelError::ActionFailed(e.to_string()))
}
/// Leave group
pub async fn leave_group(&self, group_id: &str) -> Result<()> {
self.client
.post(format!("https://api.line.me/v2/bot/group/{}/leave", group_id))
.bearer_auth(self.config.channel_access_token.expose_secret())
.send()
.await?;
Ok(())
}
/// Get quota
pub async fn get_quota(&self) -> Result<LineQuota> {
let response = self.client
.get("https://api.line.me/v2/bot/message/quota")
.bearer_auth(self.config.channel_access_token.expose_secret())
.send()
.await?;
response.json().await.map_err(|e| ChannelError::ActionFailed(e.to_string()))
}
}
#[derive(Debug, Deserialize)]
pub struct LineProfile {
#[serde(rename = "displayName")]
pub display_name: String,
#[serde(rename = "userId")]
pub user_id: String,
#[serde(rename = "pictureUrl")]
pub picture_url: Option<String>,
#[serde(rename = "statusMessage")]
pub status_message: Option<String>,
}
#[derive(Debug, Deserialize)]
pub struct LineGroupSummary {
#[serde(rename = "groupId")]
pub group_id: String,
#[serde(rename = "groupName")]
pub group_name: String,
#[serde(rename = "pictureUrl")]
pub picture_url: Option<String>,
}
#[derive(Debug, Deserialize)]
pub struct LineQuota {
#[serde(rename = "type")]
pub quota_type: String,
pub value: i64,
}
}
Error Handling
#![allow(unused)]
fn main() {
#[derive(Debug, thiserror::Error)]
pub enum LineError {
#[error("Not connected")]
NotConnected,
#[error("Invalid signature")]
InvalidSignature,
#[error("Invalid user ID: {0}")]
InvalidUserId(String),
#[error("Reply token expired")]
ReplyTokenExpired,
#[error("Rate limited")]
RateLimited,
#[error("Quota exceeded")]
QuotaExceeded,
#[error("API error: {0}")]
ApiError(String),
}
}
Web Channel Specification
Overview
Web channel for browser-based chat interface via WebSocket.
Dependencies
[dependencies]
tokio = { version = "1", features = ["full"] }
tokio-tungstenite = "0.20"
axum = { version = "0.7", features = ["ws"] }
tower = "0.4"
serde_json = "1.0"
uuid = { version = "1.0", features = ["v4"] }
Authentication
#![allow(unused)]
fn main() {
pub struct WebConfig {
/// Server bind address
pub bind_address: SocketAddr,
/// TLS configuration
pub tls: Option<TlsConfig>,
/// CORS origins
pub cors_origins: Vec<String>,
/// Session timeout in seconds
pub session_timeout_secs: u64,
/// Maximum message size
pub max_message_size: usize,
/// Authentication mode
pub auth: WebAuthConfig,
}
#[derive(Debug, Clone)]
pub struct TlsConfig {
pub cert_path: PathBuf,
pub key_path: PathBuf,
}
#[derive(Debug, Clone)]
pub enum WebAuthConfig {
/// No authentication required
None,
/// Bearer token authentication
Token { tokens: Vec<SecretString> },
/// Custom authentication handler
Custom,
}
}
Channel Implementation
#![allow(unused)]
fn main() {
pub struct WebChannel {
config: WebConfig,
sessions: Arc<RwLock<HashMap<String, WebSession>>>,
message_tx: mpsc::Sender<InboundMessage>,
message_rx: mpsc::Receiver<InboundMessage>,
server_handle: Option<JoinHandle<()>>,
connected: AtomicBool,
}
#[async_trait]
impl Channel for WebChannel {
fn id(&self) -> &str { "web" }
fn name(&self) -> &str { "Web" }
fn capabilities(&self) -> ChannelCapabilities {
ChannelCapabilities {
chat_types: vec![ChatType::Direct],
media: MediaCapabilities {
images: true,
audio: true,
video: true,
files: true,
stickers: false,
voice_notes: false,
max_file_size_mb: 100,
},
features: ChannelFeatures {
reactions: true,
threads: false,
edits: true,
deletes: true,
typing_indicators: true,
read_receipts: true,
mentions: false,
polls: false,
buttons: true,
inline_queries: false,
commands: true,
markdown: true,
html: true,
},
limits: ChannelLimits {
max_message_length: 65536,
max_caption_length: 4096,
max_buttons_per_row: 10,
max_button_rows: 20,
},
}
}
async fn connect(&mut self) -> Result<()>;
async fn disconnect(&mut self) -> Result<()>;
fn is_connected(&self) -> bool;
async fn health(&self) -> Result<ChannelHealth>;
async fn send(&self, message: OutboundMessage) -> Result<SendResult>;
async fn edit(&self, target: MessageTarget, new_content: &str) -> Result<()>;
async fn delete(&self, target: MessageTarget) -> Result<()>;
async fn react(&self, target: MessageTarget, emoji: &str) -> Result<()>;
async fn unreact(&self, target: MessageTarget, emoji: &str) -> Result<()>;
async fn typing(&self, chat_id: &str) -> Result<()>;
fn messages(&self) -> Pin<Box<dyn Stream<Item = InboundMessage> + Send>>;
}
}
WebSocket Session
#![allow(unused)]
fn main() {
pub struct WebSession {
pub id: String,
pub user_id: String,
pub user_name: Option<String>,
pub created_at: DateTime<Utc>,
pub last_activity: DateTime<Utc>,
pub ws_tx: mpsc::Sender<WebSocketMessage>,
pub metadata: HashMap<String, serde_json::Value>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "type")]
pub enum WebSocketMessage {
/// Client -> Server: User message
#[serde(rename = "message")]
Message {
id: String,
text: String,
#[serde(skip_serializing_if = "Option::is_none")]
media: Option<Vec<WebMedia>>,
#[serde(skip_serializing_if = "Option::is_none")]
reply_to: Option<String>,
},
/// Server -> Client: Assistant message
#[serde(rename = "response")]
Response {
id: String,
text: String,
#[serde(skip_serializing_if = "Option::is_none")]
media: Option<Vec<WebMedia>>,
#[serde(skip_serializing_if = "Option::is_none")]
buttons: Option<Vec<Vec<WebButton>>>,
#[serde(skip_serializing_if = "Option::is_none")]
metadata: Option<serde_json::Value>,
},
/// Server -> Client: Streaming chunk
#[serde(rename = "chunk")]
Chunk {
id: String,
text: String,
#[serde(rename = "done")]
is_done: bool,
},
/// Client -> Server: Typing indicator
#[serde(rename = "typing")]
Typing {
#[serde(rename = "isTyping")]
is_typing: bool,
},
/// Server -> Client: Bot typing indicator
#[serde(rename = "bot_typing")]
BotTyping {
#[serde(rename = "isTyping")]
is_typing: bool,
},
/// Client -> Server: Read receipt
#[serde(rename = "read")]
Read {
#[serde(rename = "messageId")]
message_id: String,
},
/// Client -> Server: Button click
#[serde(rename = "button_click")]
ButtonClick {
#[serde(rename = "messageId")]
message_id: String,
#[serde(rename = "buttonId")]
button_id: String,
data: String,
},
/// Server -> Client: Message edit
#[serde(rename = "edit")]
Edit {
id: String,
#[serde(rename = "newText")]
new_text: String,
},
/// Server -> Client: Message delete
#[serde(rename = "delete")]
Delete {
id: String,
},
/// Server -> Client: Error
#[serde(rename = "error")]
Error {
code: String,
message: String,
},
/// Ping/Pong for keep-alive
#[serde(rename = "ping")]
Ping,
#[serde(rename = "pong")]
Pong,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct WebMedia {
#[serde(rename = "type")]
pub media_type: String,
pub url: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub name: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub size: Option<u64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub mime_type: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct WebButton {
pub id: String,
pub text: String,
#[serde(rename = "type")]
pub button_type: String, // "callback" | "url"
#[serde(skip_serializing_if = "Option::is_none")]
pub data: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub url: Option<String>,
}
}
HTTP Server
#![allow(unused)]
fn main() {
impl WebChannel {
async fn start_server(&mut self) -> Result<()> {
let sessions = self.sessions.clone();
let message_tx = self.message_tx.clone();
let config = self.config.clone();
let app = Router::new()
.route("/ws", get(Self::websocket_handler))
.route("/health", get(Self::health_handler))
.route("/upload", post(Self::upload_handler))
.layer(CorsLayer::new()
.allow_origin(config.cors_origins.iter()
.map(|o| o.parse().unwrap())
.collect::<Vec<_>>())
.allow_methods([Method::GET, Method::POST])
.allow_headers(Any))
.with_state(WebState {
sessions,
message_tx,
config,
});
let listener = TcpListener::bind(&self.config.bind_address).await?;
self.server_handle = Some(tokio::spawn(async move {
axum::serve(listener, app).await.ok();
}));
self.connected.store(true, Ordering::SeqCst);
Ok(())
}
async fn websocket_handler(
ws: WebSocketUpgrade,
State(state): State<WebState>,
headers: HeaderMap,
) -> impl IntoResponse {
// Authenticate
if let Err(e) = Self::authenticate(&state.config.auth, &headers) {
return (StatusCode::UNAUTHORIZED, e.to_string()).into_response();
}
ws.on_upgrade(move |socket| Self::handle_socket(socket, state))
}
async fn handle_socket(socket: WebSocket, state: WebState) {
let session_id = Uuid::new_v4().to_string();
let (ws_tx, mut ws_rx) = socket.split();
let (tx, rx) = mpsc::channel(32);
// Create session
let session = WebSession {
id: session_id.clone(),
user_id: session_id.clone(), // Could be extracted from auth
user_name: None,
created_at: Utc::now(),
last_activity: Utc::now(),
ws_tx: tx,
metadata: HashMap::new(),
};
state.sessions.write().await.insert(session_id.clone(), session);
// Spawn sender task
let sender = tokio::spawn(async move {
let mut rx = ReceiverStream::new(rx);
let mut ws_tx = ws_tx;
while let Some(msg) = rx.next().await {
let json = serde_json::to_string(&msg).unwrap();
if ws_tx.send(Message::Text(json)).await.is_err() {
break;
}
}
});
// Receive messages
while let Some(msg) = ws_rx.next().await {
match msg {
Ok(Message::Text(text)) => {
if let Ok(ws_msg) = serde_json::from_str::<WebSocketMessage>(&text) {
Self::handle_message(ws_msg, &session_id, &state).await;
}
}
Ok(Message::Close(_)) => break,
Err(_) => break,
_ => {}
}
}
// Cleanup
state.sessions.write().await.remove(&session_id);
sender.abort();
}
async fn handle_message(msg: WebSocketMessage, session_id: &str, state: &WebState) {
match msg {
WebSocketMessage::Message { id, text, media, reply_to } => {
let inbound = InboundMessage {
id: MessageId::new(id),
timestamp: Utc::now(),
channel: "web".to_string(),
account_id: "web".to_string(),
sender: SenderInfo {
id: session_id.to_string(),
username: None,
display_name: None,
phone_number: None,
is_bot: false,
},
chat: ChatInfo {
id: session_id.to_string(),
chat_type: ChatType::Direct,
title: None,
guild_id: None,
},
text,
media: media.map(|m| m.into_iter().map(|wm| MediaAttachment {
attachment_type: match wm.media_type.as_str() {
"image" => MediaType::Image,
"video" => MediaType::Video,
"audio" => MediaType::Audio,
_ => MediaType::Document,
},
url: Some(wm.url),
file_id: None,
file_path: None,
mime_type: wm.mime_type,
file_name: wm.name,
file_size: wm.size,
caption: None,
}).collect()).unwrap_or_default(),
quote: reply_to.map(|r| QuotedMessage {
id: r,
text: None,
sender_id: None,
}),
thread: None,
metadata: serde_json::json!({
"session_id": session_id,
}),
};
state.message_tx.send(inbound).await.ok();
}
WebSocketMessage::Typing { is_typing } => {
// Handle typing indicator
}
WebSocketMessage::Read { message_id } => {
// Handle read receipt
}
WebSocketMessage::ButtonClick { message_id, button_id, data } => {
// Handle button click as message
}
WebSocketMessage::Ping => {
// Send pong
}
_ => {}
}
}
}
}
Sending Messages
#![allow(unused)]
fn main() {
impl WebChannel {
async fn send_to_session(&self, session_id: &str, msg: WebSocketMessage) -> Result<()> {
let sessions = self.sessions.read().await;
let session = sessions.get(session_id)
.ok_or(ChannelError::NotConnected)?;
session.ws_tx.send(msg).await
.map_err(|e| ChannelError::SendFailed(e.to_string()))
}
async fn send_response(
&self,
session_id: &str,
message: &OutboundMessage,
) -> Result<SendResult> {
let msg_id = Uuid::new_v4().to_string();
let buttons = message.buttons.as_ref().map(|rows| {
rows.iter().map(|row| {
row.iter().map(|btn| WebButton {
id: Uuid::new_v4().to_string(),
text: btn.text.clone(),
button_type: match &btn.action {
ButtonAction::Callback(_) => "callback".to_string(),
ButtonAction::Url(_) => "url".to_string(),
},
data: match &btn.action {
ButtonAction::Callback(d) => Some(d.clone()),
_ => None,
},
url: match &btn.action {
ButtonAction::Url(u) => Some(u.clone()),
_ => None,
},
}).collect()
}).collect()
});
let media = if message.media.is_empty() {
None
} else {
Some(message.media.iter().map(|m| WebMedia {
media_type: match m.attachment_type {
MediaType::Image => "image".to_string(),
MediaType::Video => "video".to_string(),
MediaType::Audio => "audio".to_string(),
_ => "file".to_string(),
},
url: m.url.clone().unwrap_or_default(),
name: m.file_name.clone(),
size: m.file_size,
mime_type: m.mime_type.clone(),
}).collect())
};
let ws_msg = WebSocketMessage::Response {
id: msg_id.clone(),
text: message.text.clone(),
media,
buttons,
metadata: None,
};
self.send_to_session(session_id, ws_msg).await?;
Ok(SendResult {
message_id: msg_id,
timestamp: Utc::now(),
})
}
async fn send_streaming_chunk(
&self,
session_id: &str,
message_id: &str,
text: &str,
is_done: bool,
) -> Result<()> {
let ws_msg = WebSocketMessage::Chunk {
id: message_id.to_string(),
text: text.to_string(),
is_done,
};
self.send_to_session(session_id, ws_msg).await
}
}
}
Error Handling
#![allow(unused)]
fn main() {
#[derive(Debug, thiserror::Error)]
pub enum WebError {
#[error("Server not running")]
NotRunning,
#[error("Session not found: {0}")]
SessionNotFound(String),
#[error("Authentication failed")]
AuthenticationFailed,
#[error("Invalid message format")]
InvalidMessage,
#[error("Connection closed")]
ConnectionClosed,
#[error("Rate limited")]
RateLimited,
#[error("Server error: {0}")]
ServerError(String),
}
}
Repository Layout
smartassist/
├── crates/
│ ├── smartassist-core # Core types, configuration, shared utilities
│ ├── smartassist-sandbox # Command execution sandboxing
│ ├── smartassist-channels # Messaging channel implementations (feature-gated)
│ ├── smartassist-agent # Agent runtime, sessions, and tool execution
│ ├── smartassist-memory # Memory / vector store and embeddings
│ ├── smartassist-gateway # JSON-RPC gateway server over WebSocket
│ ├── smartassist-cli # Command-line interface
│ ├── smartassist-plugin-sdk # Plugin development kit for extensions
│ ├── smartassist-providers # Model provider integrations
│ ├── smartassist-secrets # Encrypted secrets and credential management
│ ├── smartassist-security # Guardrails and security policy
│ ├── smartassist-learnings # Learnings / improvement engine
│ ├── smartassist-browser # Browser automation
│ ├── smartassist-canvas # Canvas / UI surface support
│ ├── smartassist-cron # Scheduled task execution
│ ├── smartassist-transcription # Audio transcription
│ ├── smartassist-talk # Voice / talk interface
│ └── smartassist-mcp # Model Context Protocol integration
├── channels/
│ └── smartassist-telegram # Standalone Telegram channel binary
├── apps/
│ ├── android # Android client
│ └── ios # iOS client
├── smartassist-control/ # Tauri + Vite desktop control app
├── examples/
│ └── hello-plugin # Example plugin using smartassist-plugin-sdk
├── tests/
│ ├── integration # Integration test suite
│ ├── bdd # Cucumber BDD suite
│ ├── e2e # End-to-end suite
│ └── fixtures # Shared test fixtures
├── docs/ # This documentation site (mdBook)
└── rust-spec/ # Source specification documents
Each crate under crates/ is a member of the root
Cargo.toml
workspace, sharing a single Cargo.lock and dependency set defined under
[workspace.dependencies].
Agent Tools
The agent includes 101 built-in tools:
File System
read- Read file contentswrite- Write file contentsedit- Edit files with diff-based changesglob- Find files by patterngrep- Search file contents
File Operations
file_copy- Copy files to a new locationfile_move- Move or rename filesfile_stat- Get file/directory information (size, timestamps, permissions)file_delete- Delete files or directories (requires approval)
Archive
zip- Create/extract zip archivestar- Create/extract tar archives (with gzip support)
Checksum
file_checksum- Compute file hash (MD5, SHA1, SHA256, SHA512)file_verify- Verify file matches expected hash
Template
template- Substitute variables in template stringsformat- Format values (JSON, numbers, bytes, durations)
System
bash- Execute shell commands
Process
process_list- List running processesprocess_info- Get current process information
Utility
sleep- Wait for a specified durationtemp_file- Create a temporary filetemp_dir- Create a temporary directoryecho- Echo a value back (useful for testing)
Environment
env_get- Get environment variable valueenv_list- List environment variablesenv_check- Check if environment variables exist
HTTP
http_request- Make HTTP requests to APIsurl_parse- Parse URL into componentsurl_build- Build URL with query parameters
Network
dns_lookup- DNS record lookupport_check- Check TCP port connectivityhttp_ping- Check HTTP/HTTPS endpoint reachabilitynet_info- Get network interface information
Web
web_fetch- Fetch and process web pagesweb_search- Search the web
Messaging
message- Send messagessessions_spawn- Create new agent sessionssessions_send- Send to existing sessionssessions_list- List active sessionssessions_history- Get session historysession_status- Get session status
Memory
memory_search- Search stored memoriesmemory_get- Retrieve specific memoriesmemory_store- Store information in memorymemory_index- Index and organize memory entries
Automation
cron- Schedule recurring tasksgateway- Control the gatewaynodes- Manage distributed nodes
Media
image- Analyze imagestts- Text-to-speech
Browser
browser- Browser automation
Channel Actions
telegram_actions- Telegram-specific actionsdiscord_actions- Discord-specific actionsslack_actions- Slack-specific actions
Notebook
notebook_edit- Edit Jupyter notebook cells
Code Intelligence
lsp- Language Server Protocol for go-to-definition, find-references, hover
Task Management
task_create- Create tasks to track worktask_list- List all taskstask_update- Update task statustask_get- Get task details
Interactive
ask_user- Ask user questions with multiple choice optionsconfirm- Request user confirmation for actions
Planning
enter_plan_mode- Enter planning mode for implementation designexit_plan_mode- Exit planning mode and submit plan for approval
Skills
skill- Invoke a registered skill (slash command)skill_list- List available skills
Diagnostics
system_info- Get system information (OS, architecture, environment)health_check- Check agent health and statusdiagnostic- Run diagnostics to troubleshoot issues
Context Management
context_add- Add information to working context for later referencecontext_get- Retrieve entries from working contextcontext_clear- Clear the working context
Diff & Patch
diff- Generate diffs between text or filespatch- Preview and apply search/replace changes
Git
git_status- Get repository status (staged, modified, untracked)git_log- View commit historygit_diff- View changesgit_branch- List and manage branches
JSON/YAML
json_query- Query JSON data using path expressionsjson_transform- Transform JSON (pick, omit, rename, flatten)yaml- Parse and convert between YAML and JSON
Encoding & Hashing
base64- Base64 encode/decodehex- Hexadecimal encode/decodehash- Compute hashes (MD5, SHA1, SHA256, SHA512)url_encode- URL encode/decode
Time & Date
now- Get current date and timedate_parse- Parse and format date stringsdate_calc- Date calculations (add, subtract, diff)
String Manipulation
case- Convert case (upper, lower, camel, snake, kebab)split_join- Split and join stringsreplace- Text replacement with regex supporttrim_pad- Trim whitespace or pad strings
Math & Random
calc- Mathematical calculations (add, sqrt, power, etc.)random- Generate random numbers, strings, or pick itemsuuid- Generate UUIDs
Validation
validate- Validate formats (email, URL, JSON, UUID, IP, etc.)is_empty- Check if value is empty, null, or blank
Comparison & Assertions
compare- Compare two values (eq, ne, lt, gt, contains, starts_with, ends_with)assert- Assert a condition is true (returns error if false)match- Match text against regex patterns with capture groupsversion_compare- Compare semantic version strings
Gateway RPC Methods
The gateway exposes 46+ RPC methods for:
- Health monitoring (
health,status) - Chat interface (
chat,chat.history,chat.abort) - Agent management (
agent,agent.stream) - Session management (
sessions.list,sessions.resolve,sessions.patch,sessions.delete) - Model management (
models.list) - Configuration (
config.get,config.set,config.patch,config.schema) - Channel messaging (
send,send.poll) - Device pairing (
device.pair.list,device.pair.approve,device.pair.reject,device.token.rotate,device.token.revoke) - Node management (
node.list,node.describe,node.pair.request,node.pair.approve,node.pair.reject,node.unpair,node.rename,node.invoke) - Cron scheduling (
cron.list,cron.status,cron.add,cron.update,cron.remove,cron.run,cron.runs,wake) - Execution approvals (
exec.approvals.get,exec.approvals.set,exec.approval.request,exec.approval.resolve) - Skill management (
skills.status,skills.bins,skills.install,skills.update) - System operations (
system-presence,system-event,last-heartbeat,set-heartbeats,logs.tail) - Setup wizard (
wizard.start,wizard.next,wizard.cancel,wizard.status)
See Gateway Overview for the wire protocol and handler design.
Quick Examples
Short, copy-pasteable snippets. For the full specifications, see Model Providers and Plugin SDK.
Model providers
smartassist-providers includes integrations for major AI providers:
| Provider | Example models | Features |
|---|---|---|
| Anthropic Claude | claude-opus-4-20250514, claude-sonnet-4-20250514, claude-3-5-haiku-20241022 | Streaming, tool calling, vision, 200K context |
| OpenAI GPT | gpt-4o, gpt-4o-mini, o1, o3-mini | Streaming, tool calling, vision, 128K context |
| Google Gemini | gemini-2.0-flash, gemini-1.5-pro | Streaming, tool calling, vision, 2M context |
#![allow(unused)]
fn main() {
use smartassist_providers::{anthropic::AnthropicProvider, Provider};
let provider = AnthropicProvider::from_env()?;
let response = provider.chat(
"claude-sonnet-4-20250514",
&[Message::user("Hello!")],
None,
).await?;
}
Plugin SDK
Create custom plugins using the SDK:
#![allow(unused)]
fn main() {
use smartassist_plugin_sdk::prelude::*;
pub struct MyPlugin;
#[async_trait]
impl Plugin for MyPlugin {
fn metadata(&self) -> PluginMetadata {
PluginMetadata {
name: "my-plugin".to_string(),
version: Version::parse("1.0.0").unwrap(),
description: "My custom plugin".to_string(),
author: Some("Author".to_string()),
homepage: None,
license: Some("MIT".to_string()),
capabilities: vec![PluginCapability::Tool],
min_smartassist_version: None,
}
}
async fn initialize(&mut self, ctx: &PluginContext) -> Result<()> {
Ok(())
}
async fn shutdown(&mut self) -> Result<()> {
Ok(())
}
fn as_any(&self) -> &dyn std::any::Any { self }
fn as_any_mut(&mut self) -> &mut dyn std::any::Any { self }
}
}
Plugin capabilities:
Channel- Custom messaging channelsTool- Custom agent toolsModelProvider- Custom AI model providersHook- Middleware and interceptorsStorage- Custom storage backendsMedia- Media processing
See examples/hello-plugin
for a complete working plugin.