mirror of
https://github.com/outbackdingo/optimclaw.git
synced 2026-08-25 14:53:34 +00:00
feat: Add Google Suite & Telegram WASM tools (#9)
* Add Google Calendar and Gmail WASM tools, and /add-tool skill Scaffold two new WASM tools that share a single Google OAuth token: - google-calendar: list/get/create/update/delete calendar events - gmail: list/search/get/send/draft/reply/trash emails Both tools use the sandboxed WIT interface with strict HTTP allowlists, credential injection, and rate limiting. OAuth config requests only the minimum scopes needed (calendar.events, gmail.modify, gmail.compose). Also adds the /add-tool skill for scaffolding future WASM or built-in tools with all boilerplate wired up. Co-Authored-By: Claude Opus 4.6 <[email protected]> * Document WASM vs MCP server decision guide in CLAUDE.md Co-Authored-By: Claude Opus 4.6 <[email protected]> * Add Google Drive WASM tool with full file and sharing management Supports 12 actions: list/get/download/upload/update files, create folders, delete/trash, share/list/remove permissions, and list shared drives. Works with both personal and organizational drives via the corpora parameter. Uses shared google_oauth_token for auth. Co-Authored-By: Claude Opus 4.6 <[email protected]> * Add Google Sheets, Docs, and Slides WASM tools Three new Google Workspace tools sharing google_oauth_token: - Sheets: create spreadsheets, read/write/append values, manage sheets, format cells - Docs: create/read/edit documents, text formatting, paragraphs, tables, lists - Slides: create/edit presentations, shapes, images, text formatting, thumbnails, templates Also adds tools-src/TOOLS.md tracking implementation status. Co-Authored-By: Claude Opus 4.6 <[email protected]> * Add Telegram WASM tool with direct MTProto over HTTPS Replace TDLight Docker dependency with pure-Rust grammers crates for direct encrypted MTProto communication to Telegram's web transport endpoints. No middleware, no Docker needed. Co-Authored-By: Claude Opus 4.6 <[email protected]> * Gitignore Cargo.lock files in WASM tools Library crates should not commit lock files. Consolidate per-tool .gitignore into a single one at wasm-tools/ level. Co-Authored-By: Claude Opus 4.6 <[email protected]> * Flatten tools-src/wasm-tools/ into tools-src/ All tools are WASM, the extra nesting added no value. Moves all tool crates up one level, updates WIT paths and documentation references. Co-Authored-By: Claude Opus 4.6 <[email protected]> * Fix Slack tool: add OAuth auth, URL encoding, pin wit-bindgen - Add OAuth 2.0 auth section to Slack capabilities with proper scopes and manual fallback instructions - URL-encode query parameters in GET requests to prevent injection - Remove dead SlackApiError struct - Pin wit-bindgen to =0.36 across all WASM tools for Rust 1.86 compat - Update add-tool template with pinned version Co-Authored-By: Claude Opus 4.6 <[email protected]> --------- Co-authored-by: Claude Opus 4.6 <[email protected]>
This commit is contained in:
co-authored by
Claude Opus 4.6
parent
e6725eb6d9
commit
a35db4d32d
@@ -0,0 +1,21 @@
|
||||
[package]
|
||||
name = "slack-tool"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
description = "Slack integration tool for IronClaw (WASM component)"
|
||||
license = "MIT OR Apache-2.0"
|
||||
publish = false
|
||||
|
||||
[lib]
|
||||
crate-type = ["cdylib"]
|
||||
|
||||
[dependencies]
|
||||
wit-bindgen = "=0.36"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
|
||||
[profile.release]
|
||||
opt-level = "s"
|
||||
lto = true
|
||||
strip = true
|
||||
codegen-units = 1
|
||||
@@ -0,0 +1,238 @@
|
||||
# Slack WASM Tool
|
||||
|
||||
A standalone WASM component that provides Slack integration for IronClaw. This serves as both a functional tool and a template for building custom WASM tools.
|
||||
|
||||
## Features
|
||||
|
||||
- **send_message**: Send messages to channels or threads
|
||||
- **list_channels**: List channels the bot has access to
|
||||
- **get_channel_history**: Retrieve recent messages from a channel
|
||||
- **post_reaction**: Add emoji reactions to messages
|
||||
- **get_user_info**: Get information about Slack users
|
||||
|
||||
## Prerequisites
|
||||
|
||||
1. **Rust toolchain** with WASM target:
|
||||
```bash
|
||||
rustup target add wasm32-wasip2
|
||||
```
|
||||
|
||||
2. **cargo-component** for building WASM components:
|
||||
```bash
|
||||
cargo install cargo-component
|
||||
```
|
||||
|
||||
3. **Slack Bot Token** with the following OAuth scopes:
|
||||
- `chat:write` - Send messages
|
||||
- `channels:read` - List public channels
|
||||
- `channels:history` - Read channel history
|
||||
- `groups:read` - List private channels
|
||||
- `groups:history` - Read private channel history
|
||||
- `reactions:write` - Add reactions
|
||||
- `users:read` - Get user information
|
||||
|
||||
## Building
|
||||
|
||||
```bash
|
||||
cd tools-src/slack
|
||||
cargo component build --release
|
||||
```
|
||||
|
||||
The compiled WASM component will be at:
|
||||
```
|
||||
target/wasm32-wasip2/release/slack_tool.wasm
|
||||
```
|
||||
|
||||
## Installation
|
||||
|
||||
### Option A: File-based (Development)
|
||||
|
||||
Copy the WASM and capabilities files to the agent's tools directory:
|
||||
|
||||
```bash
|
||||
mkdir -p ~/.ironclaw/tools
|
||||
cp target/wasm32-wasip2/release/slack_tool.wasm ~/.ironclaw/tools/slack.wasm
|
||||
cp slack.capabilities.json ~/.ironclaw/tools/
|
||||
```
|
||||
|
||||
### Option B: Database Storage (Production)
|
||||
|
||||
Use the agent CLI or API to store the tool:
|
||||
|
||||
```bash
|
||||
ironclaw tool install \
|
||||
--name slack \
|
||||
--wasm target/wasm32-wasip2/release/slack_tool.wasm \
|
||||
--capabilities slack.capabilities.json
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
Store your Slack bot token as a secret:
|
||||
|
||||
```bash
|
||||
ironclaw secret set slack_bot_token "xoxb-your-token-here"
|
||||
```
|
||||
|
||||
Or via SQL:
|
||||
```sql
|
||||
INSERT INTO secrets (user_id, name, encrypted_value, key_salt)
|
||||
VALUES ('your_user_id', 'slack_bot_token', ...);
|
||||
```
|
||||
|
||||
## Usage Examples
|
||||
|
||||
### Send a Message
|
||||
|
||||
```json
|
||||
{
|
||||
"action": "send_message",
|
||||
"channel": "#general",
|
||||
"text": "Hello from IronClaw!"
|
||||
}
|
||||
```
|
||||
|
||||
### Reply in a Thread
|
||||
|
||||
```json
|
||||
{
|
||||
"action": "send_message",
|
||||
"channel": "C1234567890",
|
||||
"text": "This is a thread reply",
|
||||
"thread_ts": "1234567890.123456"
|
||||
}
|
||||
```
|
||||
|
||||
### List Channels
|
||||
|
||||
```json
|
||||
{
|
||||
"action": "list_channels",
|
||||
"limit": 50
|
||||
}
|
||||
```
|
||||
|
||||
### Get Channel History
|
||||
|
||||
```json
|
||||
{
|
||||
"action": "get_channel_history",
|
||||
"channel": "C1234567890",
|
||||
"limit": 10
|
||||
}
|
||||
```
|
||||
|
||||
### Add a Reaction
|
||||
|
||||
```json
|
||||
{
|
||||
"action": "post_reaction",
|
||||
"channel": "C1234567890",
|
||||
"timestamp": "1234567890.123456",
|
||||
"emoji": "thumbsup"
|
||||
}
|
||||
```
|
||||
|
||||
### Get User Info
|
||||
|
||||
```json
|
||||
{
|
||||
"action": "get_user_info",
|
||||
"user_id": "U1234567890"
|
||||
}
|
||||
```
|
||||
|
||||
## Security Model
|
||||
|
||||
This tool runs in a sandboxed WASM environment with strict capability controls:
|
||||
|
||||
1. **HTTP Allowlist**: Can only access `slack.com/api/*`
|
||||
2. **Credential Injection**: The bot token is injected by the host runtime; the WASM code never sees it
|
||||
3. **Rate Limiting**: 50 requests/minute, 1000 requests/hour
|
||||
4. **No Filesystem Access**: Cannot read/write files except through workspace capability
|
||||
5. **No Network Access**: Beyond the allowlisted endpoints
|
||||
|
||||
## Capabilities File
|
||||
|
||||
The `slack.capabilities.json` file declares what this tool needs:
|
||||
|
||||
```json
|
||||
{
|
||||
"http": {
|
||||
"allowlist": [
|
||||
{ "host": "slack.com", "path_prefix": "/api/", "methods": ["GET", "POST"] }
|
||||
],
|
||||
"credentials": {
|
||||
"slack_bot_token": {
|
||||
"secret_name": "slack_bot_token",
|
||||
"location": { "type": "bearer" },
|
||||
"host_patterns": ["slack.com"]
|
||||
}
|
||||
},
|
||||
"rate_limit": { "requests_per_minute": 50, "requests_per_hour": 1000 }
|
||||
},
|
||||
"secrets": {
|
||||
"allowed_names": ["slack_bot_token"]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Building Your Own Tool
|
||||
|
||||
Use this as a template for creating new WASM tools:
|
||||
|
||||
1. Copy this directory
|
||||
2. Update `Cargo.toml` with your tool name
|
||||
3. Modify `src/types.rs` with your action types
|
||||
4. Implement API calls in `src/api.rs`
|
||||
5. Update the action dispatch in `src/lib.rs`
|
||||
6. Create your `*.capabilities.json` file
|
||||
7. Build with `cargo component build --release`
|
||||
|
||||
### Key Files
|
||||
|
||||
- `Cargo.toml` - Rust package config with WASM target
|
||||
- `src/lib.rs` - WIT bindings and main dispatch
|
||||
- `src/types.rs` - Request/response types
|
||||
- `src/api.rs` - API implementation
|
||||
- `*.capabilities.json` - Security capabilities declaration
|
||||
|
||||
### WIT Interface
|
||||
|
||||
Tools implement the `sandboxed-tool` world from `wit/tool.wit`:
|
||||
|
||||
```wit
|
||||
world sandboxed-tool {
|
||||
import host; // log, http-request, secret-exists, etc.
|
||||
export tool; // execute, schema, description
|
||||
}
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### "Slack bot token not configured"
|
||||
|
||||
Ensure you've stored the secret:
|
||||
```bash
|
||||
ironclaw secret set slack_bot_token "xoxb-..."
|
||||
```
|
||||
|
||||
### "Endpoint not in allowlist"
|
||||
|
||||
Check that `slack.capabilities.json` includes the endpoint you're trying to access.
|
||||
|
||||
### "Rate limit exceeded"
|
||||
|
||||
The tool has a default rate limit of 50 requests/minute. Wait and retry.
|
||||
|
||||
### Build errors
|
||||
|
||||
Ensure you have the WASM target and cargo-component installed:
|
||||
```bash
|
||||
rustup target add wasm32-wasip2
|
||||
cargo install cargo-component
|
||||
```
|
||||
|
||||
## License
|
||||
|
||||
MIT OR Apache-2.0
|
||||
@@ -0,0 +1,50 @@
|
||||
{
|
||||
"http": {
|
||||
"allowlist": [
|
||||
{
|
||||
"host": "slack.com",
|
||||
"path_prefix": "/api/",
|
||||
"methods": ["GET", "POST"]
|
||||
}
|
||||
],
|
||||
"credentials": {
|
||||
"slack_bot_token": {
|
||||
"secret_name": "slack_bot_token",
|
||||
"location": { "type": "bearer" },
|
||||
"host_patterns": ["slack.com"]
|
||||
}
|
||||
},
|
||||
"rate_limit": {
|
||||
"requests_per_minute": 50,
|
||||
"requests_per_hour": 1000
|
||||
},
|
||||
"timeout_secs": 30
|
||||
},
|
||||
"secrets": {
|
||||
"allowed_names": ["slack_bot_token"]
|
||||
},
|
||||
"auth": {
|
||||
"secret_name": "slack_bot_token",
|
||||
"display_name": "Slack",
|
||||
"oauth": {
|
||||
"authorization_url": "https://slack.com/oauth/v2/authorize",
|
||||
"token_url": "https://slack.com/api/oauth.v2.access",
|
||||
"client_id_env": "SLACK_OAUTH_CLIENT_ID",
|
||||
"client_secret_env": "SLACK_OAUTH_CLIENT_SECRET",
|
||||
"scopes": [
|
||||
"chat:write",
|
||||
"channels:read",
|
||||
"channels:history",
|
||||
"groups:read",
|
||||
"groups:history",
|
||||
"reactions:write",
|
||||
"users:read"
|
||||
],
|
||||
"use_pkce": false
|
||||
},
|
||||
"instructions": "1. Create a Slack App at https://api.slack.com/apps\n2. Add Bot Token Scopes under OAuth & Permissions:\n chat:write, channels:read, channels:history, groups:read,\n groups:history, reactions:write, users:read\n3. Install the app to your workspace\n4. Copy the Bot User OAuth Token (starts with xoxb-)",
|
||||
"setup_url": "https://api.slack.com/apps",
|
||||
"token_hint": "Starts with 'xoxb-'",
|
||||
"env_var": "SLACK_BOT_TOKEN"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,227 @@
|
||||
//! Slack Web API implementation.
|
||||
//!
|
||||
//! All API calls go through the host's HTTP capability, which handles
|
||||
//! credential injection and rate limiting. The WASM tool never sees
|
||||
//! the actual bot token.
|
||||
|
||||
use crate::near::agent::host;
|
||||
use crate::types::*;
|
||||
|
||||
const SLACK_API_BASE: &str = "https://slack.com/api";
|
||||
|
||||
/// Percent-encode a string for use as a URL query parameter value.
|
||||
fn url_encode(s: &str) -> String {
|
||||
let mut out = String::with_capacity(s.len());
|
||||
for b in s.bytes() {
|
||||
match b {
|
||||
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
|
||||
out.push(b as char);
|
||||
}
|
||||
_ => {
|
||||
out.push('%');
|
||||
out.push(char::from(b"0123456789ABCDEF"[(b >> 4) as usize]));
|
||||
out.push(char::from(b"0123456789ABCDEF"[(b & 0xf) as usize]));
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Make a Slack API call.
|
||||
fn slack_api_call(method: &str, endpoint: &str, body: Option<&str>) -> Result<String, String> {
|
||||
let url = format!("{}/{}", SLACK_API_BASE, endpoint);
|
||||
|
||||
// Content-Type header for POST requests
|
||||
let headers = if body.is_some() {
|
||||
r#"{"Content-Type": "application/json; charset=utf-8"}"#
|
||||
} else {
|
||||
"{}"
|
||||
};
|
||||
|
||||
let body_bytes = body.map(|b| b.as_bytes().to_vec());
|
||||
|
||||
host::log(
|
||||
host::LogLevel::Debug,
|
||||
&format!("Slack API: {} {}", method, endpoint),
|
||||
);
|
||||
|
||||
let response = host::http_request(method, &url, headers, body_bytes.as_deref())?;
|
||||
|
||||
if response.status < 200 || response.status >= 300 {
|
||||
return Err(format!(
|
||||
"Slack API returned status {}: {}",
|
||||
response.status,
|
||||
String::from_utf8_lossy(&response.body)
|
||||
));
|
||||
}
|
||||
|
||||
String::from_utf8(response.body).map_err(|e| format!("Invalid UTF-8 in response: {}", e))
|
||||
}
|
||||
|
||||
/// Send a message to a Slack channel.
|
||||
pub fn send_message(
|
||||
channel: &str,
|
||||
text: &str,
|
||||
thread_ts: Option<&str>,
|
||||
) -> Result<SendMessageResult, String> {
|
||||
let mut payload = serde_json::json!({
|
||||
"channel": channel,
|
||||
"text": text,
|
||||
});
|
||||
|
||||
if let Some(ts) = thread_ts {
|
||||
payload["thread_ts"] = serde_json::Value::String(ts.to_string());
|
||||
}
|
||||
|
||||
let body = serde_json::to_string(&payload).map_err(|e| e.to_string())?;
|
||||
let response = slack_api_call("POST", "chat.postMessage", Some(&body))?;
|
||||
|
||||
let parsed: serde_json::Value =
|
||||
serde_json::from_str(&response).map_err(|e| format!("Failed to parse response: {}", e))?;
|
||||
|
||||
if !parsed["ok"].as_bool().unwrap_or(false) {
|
||||
let error = parsed["error"].as_str().unwrap_or("unknown_error");
|
||||
return Err(format!("Slack API error: {}", error));
|
||||
}
|
||||
|
||||
Ok(SendMessageResult {
|
||||
ok: true,
|
||||
channel: parsed["channel"].as_str().unwrap_or(channel).to_string(),
|
||||
ts: parsed["ts"].as_str().unwrap_or("").to_string(),
|
||||
message: parsed.get("message").map(|m| MessageInfo {
|
||||
text: m["text"].as_str().unwrap_or("").to_string(),
|
||||
user: m["user"].as_str().map(|s| s.to_string()),
|
||||
ts: m["ts"].as_str().unwrap_or("").to_string(),
|
||||
}),
|
||||
})
|
||||
}
|
||||
|
||||
/// List channels the bot has access to.
|
||||
pub fn list_channels(limit: u32) -> Result<ListChannelsResult, String> {
|
||||
let url = format!(
|
||||
"conversations.list?types=public_channel,private_channel&limit={}",
|
||||
limit
|
||||
);
|
||||
|
||||
let response = slack_api_call("GET", &url, None)?;
|
||||
|
||||
let parsed: serde_json::Value =
|
||||
serde_json::from_str(&response).map_err(|e| format!("Failed to parse response: {}", e))?;
|
||||
|
||||
if !parsed["ok"].as_bool().unwrap_or(false) {
|
||||
let error = parsed["error"].as_str().unwrap_or("unknown_error");
|
||||
return Err(format!("Slack API error: {}", error));
|
||||
}
|
||||
|
||||
let channels = parsed["channels"]
|
||||
.as_array()
|
||||
.map(|arr| {
|
||||
arr.iter()
|
||||
.map(|c| Channel {
|
||||
id: c["id"].as_str().unwrap_or("").to_string(),
|
||||
name: c["name"].as_str().unwrap_or("").to_string(),
|
||||
is_private: c["is_private"].as_bool().unwrap_or(false),
|
||||
is_member: c["is_member"].as_bool().unwrap_or(false),
|
||||
topic: c["topic"]["value"].as_str().map(|s| s.to_string()),
|
||||
purpose: c["purpose"]["value"].as_str().map(|s| s.to_string()),
|
||||
})
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
|
||||
Ok(ListChannelsResult { ok: true, channels })
|
||||
}
|
||||
|
||||
/// Get message history from a channel.
|
||||
pub fn get_channel_history(channel: &str, limit: u32) -> Result<ChannelHistoryResult, String> {
|
||||
let url = format!(
|
||||
"conversations.history?channel={}&limit={}",
|
||||
url_encode(channel),
|
||||
limit
|
||||
);
|
||||
|
||||
let response = slack_api_call("GET", &url, None)?;
|
||||
|
||||
let parsed: serde_json::Value =
|
||||
serde_json::from_str(&response).map_err(|e| format!("Failed to parse response: {}", e))?;
|
||||
|
||||
if !parsed["ok"].as_bool().unwrap_or(false) {
|
||||
let error = parsed["error"].as_str().unwrap_or("unknown_error");
|
||||
return Err(format!("Slack API error: {}", error));
|
||||
}
|
||||
|
||||
let messages = parsed["messages"]
|
||||
.as_array()
|
||||
.map(|arr| {
|
||||
arr.iter()
|
||||
.map(|m| HistoryMessage {
|
||||
ts: m["ts"].as_str().unwrap_or("").to_string(),
|
||||
text: m["text"].as_str().unwrap_or("").to_string(),
|
||||
user: m["user"].as_str().map(|s| s.to_string()),
|
||||
msg_type: m["type"].as_str().unwrap_or("message").to_string(),
|
||||
})
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
|
||||
Ok(ChannelHistoryResult { ok: true, messages })
|
||||
}
|
||||
|
||||
/// Add a reaction to a message.
|
||||
pub fn post_reaction(
|
||||
channel: &str,
|
||||
timestamp: &str,
|
||||
emoji: &str,
|
||||
) -> Result<PostReactionResult, String> {
|
||||
let payload = serde_json::json!({
|
||||
"channel": channel,
|
||||
"timestamp": timestamp,
|
||||
"name": emoji,
|
||||
});
|
||||
|
||||
let body = serde_json::to_string(&payload).map_err(|e| e.to_string())?;
|
||||
let response = slack_api_call("POST", "reactions.add", Some(&body))?;
|
||||
|
||||
let parsed: serde_json::Value =
|
||||
serde_json::from_str(&response).map_err(|e| format!("Failed to parse response: {}", e))?;
|
||||
|
||||
if !parsed["ok"].as_bool().unwrap_or(false) {
|
||||
let error = parsed["error"].as_str().unwrap_or("unknown_error");
|
||||
// "already_reacted" is not really an error
|
||||
if error != "already_reacted" {
|
||||
return Err(format!("Slack API error: {}", error));
|
||||
}
|
||||
}
|
||||
|
||||
Ok(PostReactionResult { ok: true })
|
||||
}
|
||||
|
||||
/// Get information about a user.
|
||||
pub fn get_user_info(user_id: &str) -> Result<GetUserInfoResult, String> {
|
||||
let url = format!("users.info?user={}", url_encode(user_id));
|
||||
|
||||
let response = slack_api_call("GET", &url, None)?;
|
||||
|
||||
let parsed: serde_json::Value =
|
||||
serde_json::from_str(&response).map_err(|e| format!("Failed to parse response: {}", e))?;
|
||||
|
||||
if !parsed["ok"].as_bool().unwrap_or(false) {
|
||||
let error = parsed["error"].as_str().unwrap_or("unknown_error");
|
||||
return Err(format!("Slack API error: {}", error));
|
||||
}
|
||||
|
||||
let user = &parsed["user"];
|
||||
let profile = &user["profile"];
|
||||
|
||||
Ok(GetUserInfoResult {
|
||||
ok: true,
|
||||
user: UserInfo {
|
||||
id: user["id"].as_str().unwrap_or("").to_string(),
|
||||
name: user["name"].as_str().unwrap_or("").to_string(),
|
||||
real_name: profile["real_name"].as_str().map(|s| s.to_string()),
|
||||
display_name: profile["display_name"].as_str().map(|s| s.to_string()),
|
||||
email: profile["email"].as_str().map(|s| s.to_string()),
|
||||
is_bot: user["is_bot"].as_bool().unwrap_or(false),
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,205 @@
|
||||
//! Slack WASM Tool for IronClaw.
|
||||
//!
|
||||
//! This is a standalone WASM component that provides Slack integration.
|
||||
//! It demonstrates how to build external tools that can be dynamically
|
||||
//! loaded by the agent runtime.
|
||||
//!
|
||||
//! # Capabilities Required
|
||||
//!
|
||||
//! - HTTP: `slack.com/api/*` (GET, POST)
|
||||
//! - Secrets: `slack_bot_token` (injected automatically)
|
||||
//!
|
||||
//! # Supported Actions
|
||||
//!
|
||||
//! - `send_message`: Send a message to a channel
|
||||
//! - `list_channels`: List channels the bot has access to
|
||||
//! - `get_channel_history`: Get recent messages from a channel
|
||||
//! - `post_reaction`: Add an emoji reaction to a message
|
||||
//! - `get_user_info`: Get information about a Slack user
|
||||
//!
|
||||
//! # Example Usage
|
||||
//!
|
||||
//! ```json
|
||||
//! {"action": "send_message", "channel": "#general", "text": "Hello from the agent!"}
|
||||
//! ```
|
||||
|
||||
mod api;
|
||||
mod types;
|
||||
|
||||
use types::SlackAction;
|
||||
|
||||
// Generate bindings from the WIT interface.
|
||||
// This creates the `bindings` module with types and traits.
|
||||
wit_bindgen::generate!({
|
||||
world: "sandboxed-tool",
|
||||
path: "../../wit/tool.wit",
|
||||
});
|
||||
|
||||
/// Implementation of the tool interface.
|
||||
struct SlackTool;
|
||||
|
||||
impl exports::near::agent::tool::Guest for SlackTool {
|
||||
fn execute(req: exports::near::agent::tool::Request) -> exports::near::agent::tool::Response {
|
||||
match execute_inner(&req.params) {
|
||||
Ok(result) => exports::near::agent::tool::Response {
|
||||
output: Some(result),
|
||||
error: None,
|
||||
},
|
||||
Err(e) => exports::near::agent::tool::Response {
|
||||
output: None,
|
||||
error: Some(e),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn schema() -> String {
|
||||
// JSON Schema for the tool's parameters
|
||||
r#"{
|
||||
"type": "object",
|
||||
"required": ["action"],
|
||||
"oneOf": [
|
||||
{
|
||||
"properties": {
|
||||
"action": { "const": "send_message" },
|
||||
"channel": {
|
||||
"type": "string",
|
||||
"description": "Channel ID or name (e.g., '#general' or 'C1234567890')"
|
||||
},
|
||||
"text": {
|
||||
"type": "string",
|
||||
"description": "Message text (supports Slack mrkdwn formatting)"
|
||||
},
|
||||
"thread_ts": {
|
||||
"type": "string",
|
||||
"description": "Optional thread timestamp to reply in a thread"
|
||||
}
|
||||
},
|
||||
"required": ["action", "channel", "text"]
|
||||
},
|
||||
{
|
||||
"properties": {
|
||||
"action": { "const": "list_channels" },
|
||||
"limit": {
|
||||
"type": "integer",
|
||||
"description": "Maximum number of channels to return (default: 100)",
|
||||
"default": 100
|
||||
}
|
||||
},
|
||||
"required": ["action"]
|
||||
},
|
||||
{
|
||||
"properties": {
|
||||
"action": { "const": "get_channel_history" },
|
||||
"channel": {
|
||||
"type": "string",
|
||||
"description": "Channel ID (e.g., 'C1234567890')"
|
||||
},
|
||||
"limit": {
|
||||
"type": "integer",
|
||||
"description": "Maximum number of messages to return (default: 20)",
|
||||
"default": 20
|
||||
}
|
||||
},
|
||||
"required": ["action", "channel"]
|
||||
},
|
||||
{
|
||||
"properties": {
|
||||
"action": { "const": "post_reaction" },
|
||||
"channel": {
|
||||
"type": "string",
|
||||
"description": "Channel ID containing the message"
|
||||
},
|
||||
"timestamp": {
|
||||
"type": "string",
|
||||
"description": "Timestamp of the message to react to"
|
||||
},
|
||||
"emoji": {
|
||||
"type": "string",
|
||||
"description": "Emoji name without colons (e.g., 'thumbsup')"
|
||||
}
|
||||
},
|
||||
"required": ["action", "channel", "timestamp", "emoji"]
|
||||
},
|
||||
{
|
||||
"properties": {
|
||||
"action": { "const": "get_user_info" },
|
||||
"user_id": {
|
||||
"type": "string",
|
||||
"description": "User ID (e.g., 'U1234567890')"
|
||||
}
|
||||
},
|
||||
"required": ["action", "user_id"]
|
||||
}
|
||||
]
|
||||
}"#
|
||||
.to_string()
|
||||
}
|
||||
|
||||
fn description() -> String {
|
||||
"Slack integration tool for sending messages, listing channels, reading history, \
|
||||
adding reactions, and getting user information. Requires a Slack bot token with \
|
||||
appropriate scopes (chat:write, channels:read, channels:history, reactions:write, \
|
||||
users:read)."
|
||||
.to_string()
|
||||
}
|
||||
}
|
||||
|
||||
/// Inner execution logic with proper error handling.
|
||||
fn execute_inner(params: &str) -> Result<String, String> {
|
||||
// Check if the Slack token is configured
|
||||
if !crate::near::agent::host::secret_exists("slack_bot_token") {
|
||||
return Err(
|
||||
"Slack bot token not configured. Please add the 'slack_bot_token' secret.".to_string(),
|
||||
);
|
||||
}
|
||||
|
||||
// Parse the action from JSON
|
||||
let action: SlackAction =
|
||||
serde_json::from_str(params).map_err(|e| format!("Invalid parameters: {}", e))?;
|
||||
|
||||
crate::near::agent::host::log(
|
||||
crate::near::agent::host::LogLevel::Info,
|
||||
&format!("Executing Slack action: {:?}", action),
|
||||
);
|
||||
|
||||
// Dispatch to the appropriate handler
|
||||
let result = match action {
|
||||
SlackAction::SendMessage {
|
||||
channel,
|
||||
text,
|
||||
thread_ts,
|
||||
} => {
|
||||
let result = api::send_message(&channel, &text, thread_ts.as_deref())?;
|
||||
serde_json::to_string(&result).map_err(|e| e.to_string())?
|
||||
}
|
||||
|
||||
SlackAction::ListChannels { limit } => {
|
||||
let result = api::list_channels(limit)?;
|
||||
serde_json::to_string(&result).map_err(|e| e.to_string())?
|
||||
}
|
||||
|
||||
SlackAction::GetChannelHistory { channel, limit } => {
|
||||
let result = api::get_channel_history(&channel, limit)?;
|
||||
serde_json::to_string(&result).map_err(|e| e.to_string())?
|
||||
}
|
||||
|
||||
SlackAction::PostReaction {
|
||||
channel,
|
||||
timestamp,
|
||||
emoji,
|
||||
} => {
|
||||
let result = api::post_reaction(&channel, ×tamp, &emoji)?;
|
||||
serde_json::to_string(&result).map_err(|e| e.to_string())?
|
||||
}
|
||||
|
||||
SlackAction::GetUserInfo { user_id } => {
|
||||
let result = api::get_user_info(&user_id)?;
|
||||
serde_json::to_string(&result).map_err(|e| e.to_string())?
|
||||
}
|
||||
};
|
||||
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
// Export the tool implementation.
|
||||
export!(SlackTool);
|
||||
@@ -0,0 +1,140 @@
|
||||
//! Types for Slack API requests and responses.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Input parameters for the Slack tool.
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(tag = "action", rename_all = "snake_case")]
|
||||
pub enum SlackAction {
|
||||
/// Send a message to a channel.
|
||||
SendMessage {
|
||||
/// Channel ID or name (e.g., "#general" or "C1234567890").
|
||||
channel: String,
|
||||
/// Message text (supports Slack mrkdwn formatting).
|
||||
text: String,
|
||||
/// Optional thread timestamp to reply in a thread.
|
||||
#[serde(default)]
|
||||
thread_ts: Option<String>,
|
||||
},
|
||||
|
||||
/// List channels the bot has access to.
|
||||
ListChannels {
|
||||
/// Maximum number of channels to return (default: 100).
|
||||
#[serde(default = "default_limit")]
|
||||
limit: u32,
|
||||
},
|
||||
|
||||
/// Get message history from a channel.
|
||||
GetChannelHistory {
|
||||
/// Channel ID (e.g., "C1234567890").
|
||||
channel: String,
|
||||
/// Maximum number of messages to return (default: 20).
|
||||
#[serde(default = "default_history_limit")]
|
||||
limit: u32,
|
||||
},
|
||||
|
||||
/// Add a reaction (emoji) to a message.
|
||||
PostReaction {
|
||||
/// Channel ID containing the message.
|
||||
channel: String,
|
||||
/// Timestamp of the message to react to.
|
||||
timestamp: String,
|
||||
/// Emoji name without colons (e.g., "thumbsup").
|
||||
emoji: String,
|
||||
},
|
||||
|
||||
/// Get information about a user.
|
||||
GetUserInfo {
|
||||
/// User ID (e.g., "U1234567890").
|
||||
user_id: String,
|
||||
},
|
||||
}
|
||||
|
||||
fn default_limit() -> u32 {
|
||||
100
|
||||
}
|
||||
|
||||
fn default_history_limit() -> u32 {
|
||||
20
|
||||
}
|
||||
|
||||
/// Result from send_message.
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct SendMessageResult {
|
||||
pub ok: bool,
|
||||
pub channel: String,
|
||||
pub ts: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub message: Option<MessageInfo>,
|
||||
}
|
||||
|
||||
/// Basic message info.
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct MessageInfo {
|
||||
pub text: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub user: Option<String>,
|
||||
pub ts: String,
|
||||
}
|
||||
|
||||
/// A Slack channel.
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct Channel {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub is_private: bool,
|
||||
pub is_member: bool,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub topic: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub purpose: Option<String>,
|
||||
}
|
||||
|
||||
/// Result from list_channels.
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct ListChannelsResult {
|
||||
pub ok: bool,
|
||||
pub channels: Vec<Channel>,
|
||||
}
|
||||
|
||||
/// Result from get_channel_history.
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct ChannelHistoryResult {
|
||||
pub ok: bool,
|
||||
pub messages: Vec<HistoryMessage>,
|
||||
}
|
||||
|
||||
/// A message from channel history.
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct HistoryMessage {
|
||||
pub ts: String,
|
||||
pub text: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub user: Option<String>,
|
||||
#[serde(rename = "type")]
|
||||
pub msg_type: String,
|
||||
}
|
||||
|
||||
/// Result from post_reaction.
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct PostReactionResult {
|
||||
pub ok: bool,
|
||||
}
|
||||
|
||||
/// User information.
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct UserInfo {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub real_name: Option<String>,
|
||||
pub display_name: Option<String>,
|
||||
pub email: Option<String>,
|
||||
pub is_bot: bool,
|
||||
}
|
||||
|
||||
/// Result from get_user_info.
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct GetUserInfoResult {
|
||||
pub ok: bool,
|
||||
pub user: UserInfo,
|
||||
}
|
||||
Reference in New Issue
Block a user