Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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

CrateDescription
smartassist-coreCore types, configuration, and shared utilities
smartassist-sandboxCommand execution sandboxing with platform-specific profiles
smartassist-channelsMessaging channel abstractions (Telegram, Discord, Slack, etc.)
smartassist-agentAgent runtime with tool execution framework
smartassist-memoryMemory and context management for agents
smartassist-gatewayJSON-RPC gateway server over WebSocket
smartassist-cliCommand-line interface
smartassist-plugin-sdkPlugin development kit for extensions
smartassist-providersModel provider integrations (Anthropic, OpenAI, Google)
smartassist-secretsEncrypted secrets and credential management
smartassist-securityGuardrails, sandboxing policy, and security tooling
smartassist-learningsLearnings/improvement engine for agent self-tuning
smartassist-browserBrowser automation
smartassist-canvasCanvas/UI surface support
smartassist-cronScheduled task execution
smartassist-transcriptionAudio transcription
smartassist-talkVoice/talk interface
smartassist-mcpModel 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:

PhaseFocusStatus
Phase 1Core Infrastructure (providers, gateway, tools)Complete
Phase 2Channels (Telegram, Discord, Slack, Signal, WhatsApp, iMessage, LINE, Web)Complete
Phase 3Advanced Features (Plugin SDK, browser automation, memory/embeddings)Complete
Phase 4Platform & Polish (CLI, config validation, comprehensive testing)Complete

Where to go next

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

VariablePurpose
ANTHROPIC_API_KEYAnthropic Claude API key
OPENAI_API_KEYOpenAI API key
GOOGLE_API_KEY / GEMINI_API_KEYGoogle Gemini API key
SMARTASSIST_PROVIDERDefault model provider (anthropic, openai, google)
SMARTASSIST_MODELDefault 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:

MetricTargetActual
Workspace Crates911 (+ smartassist-secrets, examples/hello-plugin)
Channels88 (all complete)
Providers33 (all complete)
Gateway RPC Methods34+46
Agent Tools37+101
Test CoverageComprehensive725 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) ✓

  1. Model providers (Anthropic, OpenAI)
  2. Gateway RPC methods (chat., agent.)
  3. Core tools (bash, read, write, edit, glob, grep)

Phase 2: Channels (Weeks 5-10) ✓

  1. Telegram (full implementation)
  2. Discord (full implementation)
  3. Slack (full implementation)
  4. Signal, WhatsApp, iMessage, Line, Web

Phase 3: Advanced Features (Weeks 11-16) ✓

  1. Plugin SDK
  2. Browser automation tools
  3. Memory/vector search
  4. All remaining tools

Phase 4: Platform & Polish (Weeks 17-20) ✓

  1. CLI completeness
  2. Configuration validation
  3. Comprehensive testing
  4. Documentation

Type Conventions

All specifications use these Rust type conventions:

  • String for owned strings
  • &str for borrowed strings
  • Option<T> for optional fields
  • Vec<T> for arrays
  • HashMap<K, V> for objects/maps
  • serde_json::Value for dynamic JSON
  • chrono::DateTime<Utc> for timestamps
  • uuid::Uuid for 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-trait for async traits
  • futures for stream utilities
  • tokio-stream for 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 response
  • chat.history - Get conversation history
  • chat.abort - Abort ongoing chat

2. Agent Methods

  • agent - Run agent turn
  • agent.stream - Streaming agent turn

3. Session Methods

  • sessions.list - List sessions
  • sessions.resolve - Resolve session label to key
  • sessions.patch - Update session config
  • sessions.delete - Delete session

4. Node Methods

  • node.pair.request - Request node pairing
  • node.pair.approve - Approve pairing
  • node.pair.reject - Reject pairing
  • node.unpair - Unpair node
  • node.rename - Rename node
  • node.list - List nodes
  • node.describe - Get node details
  • node.invoke - Invoke node command
  • node.invoke.result - Node command result
  • node.event - Node event

5. Device Methods

  • device.pair.list - List paired devices
  • device.pair.approve - Approve pairing
  • device.pair.reject - Reject pairing
  • device.token.rotate - Rotate device token
  • device.token.revoke - Revoke device token

6. Exec Approval Methods

  • exec.approvals.get - Get approval config
  • exec.approvals.set - Set approval config
  • exec.approvals.node.get - Get node approval config
  • exec.approvals.node.set - Set node approval config
  • exec.approval.request - Request exec approval
  • exec.approval.resolve - Resolve approval

7. Config Methods

  • config.get - Get config
  • config.set - Set config
  • config.apply - Apply config with restart
  • config.patch - Patch config
  • config.schema - Get config schema

