maniac-killer 0.1.0

High-resource runaway process watchdog and remote executioner for macOS & Linux with AI agent immunity
use crate::auth::Auth;
use crate::config::Config;
use crate::detector::TrackedProcess;
use crate::killer::KillResult;
use crate::notifier::urlencode;
use chrono::Utc;
use reqwest::Client;
use serde_json::json;

pub struct SlackNotifier;

impl SlackNotifier {
    pub async fn send_alert(
        config: &Config,
        proc: &TrackedProcess,
        base_url: &str,
    ) -> Result<(), String> {
        let token = match &config.slack_bot_token {
            Some(t) if !t.is_empty() => t,
            _ => return Err("Slack Bot Token not configured".to_string()),
        };
        let channel = match &config.slack_channel {
            Some(c) if !c.is_empty() => c,
            _ => return Err("Slack Channel not configured".to_string()),
        };

        let server_name = config.get_server_name();
        let ssh_host = config.get_ssh_host();
        let now_ts = Utc::now().timestamp();

        let client = Client::new();

        // Generate HMAC-SHA256 Expiring Action Signatures (Valid for 15 mins)
        let kill_sig = Auth::sign_action(
            &config.auth_token,
            "kill",
            proc.pid,
            proc.start_time,
            now_ts,
        );
        let kill_url = format!(
            "{}/confirm-kill?pid={}&st={}&ts={}&sig={}",
            base_url, proc.pid, proc.start_time, now_ts, kill_sig
        );

        let mute_sig = Auth::sign_action(
            &config.auth_token,
            "mute",
            proc.pid,
            proc.start_time,
            now_ts,
        );
        let mute_url = format!(
            "{}/mute?pid={}&st={}&ts={}&sig={}&hours=1",
            base_url, proc.pid, proc.start_time, now_ts, mute_sig
        );

        let wl_sig = Auth::sign_action(
            &config.auth_token,
            "whitelist",
            proc.pid,
            proc.start_time,
            now_ts,
        );
        let wl_url = format!(
            "{}/whitelist?name={}&ts={}&sig={}",
            base_url,
            urlencode(&proc.name),
            now_ts,
            wl_sig
        );

        let cmd_short = if proc.cmdline.len() > 180 {
            format!("{}...", &proc.cmdline[..180])
        } else {
            proc.cmdline.clone()
        };

        let fallback_text = format!(
            "๐Ÿšจ [MANIAC KILLER โ€” {}] ํญ์ฃผ ํ”„๋กœ์„ธ์Šค ๊ฐ์ง€: {} (PID: {}, CPU: {:.1}%, MEM: {}MB)",
            server_name, proc.name, proc.pid, proc.cpu_percent, proc.memory_mb
        );

        let blocks = json!([
            {
                "type": "header",
                "text": {
                    "type": "plain_text",
                    "text": format!("๐Ÿšจ [MANIAC KILLER] {} ํญ์ฃผ ํ”„๋กœ์„ธ์Šค ํฌ์ฐฉ!", server_name),
                    "emoji": true
                }
            },
            {
                "type": "section",
                "fields": [
                    {
                        "type": "mrkdwn",
                        "text": format!("*์„œ๋ฒ„/์žฅ๋น„:* `{}`", server_name)
                    },
                    {
                        "type": "mrkdwn",
                        "text": format!("*ํ”„๋กœ์„ธ์Šค:* `{}` (PID: `{}`)", proc.name, proc.pid)
                    },
                    {
                        "type": "mrkdwn",
                        "text": format!("*CPU ์ ์œ ์œจ:* *`{:.1}%`* (์—ฐ์† {}ํšŒ)", proc.cpu_percent, proc.cpu_streak)
                    },
                    {
                        "type": "mrkdwn",
                        "text": format!("*๋ฉ”๋ชจ๋ฆฌ ์ ์œ :* *`{} MB`*", proc.memory_mb)
                    },
                    {
                        "type": "mrkdwn",
                        "text": format!("*์‚ฌ์œ :* {}", proc.reason)
                    }
                ]
            },
            {
                "type": "section",
                "text": {
                    "type": "mrkdwn",
                    "text": format!("*๐Ÿ“ ์ž‘์—… ๊ฒฝ๋กœ:* `{}`\n*๐Ÿ’ป ์‹คํ–‰ ๋ช…๋ น:* `{}`", if proc.cwd.is_empty() { "N/A" } else { &proc.cwd }, cmd_short)
                }
            },
            {
                "type": "actions",
                "elements": [
                    {
                        "type": "button",
                        "text": {
                            "type": "plain_text",
                            "text": "๐Ÿฉธ ์ฆ‰์‹œ ์‚ฌ์‚ด (KILL)",
                            "emoji": true
                        },
                        "style": "danger",
                        "url": kill_url
                    },
                    {
                        "type": "button",
                        "text": {
                            "type": "plain_text",
                            "text": "๐Ÿ›ก๏ธ ํ™”์ดํŠธ๋ฆฌ์ŠคํŠธ ๋“ฑ๋ก",
                            "emoji": true
                        },
                        "url": wl_url
                    },
                    {
                        "type": "button",
                        "text": {
                            "type": "plain_text",
                            "text": "โณ 1์‹œ๊ฐ„ ์นจ๋ฌต",
                            "emoji": true
                        },
                        "url": mute_url
                    }
                ]
            },
            {
                "type": "context",
                "elements": [
                    {
                        "type": "mrkdwn",
                        "text": format!("โšก CLI ์ˆ˜๋™ ์‚ฌ์‚ด: `ssh {} \"maniac-killer kill {}\"` | Claude CLI ์„ธ์…˜ ๋ฐ ํ•ต์‹ฌ ๋ฐ๋ชฌ์€ ์ ˆ๋Œ€ ๋ณดํ˜ธ๋ฉ๋‹ˆ๋‹ค.", ssh_host, proc.pid)
                    }
                ]
            }
        ]);

        let payload = json!({
            "channel": channel,
            "text": fallback_text,
            "blocks": blocks
        });

        let resp = client
            .post("https://slack.com/api/chat.postMessage")
            .header("Authorization", format!("Bearer {}", token))
            .json(&payload)
            .send()
            .await
            .map_err(|e| format!("Failed to send Slack request: {}", e))?;

        if !resp.status().is_success() {
            return Err(format!("Slack API error status: {}", resp.status()));
        }

        let resp_json: serde_json::Value = resp
            .json()
            .await
            .map_err(|e| format!("Failed to parse Slack response: {}", e))?;

        if !resp_json
            .get("ok")
            .and_then(|v| v.as_bool())
            .unwrap_or(false)
        {
            return Err(format!(
                "Slack API returned not ok: {:?}",
                resp_json.get("error")
            ));
        }

        Ok(())
    }

