KeiSeiKit-1.0/_primitives/_rust/kei-buddy/src/bin/kei-buddy.rs
Parfii-bot 44502507a2 feat(kei-buddy): wire OpenAiExtractor + chat_id whitelist + env-configurable LLM
Two additions on top of the MVP serve binary:

1. Whitelist by chat_id (KEI_BUDDY_ALLOWED_CHAT_IDS env, CSV).
   * BuddyContext gains Arc<Option<Vec<i64>>> allowed_chat_ids
   * chat_allowed() check fires before process_text
   * Non-whitelisted chats: warn-log + ignore (no response sent)
   * None or empty list = accept all (back-compat with prior behaviour)

2. Real LLM wiring (KEI_BUDDY_LLM_PROXY / _LLM_KEY / _LLM_MODEL).
   * When extractor-openai feature compiled in AND both proxy+key set,
     run_serve instantiates OpenAiExtractor instead of MockExtractor
   * Defaults: proxy=https://api.openai.com, key=OPENAI_API_KEY env,
     model=gpt-4o-mini
   * Fallback: warns + MockExtractor (state machine still walks, but
     LLM-extracted fields are empty)
   * extractor::OpenAiExtractor gains new_with_model(proxy, key, model);
     model is now per-instance instead of compile-time DEFAULT_MODEL

3. start_listener extracted as helper — keeps run_serve readable across
   the two feature-gated branches.

Verify-before-commit:
  * cargo check -p kei-buddy (default): PASS
  * cargo check -p kei-buddy --features extractor-openai: PASS
  * cargo test -p kei-buddy --lib: 20/0 unchanged
2026-05-12 14:49:43 +08:00

144 lines
4.2 KiB
Rust

// SPDX-License-Identifier: Apache-2.0
//! kei-buddy binary — 4 subcommands: serve / migrate / webhook-set / webhook-delete.
use clap::{Parser, Subcommand};
#[derive(Parser)]
#[command(
name = "kei-buddy",
about = "KeiBuddy personal-assistant Telegram bot",
version
)]
struct Cli {
#[command(subcommand)]
command: Command,
}
#[derive(Subcommand)]
enum Command {
/// Start the Telegram webhook HTTP listener.
Serve,
/// Apply the SQLite schema (idempotent). Useful before first run.
Migrate,
/// Register a webhook URL with Telegram.
WebhookSet {
/// Public HTTPS URL for the /webhook route.
url: String,
},
/// Delete the registered Telegram webhook (revert to polling).
WebhookDelete,
}
#[tokio::main]
async fn main() -> anyhow::Result<()> {
let cli = Cli::parse();
match cli.command {
Command::Serve => cmd_serve().await,
Command::Migrate => cmd_migrate(),
Command::WebhookSet { url } => cmd_webhook_set(url).await,
Command::WebhookDelete => cmd_webhook_delete().await,
}
}
#[cfg(feature = "serve")]
async fn cmd_serve() -> anyhow::Result<()> {
use kei_buddy::serve::{run_serve, ServeConfig};
let cfg = ServeConfig {
port: port_from_env(),
db_path: db_path_from_env(),
bot_token: require_env("TELEGRAM_BOT_TOKEN")?,
webhook_secret: require_env("TELEGRAM_WEBHOOK_SECRET")?,
allowed_chat_ids: allowed_chat_ids_from_env(),
llm_proxy_url: std::env::var("KEI_BUDDY_LLM_PROXY")
.ok()
.or_else(|| Some("https://api.openai.com".to_string())),
llm_api_key: std::env::var("KEI_BUDDY_LLM_KEY")
.ok()
.or_else(|| std::env::var("OPENAI_API_KEY").ok()),
llm_model: std::env::var("KEI_BUDDY_LLM_MODEL").ok(),
};
run_serve(cfg).await
}
/// Parse `KEI_BUDDY_ALLOWED_CHAT_IDS` CSV → Some(Vec<i64>); empty/missing → None.
fn allowed_chat_ids_from_env() -> Option<Vec<i64>> {
let raw = std::env::var("KEI_BUDDY_ALLOWED_CHAT_IDS").ok()?;
let list: Vec<i64> = raw
.split(',')
.map(|s| s.trim())
.filter(|s| !s.is_empty())
.filter_map(|s| s.parse::<i64>().ok())
.collect();
if list.is_empty() {
None
} else {
Some(list)
}
}
#[cfg(not(feature = "serve"))]
async fn cmd_serve() -> anyhow::Result<()> {
anyhow::bail!("kei-buddy was compiled without the `serve` feature. Rebuild with --features serve.");
}
fn cmd_migrate() -> anyhow::Result<()> {
let path = db_path_from_env();
let _store = kei_buddy::store::SqliteBuddyStore::from_path(&path)?;
init_log();
tracing::info!(path = %path, "schema applied");
Ok(())
}
fn init_log() {
#[cfg(feature = "serve")]
{
use tracing_subscriber::{fmt, EnvFilter};
let _ = fmt().with_env_filter(EnvFilter::from_default_env()).try_init();
}
}
#[cfg(feature = "serve")]
async fn cmd_webhook_set(url: String) -> anyhow::Result<()> {
use kei_buddy::serve_telegram::set_webhook;
let token = require_env("TELEGRAM_BOT_TOKEN")?;
let secret = require_env("TELEGRAM_WEBHOOK_SECRET")?;
let http = reqwest::Client::new();
set_webhook(&token, &url, &secret, &http).await?;
tracing::info!("webhook registered");
Ok(())
}
#[cfg(not(feature = "serve"))]
async fn cmd_webhook_set(_url: String) -> anyhow::Result<()> {
anyhow::bail!("compile with --features serve");
}
#[cfg(feature = "serve")]
async fn cmd_webhook_delete() -> anyhow::Result<()> {
use kei_buddy::serve_telegram::delete_webhook;
let token = require_env("TELEGRAM_BOT_TOKEN")?;
let http = reqwest::Client::new();
delete_webhook(&token, &http).await?;
tracing::info!("webhook deleted");
Ok(())
}
#[cfg(not(feature = "serve"))]
async fn cmd_webhook_delete() -> anyhow::Result<()> {
anyhow::bail!("compile with --features serve");
}
fn require_env(name: &str) -> anyhow::Result<String> {
std::env::var(name).map_err(|_| anyhow::anyhow!("env var {name} is required"))
}
fn port_from_env() -> u16 {
std::env::var("KEI_BUDDY_PORT")
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or(8080)
}
fn db_path_from_env() -> String {
std::env::var("KEI_BUDDY_DB_PATH").unwrap_or_else(|_| "./kei-buddy.db".into())
}