pygmy 0.3.0

Ping me — notifications from AI agents (Telegram, Discord)
use std::io::{self, Write};

use anyhow::{Context, Result};
use colored::Colorize;

use crate::config::{self, DiscordWebhookConfig, NtfyConfig, TelegramConfig};
use crate::discord;
use crate::ntfy;
use crate::telegram;

pub async fn run_telegram() -> Result<()> {
    println!();
    println!("{} — set up Telegram notifications", "pygmy".bold());
    println!();

    println!("{}", "Step 1: Create a Telegram bot".bold());
    println!("1. Open Telegram and message @BotFather");
    println!("2. Send /newbot");
    println!("3. Choose a name (e.g. \"Pygmy Notifications\")");
    println!("4. Choose a username (e.g. \"my_pygmy_bot\")");
    println!("5. Copy the bot token BotFather gives you");
    println!();

    let bot_token = prompt("Paste your bot token")?;
    if bot_token.is_empty() {
        anyhow::bail!("Bot token cannot be empty.");
    }
    println!("{} Bot token saved", "".green());
    println!();

    println!("{}", "Step 2: Create a Forum group".bold());
    println!("1. Create a new Telegram group (you can be the only member)");
    println!("2. Go to group settings → Topics → Enable");
    println!(
        "3. Add your bot to the group and make it admin (ensure \"Manage Topics\" is enabled)"
    );
    println!("4. Send /start in the group (important: must start with /)");
    println!();
    prompt("Press Enter once done...")?;

    let updates = telegram::get_updates(&bot_token)
        .await
        .context("Could not reach Telegram API — check your bot token.")?;

    let mut groups: Vec<(i64, String)> = Vec::new();
    let mut seen = std::collections::HashSet::new();

    for update in &updates {
        if let Some(msg) = &update.message {
            let chat = &msg.chat;
            if (chat.chat_type == "group" || chat.chat_type == "supergroup") && seen.insert(chat.id)
            {
                let title = chat.title.clone().unwrap_or_else(|| "Untitled".into());
                groups.push((chat.id, title));
            }
        }
        if let Some(member) = &update.my_chat_member {
            let chat = &member.chat;
            if (chat.chat_type == "group" || chat.chat_type == "supergroup") && seen.insert(chat.id)
            {
                let title = chat.title.clone().unwrap_or_else(|| "Untitled".into());
                groups.push((chat.id, title));
            }
        }
    }

    if groups.is_empty() {
        eprintln!(
            "{} getUpdates returned {} update(s), but none contained group info.",
            "Debug:".dimmed(),
            updates.len()
        );
        anyhow::bail!(
            "No groups found. Make sure you:\n\
             1. Added the bot to a group\n\
             2. Sent /start in the group (regular messages are invisible to bots)\n\
             3. The group has Topics enabled\n\
             Then run `pygmy init telegram` again."
        );
    }

    let (group_id, group_title) = if groups.len() == 1 {
        let (id, title) = &groups[0];
        println!("{} Found group: \"{}\" ({})", "".green(), title, id);
        (*id, title.clone())
    } else {
        println!("Found these groups:");
        for (i, (id, title)) in groups.iter().enumerate() {
            println!("  {}. \"{}\" ({})", i + 1, title, id);
        }
        println!();
        let choice = prompt("Which group? [1]")?;
        let idx: usize = if choice.is_empty() {
            0
        } else {
            choice
                .parse::<usize>()
                .context("invalid number")?
                .checked_sub(1)
                .context("invalid choice")?
        };
        let (id, title) = groups.get(idx).context("invalid choice")?;
        (*id, title.clone())
    };

    let mut cfg = config::load_config_or_default();
    cfg.telegram = Some(TelegramConfig {
        enabled: true,
        bot_token: bot_token.clone(),
        group_id: group_id.to_string(),
    });
    config::save_config(&cfg)?;
    println!();

    println!("{}", "Step 3: Test".bold());
    print!("Creating test topic and sending message...");
    io::stdout().flush()?;

    let thread_id = telegram::create_forum_topic(&bot_token, &group_id.to_string(), "pygmy-test")
        .await
        .context(
            "Could not create topic. Make sure:\n\
             1. Topics are enabled in group settings\n\
             2. The bot is an admin in the group",
        )?;

    telegram::send_message(
        &bot_token,
        &group_id.to_string(),
        "pygmy is set up and working! 🎉",
        Some(thread_id),
    )
    .await
    .context("Could not send test message")?;

    println!(
        "\r{} Test message delivered! Check \"{}\" in Telegram.",
        "".green(),
        group_title
    );
    println!();
    println!("{} Telegram is ready.", "Done.".green().bold());

    print_snippet();

    Ok(())
}

