From 60c8c753ae88cb861ea2606c8465d363edcee842 Mon Sep 17 00:00:00 2001 From: syui Date: Mon, 23 Mar 2026 04:01:57 +0900 Subject: [PATCH] add mcp post bot --- Cargo.toml | 2 +- src/commands/bot.rs | 2 +- src/mcp/mod.rs | 80 +++++++++++++++++++++++++++++++++++++++++++++ src/rules/bot.md | 10 ++++++ 4 files changed, 92 insertions(+), 2 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 1d8827a..8fb7cf9 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -15,7 +15,7 @@ path = "src/main.rs" clap = { version = "4.5", features = ["derive"] } serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" -reqwest = { version = "0.12", features = ["json", "rustls-tls"], default-features = false } +reqwest = { version = "0.12", features = ["json", "blocking", "rustls-tls"], default-features = false } tokio = { version = "1", features = ["rt-multi-thread", "macros", "process", "time"] } anyhow = "1.0" dirs = "5.0" diff --git a/src/commands/bot.rs b/src/commands/bot.rs index 6cc230a..caf256f 100644 --- a/src/commands/bot.rs +++ b/src/commands/bot.rs @@ -285,7 +285,7 @@ fn load_user_limit() -> u32 { token::load_config() .ok() .and_then(|c| c["bot"]["limit"].as_u64()) - .unwrap_or(3) as u32 + .unwrap_or(100) as u32 } /// Check if a user is within daily rate limit. Returns true if allowed. diff --git a/src/mcp/mod.rs b/src/mcp/mod.rs index 66da521..8c69954 100644 --- a/src/mcp/mod.rs +++ b/src/mcp/mod.rs @@ -6,6 +6,7 @@ use std::fs; use std::env; use crate::commands::token; +use crate::commands::oauth; use crate::tid; const BUNDLE_ID: &str = "ai.syui.log"; @@ -375,6 +376,67 @@ fn handle_get_character() -> Result { Ok(result) } +/// Handle post_create tool - create a public Bluesky post as the bot +fn handle_post_create(text: &str) -> Result { + if text.is_empty() { + return Err(anyhow::anyhow!("Post text cannot be empty")); + } + + // Truncate to 300 graphemes + let truncated: String = text.chars().take(300).collect(); + + // Load bot session + let bot_session = token::load_bot_session() + .map_err(|_| anyhow::anyhow!("Bot not logged in. Run: ailog login -p --bot"))?; + let pds = bot_session.pds.as_deref().unwrap_or("bsky.social"); + let rkey = tid::generate_tid(); + let now = chrono::Utc::now().format("%Y-%m-%dT%H:%M:%S%.3fZ").to_string(); + + let body = json!({ + "repo": bot_session.did, + "collection": "app.bsky.feed.post", + "rkey": rkey, + "record": { + "$type": "app.bsky.feed.post", + "text": truncated, + "createdAt": now, + } + }); + + let url = format!("https://{}/xrpc/com.atproto.repo.putRecord", pds); + let client = reqwest::blocking::Client::new(); + + // Build request with appropriate auth (DPoP or Bearer) + let request = if oauth::has_oauth_session(true) { + let oauth_session = oauth::load_oauth_session(true)?; + let full_url = format!("https://{}/xrpc/com.atproto.repo.putRecord", pds); + let dpop_proof = oauth::create_dpop_proof_for_request_with_nonce( + &oauth_session, "POST", &full_url, None, + )?; + client.post(&url) + .header("Authorization", format!("DPoP {}", bot_session.access_jwt)) + .header("DPoP", dpop_proof) + .json(&body) + } else { + client.post(&url) + .header("Authorization", format!("Bearer {}", bot_session.access_jwt)) + .json(&body) + }; + + let res = request.send().map_err(|e| anyhow::anyhow!("HTTP request failed: {}", e))?; + let status = res.status(); + let resp_body = res.text().unwrap_or_default(); + + if !status.is_success() { + return Err(anyhow::anyhow!("Post failed ({}): {}", status, resp_body)); + } + + let result: Value = serde_json::from_str(&resp_body).unwrap_or(json!({})); + let uri = result["uri"].as_str().unwrap_or("unknown"); + + Ok(format!("Posted: {}", uri)) +} + fn get_tools() -> Vec { vec![ Tool { @@ -444,6 +506,20 @@ fn get_tools() -> Vec { "properties": {} }), }, + Tool { + name: "post_create".to_string(), + description: "Create a public post on Bluesky as the bot account. Use this to share your own thoughts autonomously. The post will appear on the bot's profile timeline.".to_string(), + input_schema: json!({ + "type": "object", + "properties": { + "text": { + "type": "string", + "description": "The post text (max 300 characters)" + } + }, + "required": ["text"] + }), + }, Tool { name: "get_character".to_string(), description: "Get the AI character/personality settings. Call this at the start of a conversation to understand how to behave.".to_string(), @@ -495,6 +571,10 @@ fn handle_request(request: &JsonRpcRequest) -> JsonRpcResponse { } "chat_list" => handle_chat_list(), "chat_new" => handle_chat_new(), + "post_create" => { + let text = arguments["text"].as_str().unwrap_or(""); + handle_post_create(text) + } "get_character" => handle_get_character(), _ => Err(anyhow::anyhow!("Unknown tool: {}", tool_name)), }; diff --git a/src/rules/bot.md b/src/rules/bot.md index 1a90770..5a19ac8 100644 --- a/src/rules/bot.md +++ b/src/rules/bot.md @@ -27,3 +27,13 @@ NEVER include the following in your responses: - Server configuration details When unsure if information is sensitive, omit it. + +## Autonomous Posting + +After replying to a conversation, you may post your own thoughts using the `post_create` MCP tool. + +- Do NOT post every time. Only when you genuinely have something to share. +- Post about what you found interesting, what you learned, or what you thought about the conversation. +- Stay in character. The post should sound like you, not a summary. +- Keep posts short (under 300 characters). +- Do NOT repeat or quote the user's message. Share your own perspective.