    pub async fn send_kill_report(config: &Config, result: &KillResult) -> Result<(), String> {
        let token = match &config.slack_bot_token {
            Some(t) if !t.is_empty() => t,
            _ => return Err("Slack Bot Token not configured".to_string()),
        };
        let channel = match &config.slack_channel {
            Some(c) if !c.is_empty() => c,
            _ => return Err("Slack Channel not configured".to_string()),
        };

        let server_name = config.get_server_name();
        let client = Client::new();
        let text = format!(
            "๐Ÿฉธ *[MANIAC KILLER โ€” {}] ์‚ฌ์‚ด ๋ณด๊ณ ์„œ*\nโ€ข *PID:* `{}` ({})\nโ€ข *๊ฒฐ๊ณผ:* {}\nโ€ข *ํšŒ์ˆ˜ ๋ฉ”๋ชจ๋ฆฌ:* `{} MB`\nโ€ข *๋ช…๋ น์–ด:* `{}`",
            server_name, result.pid, result.name, result.message, result.memory_freed_mb, result.cmdline
        );

        let payload = json!({
            "channel": channel,
            "text": text
        });

        let _ = client
            .post("https://slack.com/api/chat.postMessage")
            .header("Authorization", format!("Bearer {}", token))
            .json(&payload)
            .send()
            .await;

        Ok(())
    }
}