8. Cron Methods

  • cron.list - List cron jobs
  • cron.status - Get cron status
  • cron.add - Add cron job
  • cron.update - Update cron job
  • cron.remove - Remove cron job
  • cron.run - Run cron job
  • cron.runs - Get cron run history
  • wake - Send wake event

9. Skill Methods

  • skills.status - Get skills status
  • skills.bins - Get skill binaries
  • skills.install - Install skill
  • skills.update - Update skill

10. Model Methods

  • models.list - List available models

11. Health Methods

  • health - Get health status
  • status - Get system status
  • logs.tail - Tail logs

12. System Methods

  • system-presence - Get system presence
  • system-event - Send system event
  • last-heartbeat - Get last heartbeat
  • set-heartbeats - Enable/disable heartbeats

13. Wizard Methods

  • wizard.start - Start setup wizard
  • wizard.next - Next wizard step
  • wizard.cancel - Cancel wizard
  • wizard.status - Get wizard status

14. Send Methods

  • send - Send message
  • send.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 contents
  • write - Write file contents
  • edit - Edit file with search/replace
  • glob - Find files by pattern
  • grep - Search file contents

2. Execution Tools

  • bash - Execute shell commands

3. Web Tools

  • web_fetch - Fetch and extract web content
  • web_search - Search the web

4. Media Tools

  • image - Analyze images with vision models
  • tts - Text to speech conversion

5. Messaging Tools

  • message - Multi-channel messaging
  • telegram_actions - Telegram-specific actions
  • discord_actions - Discord-specific actions
  • slack_actions - Slack-specific actions

6. Session Tools

  • sessions_spawn - Create sub-agent sessions
  • sessions_send - Send messages to sessions
  • sessions_list - List active sessions
  • sessions_history - Get session history
  • session_status - Get current session status

7. Memory Tools

  • memory_search - Semantic search of memory
  • memory_get - Read memory files

8. Automation Tools

  • cron - Manage scheduled jobs
  • gateway - 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 location
  • file_move - Move or rename files
  • file_stat - Get file/directory information
  • file_delete - Delete files or directories

12. Archive Tools

  • zip - Create/extract zip archives
  • tar - 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 strings
  • format - Format values (JSON, numbers, bytes, durations)

15. Process Tools

  • process_list - List running processes
  • process_info - Get current process information

16. Utility Tools

  • sleep - Wait for a specified duration
  • temp_file - Create a temporary file
  • temp_dir - Create a temporary directory
  • echo - Echo a value back

17. Environment Tools

  • env_get - Get environment variable value
  • env_list - List environment variables
  • env_check - Check if environment variables exist

18. HTTP Tools

  • http_request - Make HTTP requests to APIs
  • url_parse - Parse URL into components
  • url_build - Build URL with query parameters

19. Network Tools

  • dns_lookup - DNS record lookup
  • port_check - Check TCP port connectivity
  • http_ping - Check HTTP/HTTPS endpoint reachability
  • net_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 work
  • task_list - List all tasks
  • task_update - Update task status
  • task_get - Get task details

23. Interactive Tools

  • ask_user - Ask user questions with multiple choice options
  • confirm - Request user confirmation for actions

24. Planning Tools

  • enter_plan_mode - Enter planning mode for implementation design
  • exit_plan_mode - Exit planning mode and submit plan

25. Skills Tools

  • skill - Invoke a registered skill
  • skill_list - List available skills

26. Diagnostics Tools

  • system_info - Get system information
  • health_check - Check agent health and status
  • diagnostic - Run diagnostics to troubleshoot issues

27. Context Management Tools

  • context_add - Add information to working context
  • context_get - Retrieve entries from working context
  • context_clear - Clear the working context

28. Diff & Patch Tools

  • diff - Generate diffs between text or files
  • patch - Preview and apply search/replace changes

29. Git Tools

  • git_status - Get repository status
  • git_log - View commit history
  • git_diff - View changes
  • git_branch - List and manage branches

30. JSON/YAML Tools

  • json_query - Query JSON data using path expressions
  • json_transform - Transform JSON (pick, omit, rename, flatten)
  • yaml - Parse and convert between YAML and JSON

31. Encoding & Hashing Tools

  • base64 - Base64 encode/decode
  • hex - Hexadecimal encode/decode
  • hash - Compute hashes (MD5, SHA1, SHA256, SHA512)
  • url_encode - URL encode/decode

32. Time & Date Tools

  • now - Get current date and time
  • date_parse - Parse and format date strings
  • date_calc - Date calculations (add, subtract, diff)

33. String Manipulation Tools

  • case - Convert case (upper, lower, camel, snake, kebab)
  • split_join - Split and join strings
  • replace - Text replacement with regex support
  • trim_pad - Trim whitespace or pad strings