pub async fn run_discord_webhook() -> Result<()> {
    println!();
    println!("{} — set up Discord webhook notifications", "pygmy".bold());
    println!();

    println!("{}", "Step 1: Create a Discord webhook".bold());
    println!("1. Open Discord and go to the channel you want notifications in");
    println!("2. Click the gear icon (Edit Channel) → Integrations → Webhooks");
    println!("3. Click \"New Webhook\", give it a name (e.g. \"pygmy\")");
    println!("4. Click \"Copy Webhook URL\"");
    println!();

    let url = prompt("Paste your webhook URL")?;
    if url.is_empty() {
        anyhow::bail!("Webhook URL cannot be empty.");
    }
    if !url.starts_with("https://discord.com/api/webhooks/")
        && !url.starts_with("https://discordapp.com/api/webhooks/")
    {
        anyhow::bail!(
            "That doesn't look like a Discord webhook URL.\n\
             Expected: https://discord.com/api/webhooks/..."
        );
    }
    println!("{} Webhook URL saved", "".green());
    println!();

    println!("{}", "Step 2: Test".bold());
    print!("Sending test message...");
    io::stdout().flush()?;

    discord::send_message(&url, "**[pygmy-test]**\npygmy is set up and working! 🎉")
        .await
        .context("Could not send test message — check your webhook URL.")?;

    println!(
        "\r{} Test message delivered! Check your Discord channel.",
        "".green()
    );
    println!();

    let mut cfg = config::load_config_or_default();
    cfg.discord_webhook = Some(DiscordWebhookConfig { enabled: true, url });
    config::save_config(&cfg)?;

    println!("{} Discord webhook is ready.", "Done.".green().bold());

    print_snippet();

    Ok(())
}

pub async fn run_ntfy() -> Result<()> {
    println!();
    println!("{} — set up ntfy push notifications", "pygmy".bold());
    println!();

    println!("{}", "Step 1: Subscribe to a topic".bold());
    println!("1. Install the ntfy app on your phone (Android/iOS) or desktop");
    println!("2. Subscribe to a topic — this will be your notification channel");
    println!("3. Pick a hard-to-guess topic name if using the public ntfy.sh server");
    println!("   (on ntfy.sh, anyone who knows the topic name can read/write to it)");
    println!();

    let server = prompt_with_default("Server URL", "https://ntfy.sh")?;
    if server.is_empty() {
        anyhow::bail!("Server URL cannot be empty.");
    }
    println!("{} Server: {}", "".green(), server);
    println!();

    let topic = prompt("ntfy topic name")?;
    if topic.is_empty() {
        anyhow::bail!("Topic name cannot be empty.");
    }
    println!("{} Topic: {}", "".green(), topic);
    println!();

    println!("{}", "Step 2: Authentication (optional)".bold());
    println!("Leave blank unless you've configured access control for your topic.");
    println!();

    let token_input = prompt("Access token (press Enter to skip)")?;
    let token = if token_input.is_empty() {
        println!("{} No token (public access)", "".green());
        None
    } else {
        println!("{} Token saved", "".green());
        Some(token_input)
    };
    println!();

    let ntfy_config = NtfyConfig {
        enabled: true,
        server: server.clone(),
        topic: topic.clone(),
        token: token.clone(),
    };

    println!("{}", "Step 3: Test".bold());
    print!("Sending test notification...");
    io::stdout().flush()?;

    ntfy::send_message(
        &ntfy_config,
        "pygmy-test",
        "pygmy is set up and working! 🎉",
    )
    .await
    .context("Could not send test notification — check your server URL and topic.")?;

    println!(
        "\r{} Test notification sent! Check your ntfy app.",
        "".green()
    );
    println!();

    let mut cfg = config::load_config_or_default();
    cfg.ntfy = Some(ntfy_config);
    config::save_config(&cfg)?;

    println!("{} ntfy is ready.", "Done.".green().bold());

    print_snippet();

    Ok(())
}

fn print_snippet() {
    println!();
    println!("Add the following to your CLAUDE.md or agent instructions:");
    println!();
    println!("---");
    print!("{}", include_str!("pygmy_claude_snippet.md"));
    println!("---");
}

fn prompt(label: &str) -> Result<String> {
    print!("{}: ", label);
    io::stdout().flush()?;
    let mut input = String::new();
    io::stdin().read_line(&mut input)?;
    Ok(input.trim().to_string())
}

fn prompt_with_default(label: &str, default: &str) -> Result<String> {
    print!("{} [{}]: ", label, default);
    io::stdout().flush()?;
    let mut input = String::new();
    io::stdin().read_line(&mut input)?;
    let trimmed = input.trim();
    if trimmed.is_empty() {
        Ok(default.to_string())
    } else {
        Ok(trimmed.to_string())
    }
}