├── .gitignore ├── Cargo.toml ├── src ├── routing.rs ├── conf.rs ├── main.rs ├── app_conf.rs ├── commands.rs ├── braid.rs ├── handler.rs ├── tracking.rs ├── message.rs └── github.rs └── README.md /.gitignore: -------------------------------------------------------------------------------- 1 | target 2 | conf.toml 3 | threads_issues.sqlite 4 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "octocat" 3 | version = "0.1.0" 4 | authors = ["James N. V. Cash "] 5 | 6 | [dependencies] 7 | iron = "^0.4" 8 | serde = "^0.7" 9 | serde_macros = "^0.7" 10 | serde_json = "^0.7" 11 | rmp = "^0.7" 12 | rmp-serde = "^0.9" 13 | uuid = { version = "0.2", features = ["v4"] } 14 | byteorder = "0.5" 15 | toml = "0.1" 16 | hyper = "^0.9" 17 | regex = "0.1" 18 | lazy_static = "0.2.1" 19 | mime = "^0.2" 20 | openssl = "0.7.14" 21 | rustc-serialize = "0.3.19" 22 | rusqlite = "0.7.3" 23 | 24 | [profile.release] 25 | lto = true 26 | -------------------------------------------------------------------------------- /src/routing.rs: -------------------------------------------------------------------------------- 1 | use std::error::Error; 2 | use std::fmt; 3 | 4 | // Route error handler 5 | #[derive(Debug)] 6 | pub struct NoRoute; 7 | 8 | impl fmt::Display for NoRoute { 9 | fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { 10 | f.write_str("No matching route found.") 11 | } 12 | } 13 | 14 | impl Error for NoRoute { 15 | fn description(&self) -> &str { "No Route" } 16 | } 17 | 18 | #[derive(Debug)] 19 | pub struct MissingMac; 20 | 21 | impl fmt::Display for MissingMac { 22 | fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { 23 | f.write_str("Missing X-Braid-Signature header") 24 | } 25 | } 26 | 27 | impl Error for MissingMac { 28 | fn description(&self) -> &str { "Missing signature header" } 29 | } 30 | 31 | #[derive(Debug)] 32 | pub struct BadMac; 33 | 34 | impl fmt::Display for BadMac { 35 | fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { 36 | f.write_str("Mac check failed") 37 | } 38 | } 39 | 40 | impl Error for BadMac { 41 | fn description(&self) -> &str { "Bad signature header" } 42 | } 43 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Octocat # 2 | 3 | A Braid bot for interacting with Github issues 4 | 5 | Building with rustc 1.12.0-nightly (27e766d7b 2016-07-19) 6 | 7 | To set up: 8 | 9 | - Generate an access token with `repo` scope at [https://github.com/settings/tokens](https://github.com/settings/tokens) 10 | - Add a webhook on Github from the relevant repository (from repo Settings), with the triggered events "Issues" and "Issue Comment" 11 | - Add the bot on Braid, with the path of webhook url being `/message` 12 | 13 | 14 | Example conf.toml: 15 | 16 | ``` 17 | [general] 18 | port = "7777" 19 | db_name = "octocat_db.sqlite" 20 | 21 | [braid] 22 | name = "octocat" 23 | api_url = "https://api.braid.chat" 24 | site_url = "https://braid.chat" 25 | app_id = "app id from braid" 26 | token = "app token from braid" 27 | 28 | [github] 29 | webhook_secret = "random secret you put in the github webhook conf" 30 | 31 | [[repos]] 32 | token = "token created from github" 33 | org = "jamesnvc" 34 | repo = "dotfiles" 35 | tag_id = "some braid tag id" 36 | 37 | [[repos]] 38 | token = "token created from github" 39 | org = "jamesnvc" 40 | repo = "emacs.d" 41 | tag_id = "some braid tag id" 42 | ``` 43 | 44 | ## Octocat in Action 45 | 46 | ![Bot running demo](https://s3.amazonaws.com/chat.leanpixel.com/uploads/579c1378-7d27-4454-8864-738df842d6fa/demo2.gif) 47 | -------------------------------------------------------------------------------- /src/conf.rs: -------------------------------------------------------------------------------- 1 | use toml; 2 | use std::collections::BTreeMap; 3 | use std::io::Read; 4 | use std::fs::File; 5 | 6 | fn slurp(file_name: &str) -> Result { 7 | let mut s = String::new(); 8 | match File::open(file_name).and_then(|mut f| { f.read_to_string(&mut s) }) { 9 | Ok(_) => Ok(s), 10 | Err(_) => Err("Couldn't open file to read".to_owned()) 11 | } 12 | } 13 | 14 | pub type TomlConf = BTreeMap; 15 | 16 | pub fn load_conf(file_name: &str) -> Result { 17 | let contents = try!(slurp(file_name).map_err(|e| e.to_string())); 18 | toml::Parser::new(&contents).parse().ok_or("Couldn't parse TOML".to_owned()) 19 | } 20 | 21 | pub fn get_conf_val(conf: &TomlConf, group: &str, key: &str) -> Option { 22 | conf.get(group) 23 | .and_then(|v| v.as_table()) 24 | .and_then(|tbl| tbl.get(key)) 25 | .and_then(|key_v| key_v.as_str()) 26 | .map(|s| s.to_owned()) 27 | } 28 | 29 | pub fn get_conf_val_n(conf: &TomlConf, group: &str, key: &str) -> Option { 30 | conf.get(group) 31 | .and_then(|v| v.as_table()) 32 | .and_then(|tbl| tbl.get(key)) 33 | .and_then(|key_v| key_v.as_integer()) 34 | } 35 | 36 | pub fn get_conf_group(conf: &TomlConf, group: &str) -> Option { 37 | conf.get(group).and_then(|v| v.as_table()).cloned() 38 | } 39 | 40 | pub fn validate_conf_group(conf: &TomlConf, group: &str, keys: &[&str]) { 41 | let grp = get_conf_group(conf, group) 42 | .expect(&format!("Mssing configuration for {}", group)[..]); 43 | for k in keys { 44 | if !grp.contains_key(*k) { 45 | panic!("Missing {} configuration key '{}'", group, k); 46 | } 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /src/main.rs: -------------------------------------------------------------------------------- 1 | #![feature(custom_derive, plugin)] 2 | #![plugin(serde_macros)] 3 | 4 | // main 5 | #[macro_use] extern crate iron; 6 | extern crate regex; 7 | #[macro_use] extern crate lazy_static; 8 | extern crate openssl; 9 | extern crate rustc_serialize; 10 | // Message parsing 11 | extern crate rmp; 12 | extern crate rmp_serde; 13 | extern crate serde; 14 | extern crate uuid; 15 | extern crate byteorder; 16 | // braid requests 17 | extern crate hyper; 18 | extern crate mime; 19 | extern crate serde_json; 20 | // configuration 21 | extern crate toml; 22 | // tracking braid thread <-> github issues 23 | extern crate rusqlite; 24 | 25 | use std::env; 26 | use std::process; 27 | 28 | use iron::{Iron,Request,IronError}; 29 | use iron::{method,status}; 30 | 31 | mod app_conf; 32 | mod conf; 33 | mod routing; 34 | mod message; 35 | mod github; 36 | mod braid; 37 | mod handler; 38 | mod commands; 39 | mod tracking; 40 | 41 | 42 | fn main() { 43 | let args: Vec<_> = env::args().collect(); 44 | if args.len() <= 1 { 45 | println!("Usage: {} ", args[0]); 46 | process::exit(1); 47 | } 48 | // Load configuration 49 | let conf_filename = &args[1]; 50 | let conf = app_conf::load_conf(&conf_filename[..]); 51 | tracking::setup_tables(&conf); 52 | // Start server 53 | let bind_addr = format!("localhost:{}", conf.general.port); 54 | println!("Bot {:?} starting", conf.braid.name); 55 | Iron::new(move |request : &mut Request| { 56 | let req_path = request.url.path().join("/"); 57 | match request.method { 58 | method::Put => { 59 | if req_path == "message" { 60 | handler::handle_braid_message(request, conf.clone()) 61 | } else { 62 | Err(IronError::new(routing::NoRoute, status::NotFound)) 63 | } 64 | } 65 | method::Post => { 66 | if req_path == "issue" { 67 | handler::handle_github_webhook(request, conf.clone()) 68 | } else { 69 | Err(IronError::new(routing::NoRoute, status::NotFound)) 70 | } 71 | } 72 | _ => Err(IronError::new(routing::NoRoute, status::NotFound)) 73 | } 74 | }).http(&bind_addr[..]).unwrap(); 75 | } 76 | -------------------------------------------------------------------------------- /src/app_conf.rs: -------------------------------------------------------------------------------- 1 | use uuid::Uuid; 2 | 3 | use conf; 4 | 5 | #[derive(Clone)] 6 | pub struct GeneralConf { 7 | pub port: i64, 8 | pub db_name: String, 9 | } 10 | 11 | #[derive(Clone)] 12 | pub struct BraidConf { 13 | pub name: String, 14 | pub api_url: String, 15 | pub site_url: String, 16 | pub app_id: String, 17 | pub token: String, 18 | } 19 | 20 | #[derive(Clone)] 21 | pub struct GithubConf { 22 | pub webhook_secret: String, 23 | } 24 | 25 | #[derive(Clone)] 26 | pub struct RepoConf { 27 | pub token: String, 28 | pub org: String, 29 | pub repo: String, 30 | pub tag_id: Uuid, 31 | } 32 | 33 | #[derive(Clone)] 34 | pub struct AppConf { 35 | pub general: GeneralConf, 36 | pub braid: BraidConf, 37 | pub github: GithubConf, 38 | pub repos: Vec, 39 | } 40 | 41 | pub fn load_conf(conf_filename: &str) -> AppConf { 42 | let conf = conf::load_conf(conf_filename) 43 | .expect("Couldn't load conf file!"); 44 | conf::validate_conf_group(&conf, "general", &["port", "db_name"]); 45 | conf::validate_conf_group(&conf, "braid", 46 | &["name", "api_url", "app_id", "token", 47 | "site_url"]); 48 | conf::validate_conf_group(&conf, "github", &["webhook_secret"]); 49 | // Can unwrap below, since we've validated keys up here 50 | let general = GeneralConf { 51 | port: conf::get_conf_val_n(&conf, "general", "port").unwrap(), 52 | db_name: conf::get_conf_val(&conf, "general", "db_name").unwrap(), 53 | }; 54 | let braid = BraidConf { 55 | name: conf::get_conf_val(&conf, "braid", "name") 56 | .unwrap().to_owned(), 57 | api_url: conf::get_conf_val(&conf, "braid", "api_url") 58 | .unwrap().to_owned(), 59 | site_url: conf::get_conf_val(&conf, "braid", "site_url") 60 | .unwrap().to_owned(), 61 | app_id: conf::get_conf_val(&conf, "braid", "app_id") 62 | .unwrap().to_owned(), 63 | token: conf::get_conf_val(&conf, "braid", "token") 64 | .unwrap().to_owned(), 65 | }; 66 | let github = GithubConf { 67 | webhook_secret: conf::get_conf_val(&conf, "github", "webhook_secret") 68 | .unwrap().to_owned(), 69 | }; 70 | let mut repos = vec![]; 71 | for r in conf.get("repos").and_then(|r| r.as_slice()) 72 | .expect("Missing conf for repos!") { 73 | let t = r.as_table() 74 | .expect("repos should be a list of tables"); 75 | let rc = RepoConf { 76 | token: t.get("token").and_then(|t| t.as_str()) 77 | .expect("Repo missing token").to_owned(), 78 | org: t.get("org").and_then(|t| t.as_str()) 79 | .expect("Repo missing org").to_owned(), 80 | repo: t.get("repo").and_then(|t| t.as_str()) 81 | .expect("Repo missing repo name").to_owned(), 82 | tag_id: t.get("tag_id") 83 | .and_then(|t| t.as_str()) 84 | .and_then(|id| Uuid::parse_str(id).ok()) 85 | .expect("Repo missing braid tag_id").to_owned(), 86 | 87 | }; 88 | repos.push(rc); 89 | } 90 | AppConf { 91 | general: general, 92 | braid: braid, 93 | github: github, 94 | repos: repos, 95 | } 96 | } 97 | -------------------------------------------------------------------------------- /src/commands.rs: -------------------------------------------------------------------------------- 1 | use regex::Regex; 2 | 3 | use app_conf::{AppConf}; 4 | use message; 5 | use braid; 6 | use github; 7 | 8 | fn strip_leading_name(msg: &str) -> String { 9 | lazy_static! { 10 | static ref RE: Regex = Regex::new(r"^/(\w+)\b").unwrap(); 11 | } 12 | RE.replace(msg, "") 13 | } 14 | 15 | pub fn parse_command(msg: message::Message, conf: AppConf) { 16 | let body = strip_leading_name(&msg.content[..]); 17 | if let Some(command) = body.split_whitespace().next() { 18 | match &command[..] { 19 | "list" => send_repos_list(msg, conf), 20 | "create" => create_github_issue(msg, conf), 21 | "help" | _ => send_help_response(msg, conf), 22 | } 23 | } 24 | } 25 | 26 | fn send_help_response(msg: message::Message, conf: AppConf) { 27 | let bot_name = conf.braid.name.clone(); 28 | let mut help = String::new(); 29 | help.push_str("I know the following commands:\n"); 30 | help.push_str( 31 | format!("'/{} help' will make me respond with this message\n", 32 | bot_name).as_str()); 33 | help.push_str( 34 | format!("'/{} list' will get you the connected github repos\n", 35 | bot_name).as_str()); 36 | help.push_str( 37 | format!("'/{} create ' and I'll create an issue", 38 | bot_name).as_str()); 39 | help.push_str("in with the title 'text...'\n"); 40 | 41 | braid::send_braid_request(message::response_to(msg, help), &conf.braid); 42 | 43 | } 44 | 45 | fn send_repos_list(msg: message::Message, conf: AppConf) { 46 | let mut reply = String::from("I know about the following repos\n"); 47 | for r in conf.repos { 48 | reply.push_str(&r.org[..]); 49 | reply.push_str("/"); 50 | reply.push_str(&r.repo[..]); 51 | reply.push_str("\n"); 52 | } 53 | let msg = message::response_to(msg, reply); 54 | braid::send_braid_request(msg, &conf.braid); 55 | } 56 | 57 | fn create_github_issue(msg: message::Message, conf: AppConf) { 58 | let braid_conf = conf.braid.clone(); 59 | 60 | let body = strip_leading_name(&msg.content[..]); 61 | let mut words = body.split_whitespace(); 62 | let repo_conf = words.nth(1) 63 | .and_then(|s| github::find_repo_conf(s, &conf)); 64 | let issue_title = words.collect::>().join(" "); 65 | if let Some(repo_conf) = repo_conf { 66 | let sender = braid::get_user_nick(msg.user_id, &braid_conf) 67 | .unwrap_or("a braid user".to_owned()); 68 | let content = format!( 69 | "Created by octocat bot on behalf of {} from [braid chat]({})", 70 | sender, 71 | braid::thread_url(&braid_conf, &msg)); 72 | let gh_resp = github::create_issue(repo_conf, issue_title, content); 73 | if let Some(gh_issue) = gh_resp { 74 | // Opened webhook from github will open thread on braid 75 | println!("Issue opened: {:?}", gh_issue); 76 | } else { 77 | println!("Couldn't create issue"); 78 | let err_resp = "Couldn't create issue, sorry".to_owned(); 79 | braid::send_braid_request(message::response_to(msg, err_resp), 80 | &braid_conf); 81 | } 82 | } else { 83 | println!("Couldn't parse repo name"); 84 | let err_resp = "Don't know which repo you mean, sorry".to_owned(); 85 | braid::send_braid_request(message::response_to(msg, err_resp), 86 | &braid_conf); 87 | } 88 | } 89 | -------------------------------------------------------------------------------- /src/braid.rs: -------------------------------------------------------------------------------- 1 | use std::io::Read; 2 | use std::error::Error; 3 | use hyper::header::{Headers,ContentType,Authorization,Basic}; 4 | use hyper::client::Client; 5 | use hyper::status::StatusCode; 6 | use mime::{Mime,TopLevel,SubLevel}; 7 | use uuid::Uuid; 8 | 9 | use app_conf::BraidConf; 10 | use message; 11 | 12 | pub fn send_braid_request(message: message::Message, braid_conf: &BraidConf) 13 | { 14 | let api_url = format!("{}/bots/message", braid_conf.api_url); 15 | let body = message::encode_transit_msgpack(message) 16 | .expect("Couldn't encode body to send!"); 17 | let client = Client::new(); 18 | let mut headers = Headers::new(); 19 | headers.set(ContentType(Mime(TopLevel::Application, 20 | SubLevel::Ext("transit+msgpack".to_owned()), 21 | vec![]))); 22 | headers.set(Authorization(Basic{ 23 | username: braid_conf.app_id.clone(), 24 | password: Some(braid_conf.token.clone())})); 25 | match client.put(&api_url[..]).body(&body[..]).headers(headers).send() { 26 | Ok(r) => { 27 | println!("Sent message to braid"); 28 | if r.status == StatusCode::Created { 29 | println!("Message created!"); 30 | } else { 31 | println!("Something went wrong: {:?}", r); 32 | } 33 | } 34 | Err(e) => 35 | println!("Failed to send to braid: {:?}", e.description()), 36 | 37 | } 38 | } 39 | 40 | pub fn get_user_nick(user_id: Uuid, braid_conf: &BraidConf) -> Option { 41 | let api_url = format!("{}/bots/names/{}", braid_conf.api_url, 42 | user_id.hyphenated().to_string()); 43 | let mut headers = Headers::new(); 44 | headers.set(Authorization(Basic { 45 | username: braid_conf.app_id.clone(), 46 | password: Some(braid_conf.token.clone())})); 47 | let client = Client::new(); 48 | match client.get(&api_url[..]).headers(headers).send() { 49 | Ok(mut r) => { 50 | if r.status == StatusCode::Ok { 51 | let mut buf = String::new(); 52 | r.read_to_string(&mut buf).ok().and(Some(buf)) 53 | } else { 54 | println!("Something went wrong: {:?}", r); 55 | None 56 | } 57 | } 58 | Err(e) => { 59 | println!("Failed to get from braid: {:?}", e.description()); 60 | None 61 | } 62 | 63 | } 64 | } 65 | 66 | pub fn thread_url(braid_conf: &BraidConf, msg: &message::Message) -> String { 67 | format!("{}/{}/thread/{}", braid_conf.site_url, msg.group_id, msg.thread_id) 68 | } 69 | 70 | pub fn start_watching_thread(thread_id: Uuid, braid_conf: &BraidConf) { 71 | let api_url = format!("{}/bots/subscribe/{}", braid_conf.api_url, 72 | thread_id.hyphenated().to_string()); 73 | let mut headers = Headers::new(); 74 | headers.set(Authorization(Basic { 75 | username: braid_conf.app_id.clone(), 76 | password: Some(braid_conf.token.clone())})); 77 | let client = Client::new(); 78 | match client.put(&api_url[..]).headers(headers).send() { 79 | Ok(r) => { 80 | println!("Sent message to braid"); 81 | if r.status == StatusCode::Created { 82 | println!("Getting notifications from braid for thread"); 83 | } else { 84 | println!("Something went wrong: {:?}", r); 85 | } 86 | } 87 | Err(e) => 88 | println!("Failed to send to braid: {:?}", e.description()), 89 | 90 | } 91 | } 92 | -------------------------------------------------------------------------------- /src/handler.rs: -------------------------------------------------------------------------------- 1 | use std::thread; 2 | use std::io::Read; 3 | use iron::{Request,Response,IronError}; 4 | use iron::status; 5 | use iron::error::HttpError; 6 | use openssl::crypto::hmac; 7 | use openssl::crypto::hash::Type; 8 | // to make from_hex on strings work 9 | use rustc_serialize::hex::FromHex; 10 | 11 | use app_conf::AppConf; 12 | use routing; 13 | use message; 14 | use commands; 15 | use github; 16 | use tracking; 17 | 18 | fn verify_braid_hmac(mac: Vec, key: &[u8], data: &[u8]) -> bool { 19 | if let Some(mac) = String::from_utf8(mac).ok() 20 | .and_then(|mac_str| (&mac_str[..]).from_hex().ok()) { 21 | let generated: Vec = hmac::hmac(Type::SHA256, key, data).to_vec(); 22 | mac == generated 23 | } else { 24 | false 25 | } 26 | } 27 | 28 | fn verify_github_hmac(mac: Vec, key: &[u8], data: &[u8]) -> bool { 29 | let sig_str = String::from_utf8(mac).ok().unwrap_or_default(); 30 | if let Some(mac) = sig_str 31 | .splitn(2, '=').last() 32 | .and_then(|mac_str| (&mac_str[..]).from_hex().ok()) { 33 | let generated: Vec = hmac::hmac(Type::SHA1, key, data).to_vec(); 34 | mac == generated 35 | } else { 36 | false 37 | } 38 | } 39 | 40 | 41 | pub fn handle_braid_message(request: &mut Request, conf: AppConf) -> Result { 42 | // Verify MAC 43 | let mac = try!(request.headers.get_raw("X-Braid-Signature") 44 | .and_then(|h| h.get(0)) 45 | .ok_or(IronError::new(routing::MissingMac, 46 | status::Unauthorized))); 47 | 48 | let braid_token = conf.braid.token.clone(); 49 | let mut buf = Vec::new(); 50 | request.body.read_to_end(&mut buf).unwrap(); // TODO: check 51 | if !verify_braid_hmac(mac.clone(), braid_token.as_bytes(), &buf[..]) { 52 | println!("Bad mac"); 53 | return Err(IronError::new(routing::BadMac, status::Forbidden)); 54 | } 55 | println!("Mac OK"); 56 | match message::decode_transit_msgpack(buf) { 57 | Some(msg) => { 58 | thread::spawn(move || { 59 | if let Some(thread) = tracking::issue_for_thread(msg.thread_id, 60 | &conf) 61 | { 62 | github::update_from_braid(thread, msg, conf); 63 | } else { 64 | commands::parse_command(msg, conf); 65 | } 66 | }); 67 | }, 68 | None => println!("Couldn't parse message") 69 | } 70 | Ok(Response::with((status::Ok, "ok"))) 71 | } 72 | 73 | pub fn handle_github_webhook(request: &mut Request, conf: AppConf) -> Result { 74 | let mac = try!(request.headers.get_raw("X-Hub-Signature") 75 | .and_then(|h| h.get(0)) 76 | .ok_or(IronError::new(routing::MissingMac, status::Unauthorized))); 77 | 78 | let github_token = conf.github.webhook_secret.clone(); 79 | let mut buf = Vec::new(); 80 | match request.body.read_to_end(&mut buf) { 81 | Err(e) => { 82 | println!("Couldn't read github body: {:?}", e); 83 | Err(IronError::new(HttpError::Io(e), status::BadRequest)) 84 | } 85 | Ok(_) => { 86 | if !verify_github_hmac(mac.clone(), github_token.as_bytes(), &buf[..]) { 87 | println!("Bad mac"); 88 | return Err(IronError::new(routing::BadMac, status::Forbidden)); 89 | } 90 | println!("Mac OK"); 91 | 92 | thread::spawn(move || { github::update_from_github(buf, conf) }); 93 | Ok(Response::with((status::Ok, "ok"))) 94 | } 95 | } 96 | } 97 | -------------------------------------------------------------------------------- /src/tracking.rs: -------------------------------------------------------------------------------- 1 | use uuid::Uuid; 2 | use rusqlite::Connection; 3 | 4 | use app_conf::AppConf; 5 | 6 | fn get_conn(conf: &AppConf) -> Connection { 7 | Connection::open(&conf.general.db_name[..]) 8 | .expect("Couldn't open database!") 9 | } 10 | 11 | pub struct WatchedThread { 12 | pub thread_id: Uuid, 13 | pub issue_number: i64, 14 | pub repository: String, 15 | } 16 | 17 | pub fn setup_tables(conf: &AppConf) { 18 | let conn = get_conn(conf); 19 | conn.execute_batch("BEGIN; 20 | CREATE TABLE IF NOT EXISTS watched_threads ( 21 | thread_id TEXT NOT NULL UNIQUE, 22 | issue_number INTEGER NOT NULL, 23 | repository TEXT NOT NULL 24 | ); 25 | 26 | CREATE TABLE IF NOT EXISTS posted_comments ( 27 | thread_id TEXT NOT NULL, 28 | comment_id INTEGER NOT NULL 29 | ); 30 | 31 | CREATE UNIQUE INDEX IF NOT EXISTS repo_idx 32 | ON watched_threads (repository, issue_number); 33 | COMMIT;") 34 | .expect("Couldn't create the table"); 35 | } 36 | 37 | pub fn add_watched_thread(thread_id: Uuid, 38 | repo: String, 39 | issue_number: i64, 40 | conf: &AppConf) 41 | { 42 | let conn = get_conn(conf); 43 | 44 | match conn.execute("INSERT INTO watched_threads (thread_id, issue_number, repository) 45 | VALUES ($1, $2, $3)", 46 | &[&thread_id.simple().to_string(), &issue_number, &repo]) { 47 | Ok(_) => { println!("Watching thread {}, {}, {}", thread_id, repo, issue_number); } 48 | Err(e) => { println!("Couldn't save watched thread {} {} {}: {:?}", 49 | thread_id, repo, issue_number, e); 50 | } 51 | } 52 | 53 | } 54 | 55 | pub fn thread_for_issue(repo: String, issue_number: i64, conf: &AppConf) -> Option 56 | { 57 | let conn = get_conn(conf); 58 | 59 | match conn.query_row("SELECT thread_id FROM watched_threads 60 | WHERE repository = $0 AND issue_number = $1", 61 | &[&repo, &issue_number], 62 | |row| row.get::<_, String>(0)) { 63 | Ok(thread_id) => Uuid::parse_str(&thread_id[..]) 64 | .ok() 65 | .map(|t_id| WatchedThread { 66 | thread_id: t_id, 67 | repository: repo, 68 | issue_number: issue_number, 69 | }), 70 | Err(e) => { 71 | println!("Couldn't find thread for issue: {:?}", e); 72 | None 73 | } 74 | } 75 | } 76 | 77 | 78 | pub fn issue_for_thread(thread_id: Uuid, conf: &AppConf) -> Option { 79 | let conn = get_conn(conf); 80 | 81 | match conn.query_row( 82 | "SELECT issue_number, repository FROM watched_threads 83 | WHERE thread_id = $0", 84 | &[&thread_id.simple().to_string()], 85 | |row| WatchedThread { 86 | thread_id: thread_id, 87 | issue_number: row.get::<_, i64>(0), 88 | repository: row.get::<_, String>(1), 89 | }) 90 | { 91 | Ok(issue) => Some(issue), 92 | Err(e) => { 93 | println!("Couldn't find issue for thread: {:?}", e); 94 | None 95 | } 96 | } 97 | } 98 | 99 | pub fn track_comment(thread_id: Uuid, comment_id: i64, conf: &AppConf) { 100 | let conn = get_conn(conf); 101 | 102 | match conn.execute("INSERT INTO posted_comments (thread_id, comment_id) 103 | VALUES ($1, $2)", 104 | &[&thread_id.simple().to_string(), &comment_id]) { 105 | Ok(_) => { println!("Tracking posted comment {} from {}", comment_id, thread_id); }, 106 | Err(e) => { println!("Couldn't track comment: {:?}", e); } 107 | } 108 | } 109 | 110 | pub fn did_we_post_comment(thread_id: Uuid, comment_id: i64, conf: &AppConf) -> bool 111 | { 112 | let conn = get_conn(conf); 113 | match conn.query_row("SELECT count(*) FROM posted_comments 114 | WHERE thread_id = $0 AND comment_id = $1", 115 | &[&thread_id.simple().to_string(), &comment_id], 116 | |row| row.get::<_, i64>(0) != 0) { 117 | Ok(c) => c, 118 | Err(_) => false, 119 | } 120 | } 121 | -------------------------------------------------------------------------------- /src/message.rs: -------------------------------------------------------------------------------- 1 | use std::io::{Cursor,Write}; 2 | use byteorder::{WriteBytesExt,ReadBytesExt,BigEndian}; 3 | use uuid::Uuid; 4 | use serde; 5 | use serde::{Serialize,Deserialize}; 6 | use rmp::Marker; 7 | use rmp::encode::{ValueWriteError, write_map_len, write_str}; 8 | use rmp_serde::{Serializer,Deserializer}; 9 | use rmp_serde::encode::{VariantWriter, Error as EncodeError}; 10 | 11 | #[derive(Debug, PartialEq, Deserialize, Serialize, Clone)] 12 | pub struct Message { 13 | #[serde(rename="~:id", deserialize_with="deserialize_transit_uuid", serialize_with="serialize_transit_uuid")] 14 | pub id: Uuid, 15 | #[serde(rename="~:group-id", deserialize_with="deserialize_transit_uuid", serialize_with="serialize_transit_uuid")] 16 | pub group_id: Uuid, 17 | #[serde(rename="~:thread-id", deserialize_with="deserialize_transit_uuid", serialize_with="serialize_transit_uuid")] 18 | pub thread_id: Uuid, 19 | #[serde(rename="~:user-id", deserialize_with="deserialize_transit_uuid", serialize_with="serialize_transit_uuid")] 20 | pub user_id: Uuid, 21 | #[serde(rename="~:mentioned-user-ids", deserialize_with="deserialize_transit_uuid_seq", serialize_with="serialize_transit_uuid_seq")] 22 | pub mentioned_user_ids: Vec, 23 | #[serde(rename="~:mentioned-tag-ids", deserialize_with="deserialize_transit_uuid_seq", serialize_with="serialize_transit_uuid_seq")] 24 | pub mentioned_tag_ids: Vec, 25 | #[serde(rename="~:content")] 26 | pub content: String, 27 | } 28 | 29 | type TransitUuid = (String, (i64, i64)); 30 | 31 | fn deserialize_transit_uuid(de: &mut D) -> Result 32 | where D: serde::Deserializer { 33 | let transit_uuid: TransitUuid = try!(Deserialize::deserialize(de)); 34 | Ok(transit_to_uuid(transit_uuid)) 35 | } 36 | 37 | fn deserialize_transit_uuid_seq(de: &mut D) -> Result, D::Error> 38 | where D: serde::Deserializer { 39 | let transit_uuids: Vec = try!(Deserialize::deserialize(de)); 40 | Ok(transit_uuids.into_iter().map(transit_to_uuid).collect()) 41 | } 42 | 43 | fn transit_to_uuid(transit_uuid: TransitUuid) -> Uuid { 44 | assert!(transit_uuid.0 == "~#u", "Mis-tagged transit"); 45 | let mut wrtr = vec![]; 46 | let hi64 = (transit_uuid.1).0; 47 | let lo64 = (transit_uuid.1).1; 48 | wrtr.write_i64::(hi64).unwrap(); 49 | wrtr.write_i64::(lo64).unwrap(); 50 | let mut bytes: [u8; 16] = [0; 16]; 51 | for i in 0..wrtr.len() { 52 | bytes[i] = wrtr[i]; 53 | } 54 | Uuid::from_bytes(&bytes).ok().unwrap() 55 | } 56 | 57 | fn serialize_transit_uuid(uuid: &Uuid, se: &mut S) -> Result<(), S::Error> 58 | where S: serde::Serializer { 59 | let transit_uuid = uuid_to_transit(uuid); 60 | match transit_uuid.serialize(se) { 61 | Ok(_) => Ok(()), 62 | Err(_) => Err(serde::ser::Error::custom("Failed to serialize uuid")), 63 | } 64 | } 65 | 66 | fn serialize_transit_uuid_seq(uuids: &[Uuid], se: &mut S) -> Result<(), S::Error> 67 | where S: serde::Serializer { 68 | let transit_uuids: Vec = uuids.into_iter().map(uuid_to_transit).collect(); 69 | match transit_uuids.serialize(se) { 70 | Ok(_) => Ok(()), 71 | Err(_) => Err(serde::ser::Error::custom("Failed to serialize uuid vector")), 72 | } 73 | } 74 | 75 | fn uuid_to_transit(uuid: &Uuid) -> TransitUuid { 76 | let bytes = uuid.as_bytes(); 77 | let mut reader = Cursor::new(bytes); 78 | let hi64 = reader.read_i64::().unwrap(); 79 | let lo64 = reader.read_i64::().unwrap(); 80 | ("~#u".to_string(), (hi64, lo64)) 81 | } 82 | 83 | struct StructMapWriter; 84 | 85 | impl VariantWriter for StructMapWriter { 86 | fn write_struct_len(&self, wr: &mut W, len: u32) -> Result 87 | where W: Write 88 | { 89 | write_map_len(wr, len) 90 | } 91 | 92 | fn write_field_name(&self, wr: &mut W, _key: &str) -> Result<(), ValueWriteError> 93 | where W: Write 94 | { 95 | write_str(wr, _key) 96 | } 97 | } 98 | 99 | pub fn encode_transit_msgpack(msg: Message) -> Result, EncodeError> { 100 | let mut buf = vec![]; 101 | try!(msg.serialize(&mut Serializer::with(&mut &mut buf, StructMapWriter))); 102 | Ok(buf) 103 | } 104 | 105 | pub fn decode_transit_msgpack(msgpack_buf: Vec) -> Option { 106 | let cur = Cursor::new(&msgpack_buf[..]); 107 | let mut deserializer = Deserializer::new(cur); 108 | Deserialize::deserialize(&mut deserializer).ok() 109 | } 110 | 111 | pub fn response_to(msg: Message, content: String) -> Message { 112 | Message { 113 | id: Uuid::new_v4(), 114 | user_id: Uuid::new_v4(), // gets filled in by server 115 | group_id: msg.group_id, 116 | thread_id: msg.thread_id, 117 | mentioned_user_ids: vec![], 118 | mentioned_tag_ids: vec![], 119 | content: content, 120 | } 121 | } 122 | 123 | pub fn new_thread_msg(tag: Uuid, content: String) -> Message { 124 | Message { 125 | id: Uuid::new_v4(), 126 | user_id: Uuid::new_v4(), // gets filled in by server 127 | group_id: Uuid::new_v4(), // gets filled in by server 128 | thread_id: Uuid::new_v4(), 129 | mentioned_user_ids: vec![], 130 | mentioned_tag_ids: vec![tag], 131 | content: content, 132 | } 133 | } 134 | 135 | pub fn reply_to_thread(thread: Uuid, content: String) -> Message { 136 | Message { 137 | id: Uuid::new_v4(), 138 | // user_id gets filled in by server 139 | user_id: Uuid::new_v4(), 140 | // user_id gets filled in by server 141 | group_id: Uuid::new_v4(), 142 | thread_id: thread, 143 | mentioned_user_ids: vec![], 144 | mentioned_tag_ids: vec![], 145 | content: content, 146 | } 147 | } 148 | -------------------------------------------------------------------------------- /src/github.rs: -------------------------------------------------------------------------------- 1 | use std::io::Read; 2 | use std::collections::BTreeMap; 3 | use hyper::client::{Client,Response}; 4 | use hyper::header::{Headers,ContentType,Authorization,Bearer,UserAgent}; 5 | use hyper::error::Result as HttpResult; 6 | use serde_json; 7 | use serde_json::value::{Value as JsonValue,Map}; 8 | 9 | use app_conf::{AppConf,RepoConf}; 10 | use tracking; 11 | use braid; 12 | use message; 13 | 14 | static GITHUB_API_URL: &'static str = "https://api.github.com"; 15 | 16 | fn send_github_request(token: &str, endpoint: &str, data: JsonValue) -> HttpResult { 17 | let mut url_str = String::from(GITHUB_API_URL); 18 | url_str.push_str(endpoint); 19 | let mut headers = Headers::new(); 20 | headers.set(ContentType::json()); 21 | headers.set(Authorization(Bearer { token: token.to_owned() })); 22 | headers.set(UserAgent("braidchat/octocat".to_owned())); 23 | let body = serde_json::to_string(&data).expect("Can't serialize data"); 24 | let client = Client::new(); 25 | client.post(url_str.as_str()) 26 | .body(&body[..]) 27 | .headers(headers) 28 | .send() 29 | } 30 | 31 | pub fn find_repo_conf<'a>(name: &str, conf: &'a AppConf) -> Option<&'a RepoConf> { 32 | if name.contains('/') { 33 | let mut split = name.splitn(2, '/'); 34 | let org = split.next().unwrap(); 35 | let repo = split.next().unwrap(); 36 | for r in &conf.repos { 37 | if r.repo == repo && r.org == org { 38 | return Some(r) 39 | } 40 | } 41 | None 42 | } else { 43 | for r in &conf.repos { 44 | if r.repo == name { 45 | return Some(r) 46 | } 47 | } 48 | None 49 | } 50 | } 51 | 52 | #[derive(Debug)] 53 | pub struct GithubIssue { 54 | pub url: String, 55 | pub number: i64, 56 | } 57 | 58 | pub fn create_issue(github_conf: &RepoConf, title: String, content: String) 59 | -> Option 60 | { 61 | let token = github_conf.token.clone(); 62 | let owner = github_conf.org.clone(); 63 | let repo = github_conf.repo.clone(); 64 | let mut path = String::from("/repos/"); 65 | path.push_str(&owner[..]); 66 | path.push_str("/"); 67 | path.push_str(&repo[..]); 68 | path.push_str("/issues"); 69 | 70 | let mut map = Map::new(); 71 | map.insert(String::from("title"), JsonValue::String(title)); 72 | map.insert(String::from("body"), JsonValue::String(content)); 73 | let data = JsonValue::Object(map); 74 | 75 | match send_github_request(&token[..], path.as_str(), data) { 76 | Err(e) => { println!("Error fetching from github: {:?}", e); None } 77 | Ok(mut resp) => { 78 | let mut buf = String::new(); 79 | match resp.read_to_string(&mut buf) { 80 | Err(_) => { println!("Couldn't read response"); None }, 81 | Ok(_) => { 82 | match serde_json::from_str(&buf[..]) { 83 | Ok(new_issue) => { 84 | let new_issue: BTreeMap = new_issue; 85 | let url = new_issue.get("html_url") 86 | .and_then(|url| url.as_string() ); 87 | let number: Option = new_issue.get("number") 88 | .and_then(|n| { n.as_i64() }); 89 | if let (Some(u), Some(n)) = (url, number) { 90 | Some(GithubIssue { url: u.to_owned(), number: n }) 91 | } else { 92 | None 93 | } 94 | } 95 | Err(e) => { println!("Failed to parse json: {:?}", e); None } 96 | } 97 | } 98 | } 99 | } 100 | } 101 | } 102 | 103 | fn new_issue_from_webhook(issue_number: i64, payload: JsonValue, conf: AppConf) 104 | { 105 | let repo_name = match payload.find_path(&["repository", "full_name"]) 106 | .and_then(|n| n.as_string()) { 107 | Some(r) => r, 108 | None => { 109 | println!("Couldn't get repository from message"); 110 | return 111 | } 112 | }; 113 | if tracking::thread_for_issue(repo_name.to_owned(), issue_number, &conf) 114 | .is_some() 115 | { 116 | println!("Already tracking this issue"); 117 | return 118 | } 119 | let repo_conf = match find_repo_conf(repo_name, &conf) { 120 | Some(c) => c, 121 | None => { 122 | println!("Couldn't find conf for {}", repo_name); 123 | return 124 | } 125 | }; 126 | 127 | let issue = match payload.find("issue") { 128 | Some(i) => i, 129 | None => { println!("No issue in payload!"); return } 130 | }; 131 | 132 | let creator = match issue.find_path(&["user", "login"]) 133 | .and_then(|u| u.as_string()) { 134 | Some(u) => u, 135 | None => { println!("Missing creator name"); return } 136 | }; 137 | let issue_title = match issue.find("title") 138 | .and_then(|t| t.as_string()) { 139 | Some(t) => t, 140 | None => { println!("Missing issue title"); return } 141 | }; 142 | let issue_url = match issue.find("html_url") 143 | .and_then(|u| u.as_string()) { 144 | Some(u) => u, 145 | None => { println!("Missing issue url"); return } 146 | }; 147 | let content = format!("{} opened issue \"{}\"\n{}", 148 | creator, issue_title, issue_url); 149 | 150 | let braid_response_tag_id = repo_conf.tag_id; 151 | let msg = message::new_thread_msg(braid_response_tag_id, content); 152 | let braid_conf = conf.braid.clone(); 153 | tracking::add_watched_thread(msg.thread_id, repo_name.to_owned(), 154 | issue_number, &conf); 155 | braid::send_braid_request(msg.clone(), &braid_conf); 156 | braid::start_watching_thread(msg.thread_id, &braid_conf); 157 | } 158 | 159 | fn comment_from_webhook(issue_number: i64, repo_name: &str, update: JsonValue, conf: AppConf) { 160 | println!("Update to issue {:?}", issue_number); 161 | let thread_id = match tracking::thread_for_issue(repo_name.to_owned(), 162 | issue_number, 163 | &conf) 164 | { 165 | Some(thread) => thread.thread_id, 166 | None => { 167 | println!("Not tracking this issue though"); 168 | return 169 | } 170 | }; 171 | let comment = match update.find("comment") { 172 | Some(comment) => comment, 173 | None => { println!("No comment in issue!"); return } 174 | }; 175 | let comment_id = match comment.find("id") 176 | .and_then(|i| i.as_i64()) { 177 | Some(i) => i, 178 | None => { println!("Missing comment id!"); return } 179 | }; 180 | if tracking::did_we_post_comment(thread_id, comment_id, &conf) { 181 | println!("webhook for our own comment"); 182 | return 183 | } 184 | let commenter = match comment.find_path(&["user", "login"]) 185 | .and_then(|u| u.as_string()) { 186 | Some(c) => c, 187 | None => { println!("Missing commenter"); return } 188 | }; 189 | let comment_body = match comment.find("body") 190 | .and_then(|b| b.as_string()) { 191 | Some(b) => b, 192 | None => { println!("Missing comment body"); return } 193 | }; 194 | let msg_body = format!("{} commented:\n{}", commenter, comment_body); 195 | let msg = message::reply_to_thread(thread_id, msg_body); 196 | braid::send_braid_request(msg, &conf.braid); 197 | } 198 | 199 | fn closed_issue_from_webhook(issue_number: i64, repo_name: &str, update: JsonValue, conf: AppConf) { 200 | println!("Issue {} in {} closed", issue_number, repo_name); 201 | let thread_id = match tracking::thread_for_issue(repo_name.to_owned(), 202 | issue_number, &conf) 203 | { 204 | Some(thread) => thread.thread_id, 205 | None => { 206 | println!("Not tracking this issue though"); 207 | return 208 | } 209 | }; 210 | let closer = update.find_path(&["sender", "login"]) 211 | .and_then(|u| u.as_string()) 212 | .unwrap_or("an unknown user"); 213 | let msg_body = format!("issue has been closed by {}", closer); 214 | let msg = message::reply_to_thread(thread_id, msg_body); 215 | braid::send_braid_request(msg, &conf.braid); 216 | } 217 | 218 | pub fn update_from_github(msg_body: Vec, conf: AppConf) { 219 | match serde_json::from_slice(&msg_body[..]) { 220 | Err(e) => println!("Couldn't parse update json: {:?}", e), 221 | Ok(update) => { 222 | let update: JsonValue = update; 223 | let repo_name = match update.find_path(&["repository", "full_name"]) 224 | .and_then(|n| n.as_string()) { 225 | Some(r) => r, 226 | None => { 227 | println!("Couldn't get repository from message"); 228 | return 229 | } 230 | }; 231 | let issue_number = match update.find_path(&["issue", "number"]) 232 | .and_then(|n| n.as_i64()) { 233 | Some(i) => i, 234 | None => { println!("Couldn't get issue #"); return } 235 | }; 236 | let action = match update.find("action") 237 | .and_then(|a| a.as_string()) { 238 | Some(a) => a, 239 | None => { println!("Couldn't get issue action!"); return } 240 | }; 241 | match action { 242 | "opened" => new_issue_from_webhook(issue_number, update.clone(), conf), 243 | "created" => comment_from_webhook(issue_number, repo_name, update.clone(), conf), 244 | "closed" => closed_issue_from_webhook(issue_number, repo_name, update.clone(), conf), 245 | _ => println!("Unknown action from webhook {}", action), 246 | } 247 | } 248 | } 249 | } 250 | 251 | pub fn update_from_braid(thread: tracking::WatchedThread, msg: message::Message, conf: AppConf) 252 | { 253 | let comment_user = braid::get_user_nick(msg.user_id, &conf.braid) 254 | .unwrap_or("some braid user".to_owned()); 255 | 256 | let repo_name = thread.repository; 257 | let repo_conf = match find_repo_conf(&repo_name[..], &conf) { 258 | Some(conf) => conf, 259 | None => { 260 | println!("Couldn't find conf for repo {}", repo_name); 261 | return 262 | } 263 | }; 264 | let token = repo_conf.token.clone(); 265 | let path = format!("/repos/{}/issues/{}/comments", repo_name, 266 | thread.issue_number); 267 | 268 | let comment = format!("{} commented via [braid]({}):\n{}", 269 | comment_user, 270 | braid::thread_url(&conf.braid, &msg), 271 | msg.content); 272 | let mut map = Map::new(); 273 | map.insert(String::from("body"), JsonValue::String(comment)); 274 | let data = JsonValue::Object(map); 275 | match send_github_request(&token[..], &path[..], data) { 276 | Err(e) => println!("Error sending github request: {:?}", e), 277 | Ok(mut resp) => { 278 | let mut buf = String::new(); 279 | match resp.read_to_string(&mut buf) { 280 | Err(e) => println!("Error reading github response: {:?}", e), 281 | Ok(_) => { 282 | match serde_json::from_str(&buf[..]) { 283 | Err(e) => println!("Couldn't parse json from github: {:?}", e), 284 | Ok(new_comment) => { 285 | let new_comment: JsonValue = new_comment; 286 | if let Some(id) = new_comment.find("id").and_then(|i| i.as_i64()) { 287 | tracking::track_comment(msg.thread_id, 288 | id, 289 | &conf); 290 | } else { 291 | println!("Couldn't get comment id"); 292 | } 293 | } 294 | } 295 | } 296 | } 297 | } 298 | } 299 | } 300 | --------------------------------------------------------------------------------