34. Math & Random Tools

  • calc - Mathematical calculations
  • random - Generate random numbers, strings, or pick items
  • uuid - 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 values
  • assert - Assert a condition is true
  • match - Match text against regex patterns
  • version_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 contents
  • write - Write file contents
  • edit - Edit files with diff-based changes
  • glob - Find files by pattern
  • grep - Search file contents

File Operations

  • file_copy - Copy files to a new location
  • file_move - Move or rename files
  • file_stat - Get file/directory information (size, timestamps, permissions)
  • file_delete - Delete files or directories (requires approval)

Archive

  • zip - Create/extract zip archives
  • tar - 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 strings
  • format - Format values (JSON, numbers, bytes, durations)

System

  • bash - Execute shell commands

Process

  • process_list - List running processes
  • process_info - Get current process information

Utility

  • sleep - Wait for a specified duration
  • temp_file - Create a temporary file
  • temp_dir - Create a temporary directory
  • echo - Echo a value back (useful for testing)

Environment

  • env_get - Get environment variable value
  • env_list - List environment variables
  • env_check - Check if environment variables exist

HTTP

  • http_request - Make HTTP requests to APIs
  • url_parse - Parse URL into components
  • url_build - Build URL with query parameters

Network

  • dns_lookup - DNS record lookup
  • port_check - Check TCP port connectivity
  • http_ping - Check HTTP/HTTPS endpoint reachability
  • net_info - Get network interface information

Web

  • web_fetch - Fetch and process web pages
  • web_search - Search the web

Messaging

  • message - Send messages
  • sessions_spawn - Create new agent sessions
  • sessions_send - Send to existing sessions
  • sessions_list - List active sessions
  • sessions_history - Get session history
  • session_status - Get session status

Memory

  • memory_search - Search stored memories
  • memory_get - Retrieve specific memories
  • memory_store - Store information in memory
  • memory_index - Index and organize memory entries

Automation

  • cron - Schedule recurring tasks
  • gateway - Control the gateway
  • nodes - Manage distributed nodes

Media

  • image - Analyze images
  • tts - Text-to-speech

Browser

  • browser - Browser automation

Channel Actions

  • telegram_actions - Telegram-specific actions
  • discord_actions - Discord-specific actions
  • slack_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 work
  • task_list - List all tasks
  • task_update - Update task status
  • task_get - Get task details

Interactive

  • ask_user - Ask user questions with multiple choice options
  • confirm - Request user confirmation for actions

Planning

  • enter_plan_mode - Enter planning mode for implementation design
  • exit_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 status
  • diagnostic - Run diagnostics to troubleshoot issues

Context Management

  • context_add - Add information to working context for later reference
  • context_get - Retrieve entries from working context
  • context_clear - Clear the working context

Diff & Patch

  • diff - Generate diffs between text or files
  • patch - Preview and apply search/replace changes

Git

  • git_status - Get repository status (staged, modified, untracked)
  • git_log - View commit history
  • git_diff - View changes
  • git_branch - List and manage branches

JSON/YAML

  • json_query - Query JSON data using path expressions
  • json_transform - Transform JSON (pick, omit, rename, flatten)
  • yaml - Parse and convert between YAML and JSON

Encoding & Hashing

  • base64 - Base64 encode/decode
  • hex - Hexadecimal encode/decode
  • hash - Compute hashes (MD5, SHA1, SHA256, SHA512)
  • url_encode - URL encode/decode

Time & Date

  • now - Get current date and time
  • date_parse - Parse and format date strings
  • date_calc - Date calculations (add, subtract, diff)

String Manipulation

  • case - Convert case (upper, lower, camel, snake, kebab)
  • split_join - Split and join strings
  • replace - Text replacement with regex support
  • trim_pad - Trim whitespace or pad strings

Math & Random

  • calc - Mathematical calculations (add, sqrt, power, etc.)
  • random - Generate random numbers, strings, or pick items
  • uuid - 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 groups
  • version_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:

ProviderExample modelsFeatures
Anthropic Claudeclaude-opus-4-20250514, claude-sonnet-4-20250514, claude-3-5-haiku-20241022Streaming, tool calling, vision, 200K context
OpenAI GPTgpt-4o, gpt-4o-mini, o1, o3-miniStreaming, tool calling, vision, 128K context
Google Geminigemini-2.0-flash, gemini-1.5-proStreaming, 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 channels
  • Tool - Custom agent tools
  • ModelProvider - Custom AI model providers
  • Hook - Middleware and interceptors
  • Storage - Custom storage backends
  • Media - Media processing

See examples/hello-plugin for a complete working plugin.