use std::collections::BTreeSet;
use std::fs;
use std::io::{self, BufRead, IsTerminal, Write};
use std::path::{Path, PathBuf};
use std::process::Command;
use std::time::{Duration, Instant};
use anyhow::{anyhow, bail, Context, Result};
use team_core::compose::Compose;
use crate::managed_bot::ManagedBotClient;
pub fn run(root: &Path, action: BotAction) -> Result<()> {
match action {
BotAction::Setup { force, manager } => setup(root, force, manager),
BotAction::List => list(root),
BotAction::Status => status(root),
}
}
#[derive(Debug)]
pub enum BotAction {
Setup {
force: bool,
manager: Option<String>,
},
List,
Status,
}
fn styled(code: &str, s: &str) -> String {
if io::stdout().is_terminal() {
format!("\x1b[{code}m{s}\x1b[0m")
} else {
s.to_string()
}
}
fn setup(root: &Path, force: bool, only_manager: Option<String>) -> Result<()> {
source_env_files(root);
let compose = super::load(root)?;
let all_managers = all_managers(&compose);
if all_managers.is_empty() {
println!("No managers in compose. Add one to `projects/<id>.yaml` and re-run.");
return Ok(());
}
println!(
"{}",
styled(
"3;90",
"Only Telegram is supported in this version — more interfaces are on the way."
)
);
match choose_setup_mode(only_manager.is_some())? {
SetupMode::Manual => manual_setup(root, &compose, force, only_manager, &all_managers),
SetupMode::Managed => managed_setup(root, &compose, force),
}
}
enum SetupMode {
Manual,
Managed,
}
fn choose_setup_mode(forced_manual: bool) -> Result<SetupMode> {
if forced_manual {
return Ok(SetupMode::Manual);
}
println!("\nHow do you want to set up Telegram bots?");
println!(
" 1) Manual token — paste a BotFather token for each manager {}",
styled("90", "(the original flow)")
);
println!(
" 2) Managed bots — one manager bot spawns a child bot per agent {}",
styled(
"90",
"(needs a manager bot with Managed Bots enabled; the Telegram-side bot creation is rougher)"
)
);
loop {
match prompt("Choose [1/2] (default 1): ")?.trim() {
"1" | "" => return Ok(SetupMode::Manual),
"2" => return Ok(SetupMode::Managed),
other => println!(" `{other}` — please enter 1 or 2."),
}
}
}
fn manual_setup(
root: &Path,
compose: &Compose,
force: bool,
only_manager: Option<String>,
all_managers: &[String],
) -> Result<()> {
let filtered: Vec<String> = match only_manager.as_deref() {
Some(m) => {
if !all_managers.contains(&m.to_string()) {
bail!(
"manager `{m}` not found. Known: {}",
all_managers.join(", ")
);
}
vec![m.to_string()]
}
None => all_managers.to_vec(),
};
let mut configured = 0usize;
let mut skipped = 0usize;
for mgr in &filtered {
match wizard_one(root, compose, mgr, force)? {
WizardOutcome::Configured => configured += 1,
WizardOutcome::AlreadyConfigured => skipped += 1,
WizardOutcome::Cancelled => {}
}
}
println!();
println!(
"Done. {configured} configured, {skipped} already set up.\n\
Run `teamctl up` to launch the bots, then DM each one in Telegram."
);
Ok(())
}
fn default_manager_bot_token_env() -> String {
"TEAMCTL_TG_MANAGER_TOKEN".to_string()
}
fn choose_managed_project(compose: &Compose) -> Result<String> {
let ids: Vec<String> = compose
.projects
.iter()
.map(|p| p.project.id.clone())
.collect();
match ids.as_slice() {
[] => bail!("no projects in compose"),
[only] => Ok(only.clone()),
_ => {
println!("\nWhich project? {}", ids.join(", "));
loop {
let pick = prompt("Project id: ")?.trim().to_string();
if ids.contains(&pick) {
return Ok(pick);
}
println!(" `{pick}` not found. Known: {}", ids.join(", "));
}
}
}
}
fn managed_setup(root: &Path, compose: &Compose, force: bool) -> Result<()> {
let project_id = choose_managed_project(compose)?;
let token_env = compose
.projects
.iter()
.find(|p| p.project.id == project_id)
.and_then(|p| p.telegram())
.and_then(|t| t.manager_bot.as_ref())
.map(|m| m.token_env.clone())
.unwrap_or_else(default_manager_bot_token_env);
let token_set = trimmed_env(&token_env).is_some();
println!("\n── managed bots · project `{project_id}` ──");
let token = if force || !token_set {
println!(
"\nStep — Create your manager bot.\n\
Open https://t.me/BotFather, send /newbot, then enable Managed Bots\n\
on it (/mybots → your bot → Bot Settings → Managed Bots).\n\
BotFather replies with a token like `123456:AAH-…`."
);
let t = prompt_secret("Paste manager bot token: ")?
.trim()
.to_string();
if t.is_empty() || !t.contains(':') {
bail!("invalid token (expected `<id>:<secret>` shape)");
}
t
} else {
println!("\nUsing existing manager bot token from {token_env}.");
trimmed_env(&token_env).unwrap()
};
println!("Verifying with Telegram…");
let me = telegram_get_me(&token)?;
let mgr_username = me.username.as_deref().unwrap_or("your-manager-bot");
println!(
" ✓ @{mgr_username} ({})",
me.first_name.as_deref().unwrap_or("?")
);
upsert_env_var(root, &token_env, &token)?;
upsert_project_manager_bot(compose, &project_id, &token_env)?;
println!(
" ✓ wrote {token_env} into .team/.env\n\
\x20\x20✓ interfaces.telegram.manager_bot on project `{project_id}` is up to date"
);
let rt = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.context("tokio runtime for managed bots")?;
let client = ManagedBotClient::new(token.clone());
let roles: Vec<String> = compose
.projects
.iter()
.find(|p| p.project.id == project_id)
.map(|p| p.managers.keys().cloned().collect())
.unwrap_or_default();
let mut minted = 0usize;
let mut skipped = 0usize;
for role in roles {
let mgr = format!("{project_id}:{role}");
let (child_token_env, child_chats_env) = manager_telegram(compose, &mgr)
.unwrap_or_else(|| (default_token_env(&mgr), default_chats_env(&mgr)));
if !force
&& trimmed_env(&child_token_env).is_some()
&& trimmed_env(&child_chats_env).is_some()
{
println!(" ✓ {role} — child bot already set up (skipped)");
skipped += 1;
continue;
}
println!("\n── child bot · {role} ──");
let suggested = suggested_child_username(&project_id, &role);
let link = ManagedBotClient::creation_link(mgr_username, &suggested);
println!(
"Open this link in Telegram and confirm the new bot:\n {link}\n\
(the bot is handed to @{mgr_username}; we pull its token automatically.)"
);
let updated = rt.block_on(client.poll_for_managed_bot())?;
let child_token = rt.block_on(client.get_managed_bot_token(updated.bot.id))?;
let child_me = telegram_get_me(&child_token)?;
let child_username = child_me.username.as_deref().unwrap_or("your-bot");
println!(
" ✓ minted @{child_username}\n\
Step — Authorize your chat: open @{child_username} and send /start."
);
let chat_id = poll_for_start(&child_token, Duration::from_secs(120))?.to_string();
write_env_file(
root,
&child_token_env,
&child_token,
&child_chats_env,
&chat_id,
)?;
upsert_manager_telegram(compose, &mgr, &child_token_env, &child_chats_env)?;
println!(" ✓ {role}: wrote {child_token_env} + {child_chats_env}, telegram block updated");
minted += 1;
}
println!();
println!(
"Done. {minted} child bot(s) set up, {skipped} already configured, under \
manager bot @{mgr_username}.\n\
Run `teamctl up` to launch them, then DM each one in Telegram."
);
Ok(())
}
fn suggested_child_username(project_id: &str, role: &str) -> String {
let base: String = format!("{project_id}_{role}")
.chars()
.map(|c| if c.is_ascii_alphanumeric() { c } else { '_' })
.collect();
format!("{base}_bot")
}
fn upsert_env_var(root: &Path, key: &str, value: &str) -> Result<()> {
let path = root.join(".env");
let existing = fs::read_to_string(&path).unwrap_or_default();
let mut out = String::new();
let mut wrote = false;
for line in existing.lines() {
let trimmed = line.trim_start();
let k = trimmed
.strip_prefix("export ")
.unwrap_or(trimmed)
.split_once('=')
.map(|(k, _)| k.trim());
if k == Some(key) {
out.push_str(&format!("{key}={value}\n"));
wrote = true;
} else {
out.push_str(line);
out.push('\n');
}
}
if !wrote {
out.push_str(&format!("{key}={value}\n"));
}
fs::write(&path, out).with_context(|| format!("write {}", path.display()))?;
unsafe {
std::env::set_var(key, value);
}
Ok(())
}
fn upsert_project_manager_bot(compose: &Compose, project_id: &str, token_env: &str) -> Result<()> {
let proj_ref = compose
.global
.projects
.iter()
.zip(compose.projects.iter())
.find(|(_, p)| p.project.id == project_id)
.map(|(r, _)| r)
.ok_or_else(|| anyhow!("project `{project_id}` not found in compose"))?;
let path = compose.root.join(&proj_ref.file);
let doc = team_core::yaml_edit::load(&path)?;
let doc = splice_project_manager_bot(doc, token_env)?;
team_core::yaml_edit::save(&doc, &path)?;
Ok(())
}
fn splice_project_manager_bot(
doc: team_core::yaml_edit::Document,
token_env: &str,
) -> Result<team_core::yaml_edit::Document> {
let has_interfaces = doc
.as_mapping()
.and_then(|m| m.get_mapping("interfaces"))
.is_some();
let doc = if has_interfaces {
doc
} else {
let mut source = doc.to_string();
if !source.ends_with('\n') {
source.push('\n');
}
source.push_str("interfaces:\n");
source
.parse()
.context("re-parse YAML after seeding `interfaces:`")?
};
team_core::yaml_edit::set_nested_mapping(
doc,
&["interfaces", "telegram", "manager_bot"],
&[("token_env", token_env)],
)
}
enum WizardOutcome {
Configured,
AlreadyConfigured,
Cancelled,
}
fn resume_prompt(
manager: &str,
token_env: &str,
chats_env: &str,
token_set: bool,
chats_set: bool,
) -> String {
match (token_set, chats_set) {
(true, false) => format!(
"Resume Telegram setup for {manager}? Token already in {token_env}; \
we'll just collect the chat id. [Y/n] "
),
(false, true) => format!(
"Resume Telegram setup for {manager}? Chat id already in {chats_env}; \
we'll just collect the token. [Y/n] "
),
(true, true) => format!(
"Re-run Telegram setup for {manager}? Existing env-var names will be reused. [Y/n] "
),
(false, false) => format!("Set up Telegram bot for {manager}? [Y/n] "),
}
}
fn wizard_one(root: &Path, compose: &Compose, manager: &str, force: bool) -> Result<WizardOutcome> {
let existing = manager_telegram(compose, manager);
let (token_env, chats_env, env_names_chosen_by_user) = match &existing {
Some((t, c)) => (t.clone(), c.clone(), false),
None => (default_token_env(manager), default_chats_env(manager), true),
};
let token_value = trimmed_env(&token_env);
let chats_value = trimmed_env(&chats_env);
let token_set = token_value.is_some();
let chats_set = chats_value.is_some();
if !force && existing.is_some() && token_set && chats_set {
println!("✓ {manager} — already configured (skipped)");
return Ok(WizardOutcome::AlreadyConfigured);
}
println!("\n── {manager} ──");
let prompt_msg = resume_prompt(manager, &token_env, &chats_env, token_set, chats_set);
if !confirm(&prompt_msg, true)? {
println!(" skipped");
return Ok(WizardOutcome::Cancelled);
}
let token = if force || !token_set {
if force && token_set {
println!(
"\nForce re-setup — paste a fresh token from BotFather (existing one in {token_env} will be overwritten):"
);
} else {
println!(
"\nStep — Create a bot.\n\
Open https://t.me/BotFather, send /newbot, follow prompts.\n\
BotFather will reply with a token like `123456:AAH-…`."
);
}
let t = prompt_secret("Paste bot token: ")?.trim().to_string();
if t.is_empty() || !t.contains(':') {
bail!("invalid token (expected `<id>:<secret>` shape)");
}
t
} else {
println!("\nUsing existing token from {token_env}.");
token_value.clone().unwrap()
};
println!("Verifying with Telegram…");
let me = telegram_get_me(&token)?;
let bot_username = me.username.as_deref().unwrap_or("your-bot");
println!(
" ✓ @{bot_username} ({})",
me.first_name.as_deref().unwrap_or("?")
);
let chat_id = if force || !chats_set {
println!(
"\nStep — Authorize your chat.\n\
Open Telegram, search for @{bot_username}, send /start to it."
);
poll_for_start(&token, Duration::from_secs(120))?.to_string()
} else {
println!("Using existing chat id(s) from {chats_env}.");
chats_value.clone().unwrap()
};
let (final_token_env, final_chats_env) = if env_names_chosen_by_user {
println!("\nStep — Pick env-var names (defaults are fine).");
let t = prompt_with_default("Token env var", &token_env)?;
let c = prompt_with_default("Chat-ids env var", &chats_env)?;
(t, c)
} else {
(token_env.clone(), chats_env.clone())
};
write_env_file(root, &final_token_env, &token, &final_chats_env, &chat_id)?;
upsert_manager_telegram(compose, manager, &final_token_env, &final_chats_env)?;
println!(
" ✓ wrote {final_token_env}, {final_chats_env} into .team/.env\n\
\x20\x20✓ telegram block on manager {manager} in projects/<id>.yaml is up to date"
);
Ok(WizardOutcome::Configured)
}
fn trimmed_env(name: &str) -> Option<String> {
std::env::var(name)
.ok()
.map(|v| v.trim().to_string())
.filter(|v| !v.is_empty())
}
fn list(root: &Path) -> Result<()> {
source_env_files(root);
let compose = super::load(root)?;
let mut any = false;
println!(
"{:<24} {:<28} {:<28} {:<8} {:<8}",
"MANAGER", "TOKEN_ENV", "CHATS_ENV", "TOKEN", "CHATS"
);
for proj in &compose.projects {
for (role, agent) in &proj.managers {
if let Some(tg) = agent.telegram() {
any = true;
let mgr = format!("{}:{}", proj.project.id, role);
println!(
"{:<24} {:<28} {:<28} {:<8} {:<8}",
mgr,
tg.bot_token_env,
tg.chat_ids_env,
env_state(&tg.bot_token_env),
env_state(&tg.chat_ids_env),
);
}
}
}
if !any {
println!("(no managers have an `interfaces.telegram` block — try `teamctl bot setup`)");
}
Ok(())
}
fn status(root: &Path) -> Result<()> {
let compose = super::load(root)?;
let prefix = &compose.global.supervisor.tmux_prefix;
let mut any = false;
for proj in &compose.projects {
for (role, agent) in &proj.managers {
if agent.telegram().is_some() {
any = true;
let mgr = format!("{}:{}", proj.project.id, role);
let session = bot_session_name(prefix, &mgr);
let running = Command::new("tmux")
.args(["has-session", "-t", &session])
.output()
.map(|o| o.status.success())
.unwrap_or(false);
println!(
"{:<24} {:<8} {}",
mgr,
if running { "running" } else { "stopped" },
session
);
}
}
}
if !any {
println!("(no managers have an `interfaces.telegram` block — try `teamctl bot setup`)");
}
Ok(())
}
fn env_state(var: &str) -> String {
match std::env::var(var) {
Ok(v) if !v.is_empty() => "set".into(),
_ => "UNSET".into(),
}
}
fn all_managers(compose: &Compose) -> Vec<String> {
let mut out = BTreeSet::new();
for proj in &compose.projects {
for role in proj.managers.keys() {
out.insert(format!("{}:{}", proj.project.id, role));
}
}
out.into_iter().collect()
}
fn manager_telegram(compose: &Compose, manager: &str) -> Option<(String, String)> {
let (project, role) = manager.split_once(':')?;
let proj = compose.projects.iter().find(|p| p.project.id == project)?;
let agent = proj.managers.get(role)?;
let tg = agent.telegram()?;
Some((tg.bot_token_env.clone(), tg.chat_ids_env.clone()))
}
fn default_token_env(manager: &str) -> String {
let role = manager.split_once(':').map(|(_, r)| r).unwrap_or(manager);
format!("TEAMCTL_TG_{}_TOKEN", role.to_uppercase().replace('-', "_"))
}
fn default_chats_env(manager: &str) -> String {
let role = manager.split_once(':').map(|(_, r)| r).unwrap_or(manager);
format!("TEAMCTL_TG_{}_CHATS", role.to_uppercase().replace('-', "_"))
}
pub fn bot_session_name(tmux_prefix: &str, manager: &str) -> String {
let safe = manager.replace(':', "-");
format!("{tmux_prefix}bot-{safe}")
}
fn prompt(msg: &str) -> Result<String> {
print!("{msg}");
io::stdout().flush().ok();
let mut line = String::new();
io::stdin()
.lock()
.read_line(&mut line)
.context("read stdin")?;
Ok(line
.trim_end_matches('\n')
.trim_end_matches('\r')
.to_string())
}
fn capture_line<R: BufRead>(mut reader: R) -> io::Result<String> {
let mut line = String::new();
reader.read_line(&mut line)?;
Ok(line
.trim_end_matches('\n')
.trim_end_matches('\r')
.to_string())
}
fn prompt_secret(msg: &str) -> Result<String> {
print!("{msg}");
io::stdout().flush().ok();
#[cfg(unix)]
{
use std::os::unix::io::AsRawFd;
let fd = io::stdin().as_raw_fd();
let is_tty = unsafe { libc::isatty(fd) } == 1;
if is_tty {
struct RestoreEcho {
fd: i32,
original: libc::termios,
}
impl Drop for RestoreEcho {
fn drop(&mut self) {
unsafe {
libc::tcsetattr(self.fd, libc::TCSANOW, &self.original);
}
}
}
let mut term: libc::termios = unsafe { std::mem::zeroed() };
if unsafe { libc::tcgetattr(fd, &mut term) } != 0 {
return Err(io::Error::last_os_error()).context("tcgetattr (mask token input)");
}
let _restore = RestoreEcho { fd, original: term };
term.c_lflag &= !(libc::ECHO | libc::ICANON);
if unsafe { libc::tcsetattr(fd, libc::TCSANOW, &term) } != 0 {
return Err(io::Error::last_os_error()).context("tcsetattr mask token input");
}
use io::Read;
let mut stdin = io::stdin().lock();
let mut stdout = io::stdout();
let mut raw: Vec<u8> = Vec::new();
let mut byte = [0u8; 1];
loop {
if stdin.read(&mut byte).context("read stdin")? == 0 {
break; }
match byte[0] {
b'\r' | b'\n' => break,
0x7f | 0x08 => {
raw.push(byte[0]);
write!(stdout, "\r{msg}\x1b[J").ok();
stdout.flush().ok();
}
b if b >= 0x20 && b != 0x7f => {
raw.push(b);
write!(stdout, "*").ok();
stdout.flush().ok();
}
_ => raw.push(byte[0]),
}
}
println!();
return process_secret_bytes(&raw);
}
}
capture_line(io::stdin().lock()).context("read stdin")
}
fn process_secret_bytes(bytes: &[u8]) -> Result<String> {
let mut buf: Vec<u8> = Vec::new();
for &b in bytes {
match b {
b'\r' | b'\n' => break,
0x7f | 0x08 => buf.clear(),
b if b >= 0x20 && b != 0x7f => buf.push(b),
_ => {}
}
}
String::from_utf8(buf).map_err(|e| anyhow!("token contains invalid UTF-8: {e}"))
}
fn prompt_with_default(label: &str, default: &str) -> Result<String> {
let raw = prompt(&format!("{label} [{default}]: "))?;
let raw = raw.trim();
Ok(if raw.is_empty() {
default.to_string()
} else {
raw.to_string()
})
}
fn confirm(msg: &str, default_yes: bool) -> Result<bool> {
let raw = prompt(msg)?.trim().to_lowercase();
if raw.is_empty() {
return Ok(default_yes);
}
Ok(matches!(raw.as_str(), "y" | "yes"))
}
fn source_env_files(root: &Path) {
for f in [
root.join(".env"),
root.parent().unwrap_or(root).join(".env"),
] {
if f.is_file() {
if let Ok(raw) = fs::read_to_string(&f) {
for line in raw.lines() {
let line = line.trim();
if line.is_empty() || line.starts_with('#') {
continue;
}
let line = line.strip_prefix("export ").unwrap_or(line);
if let Some((k, v)) = line.split_once('=') {
let v = v.trim().trim_matches('"').trim_matches('\'');
if std::env::var_os(k).is_none() {
unsafe { std::env::set_var(k, v) };
}
}
}
}
}
}
}
fn write_env_file(root: &Path, k1: &str, v1: &str, k2: &str, v2: &str) -> Result<()> {
let path = root.join(".env");
let existing = fs::read_to_string(&path).unwrap_or_default();
let mut out = String::new();
let mut wrote_k1 = false;
let mut wrote_k2 = false;
for line in existing.lines() {
let trimmed = line.trim_start();
let key = trimmed
.strip_prefix("export ")
.unwrap_or(trimmed)
.split_once('=')
.map(|(k, _)| k.trim());
match key {
Some(k) if k == k1 => {
out.push_str(&format!("{k1}={v1}\n"));
wrote_k1 = true;
}
Some(k) if k == k2 => {
out.push_str(&format!("{k2}={v2}\n"));
wrote_k2 = true;
}
_ => {
out.push_str(line);
out.push('\n');
}
}
}
if !wrote_k1 {
out.push_str(&format!("{k1}={v1}\n"));
}
if !wrote_k2 {
out.push_str(&format!("{k2}={v2}\n"));
}
fs::write(&path, out).with_context(|| format!("write {}", path.display()))?;
unsafe {
std::env::set_var(k1, v1);
std::env::set_var(k2, v2);
}
Ok(())
}
fn upsert_manager_telegram(
compose: &Compose,
manager: &str,
token_env: &str,
chats_env: &str,
) -> Result<()> {
let (project_id, role) = manager
.split_once(':')
.ok_or_else(|| anyhow!("manager must be `<project>:<role>`"))?;
let proj_ref = compose
.global
.projects
.iter()
.zip(compose.projects.iter())
.find(|(_, p)| p.project.id == project_id)
.map(|(r, _)| r)
.ok_or_else(|| anyhow!("project `{project_id}` not found in compose"))?;
let path = compose.root.join(&proj_ref.file);
edit_manager_yaml(&path, role, token_env, chats_env)
}
fn edit_manager_yaml(path: &Path, role: &str, token_env: &str, chats_env: &str) -> Result<()> {
let doc = team_core::yaml_edit::load(path)?;
let root = doc
.as_mapping()
.ok_or_else(|| anyhow!("root of {} is not a mapping", path.display()))?;
let managers = root
.get_mapping("managers")
.ok_or_else(|| anyhow!("`managers:` block missing in {}", path.display()))?;
if managers.get_mapping(role).is_none() {
return Err(anyhow!("manager `{role}` missing in {}", path.display()));
}
let doc = team_core::yaml_edit::set_nested_mapping(
doc,
&["managers", role, "interfaces", "telegram"],
&[("bot_token_env", token_env), ("chat_ids_env", chats_env)],
)?;
team_core::yaml_edit::save(&doc, path)?;
Ok(())
}
#[derive(Debug)]
struct TelegramUser {
username: Option<String>,
first_name: Option<String>,
}
fn telegram_get_me(token: &str) -> Result<TelegramUser> {
let url = format!("https://api.telegram.org/bot{token}/getMe");
let body = curl_get(&url)?;
let v: serde_json::Value = serde_json::from_str(&body).context("parse getMe response")?;
if v.get("ok").and_then(|x| x.as_bool()) != Some(true) {
let desc = v
.get("description")
.and_then(|x| x.as_str())
.unwrap_or("(no description)");
bail!("Telegram rejected token: {desc}");
}
let r = v
.get("result")
.ok_or_else(|| anyhow!("getMe: no `result`"))?;
Ok(TelegramUser {
username: r
.get("username")
.and_then(|x| x.as_str())
.map(str::to_owned),
first_name: r
.get("first_name")
.and_then(|x| x.as_str())
.map(str::to_owned),
})
}
fn poll_for_start(token: &str, deadline: Duration) -> Result<i64> {
let started = Instant::now();
let mut offset: i64 = 0;
print!(" waiting for /start ");
io::stdout().flush().ok();
while started.elapsed() < deadline {
let url =
format!("https://api.telegram.org/bot{token}/getUpdates?timeout=10&offset={offset}");
let body = match curl_get(&url) {
Ok(b) => b,
Err(_) => {
print!(".");
io::stdout().flush().ok();
std::thread::sleep(Duration::from_secs(1));
continue;
}
};
let v: serde_json::Value = match serde_json::from_str(&body) {
Ok(v) => v,
Err(_) => continue,
};
if v.get("ok").and_then(|x| x.as_bool()) != Some(true) {
print!(".");
io::stdout().flush().ok();
std::thread::sleep(Duration::from_secs(1));
continue;
}
let updates = v
.get("result")
.and_then(|r| r.as_array())
.cloned()
.unwrap_or_default();
for u in &updates {
if let Some(uid) = u.get("update_id").and_then(|x| x.as_i64()) {
offset = offset.max(uid + 1);
}
let text = u
.get("message")
.and_then(|m| m.get("text"))
.and_then(|x| x.as_str())
.unwrap_or("");
if text.trim_start().starts_with("/start") {
if let Some(cid) = u
.get("message")
.and_then(|m| m.get("chat"))
.and_then(|c| c.get("id"))
.and_then(|x| x.as_i64())
{
println!();
return Ok(cid);
}
}
}
print!(".");
io::stdout().flush().ok();
}
println!();
bail!("timed out waiting for /start (2 minutes)")
}
fn curl_get(url: &str) -> Result<String> {
let out = Command::new("curl")
.args(["-sS", "--max-time", "15", url])
.output()
.context("run curl (is curl installed?)")?;
if !out.status.success() {
let err = String::from_utf8_lossy(&out.stderr);
bail!("curl failed: {}", err.trim());
}
Ok(String::from_utf8_lossy(&out.stdout).into_owned())
}
pub struct BotSpec {
pub manager: String,
pub display_name: Option<String>,
pub session: String,
pub mailbox: PathBuf,
pub token_env: String,
pub chats_env: String,
pub tmux_prefix: String,
pub stt: Option<BotSttSpec>,
}
pub struct BotSttSpec {
pub provider: String,
pub api_key_env: String,
pub model: String,
pub language: Option<String>,
}
pub fn bot_specs(compose: &Compose) -> Vec<BotSpec> {
let prefix = &compose.global.supervisor.tmux_prefix;
let mailbox = compose.root.join(&compose.global.broker.path);
let mut out = Vec::new();
for proj in &compose.projects {
for (role, agent) in &proj.managers {
if let Some(tg) = agent.telegram() {
let mgr = format!("{}:{}", proj.project.id, role);
let stt = tg.speech_to_text.as_ref().map(|s| BotSttSpec {
provider: s.provider.clone(),
api_key_env: s.api_key_env.clone(),
model: s.model.clone(),
language: s.language.clone(),
});
out.push(BotSpec {
session: bot_session_name(prefix, &mgr),
mailbox: mailbox.clone(),
token_env: tg.bot_token_env.clone(),
chats_env: tg.chat_ids_env.clone(),
manager: mgr,
display_name: agent.display_name.clone(),
tmux_prefix: prefix.clone(),
stt,
});
}
}
}
out
}
pub fn up_one(spec: &BotSpec, team_bot_bin: &Path, root: &Path) -> Result<bool> {
let token = match std::env::var(&spec.token_env) {
Ok(v) if !v.is_empty() => v,
_ => {
eprintln!(
"skip · bot {} ({} unset — run `teamctl bot setup`)",
spec.session, spec.token_env
);
return Ok(false);
}
};
let chats = std::env::var(&spec.chats_env).unwrap_or_default();
let already = Command::new("tmux")
.args(["has-session", "-t", &spec.session])
.output()
.map(|o| o.status.success())
.unwrap_or(false);
if already {
return Ok(true);
}
let stt_flags = match &spec.stt {
Some(stt) => match std::env::var(&stt.api_key_env) {
Ok(v) if !v.is_empty() => {
let mut s = format!(
" --stt-provider {p} --stt-api-key {k} --stt-model {m}",
p = shlex_quote(&stt.provider),
k = shlex_quote(&v),
m = shlex_quote(&stt.model),
);
if let Some(lang) = &stt.language {
s.push_str(&format!(" --stt-language {l}", l = shlex_quote(lang)));
}
s
}
_ => {
eprintln!(
"skip voice · bot {} ({} unset — voice messages will be ignored)",
spec.session, stt.api_key_env
);
String::new()
}
},
None => String::new(),
};
let display_name_flag = match &spec.display_name {
Some(dn) => format!(" --manager-display-name {}", shlex_quote(dn)),
None => String::new(),
};
let cmd = format!(
"{bin} --mailbox {mb} --token {tok} --authorized-chat-ids {chats} \
--manager {mgr} --tmux-prefix {prefix}{dn}{stt}",
bin = shlex_quote(&team_bot_bin.display().to_string()),
mb = shlex_quote(&spec.mailbox.display().to_string()),
tok = shlex_quote(&token),
chats = shlex_quote(&chats),
mgr = shlex_quote(&spec.manager),
prefix = shlex_quote(&spec.tmux_prefix),
dn = display_name_flag,
stt = stt_flags,
);
let status = Command::new("tmux")
.args([
"new-session",
"-d",
"-x",
"200",
"-y",
"50",
"-s",
&spec.session,
"-c",
&root.display().to_string(),
"sh",
"-c",
&cmd,
])
.status()
.context("spawn tmux new-session for bot")?;
anyhow::ensure!(status.success(), "tmux new-session exited {status}");
Ok(true)
}
pub fn down_one(spec: &BotSpec) {
let _ = Command::new("tmux")
.args(["kill-session", "-t", &spec.session])
.status();
}
pub fn team_bot_bin() -> PathBuf {
if let Ok(p) = std::env::var("TEAMCTL_TEAM_BOT") {
return PathBuf::from(p);
}
if let Ok(exe) = std::env::current_exe() {
if let Some(dir) = exe.parent() {
let c = dir.join(if cfg!(windows) {
"team-bot.exe"
} else {
"team-bot"
});
if c.exists() {
return c;
}
}
}
PathBuf::from("team-bot")
}
fn shlex_quote(s: &str) -> String {
shlex::try_quote(s)
.map(|c| c.into_owned())
.unwrap_or_else(|_| format!("'{}'", s.replace('\'', "'\\''")))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn default_token_env_strips_project() {
assert_eq!(
default_token_env("teamctl:eng_lead"),
"TEAMCTL_TG_ENG_LEAD_TOKEN"
);
assert_eq!(default_token_env("startup:pm"), "TEAMCTL_TG_PM_TOKEN");
}
#[test]
fn default_chats_env_matches_token_shape() {
assert_eq!(default_chats_env("p:role-x"), "TEAMCTL_TG_ROLE_X_CHATS");
}
#[test]
fn capture_line_strips_only_the_trailing_newline() {
assert_eq!(
capture_line(&b"123456:AAH-abcDEF\n"[..]).unwrap(),
"123456:AAH-abcDEF"
);
}
#[test]
fn capture_line_strips_crlf() {
assert_eq!(
capture_line(&b"123:tok-_x.Y\r\n"[..]).unwrap(),
"123:tok-_x.Y"
);
}
#[test]
fn capture_line_preserves_value_bytes_including_inner_spaces() {
assert_eq!(
capture_line(&b" 12:AA b:c \n"[..]).unwrap(),
" 12:AA b:c "
);
}
#[test]
fn capture_line_handles_eof_without_newline() {
assert_eq!(
capture_line(&b"123:no-newline"[..]).unwrap(),
"123:no-newline"
);
}
#[test]
fn capture_line_matches_prompt_capture_semantics() {
let raw = "999:Zz-_.\r\n";
let via_capture = capture_line(raw.as_bytes()).unwrap();
let via_prompt_logic = raw
.trim_end_matches('\n')
.trim_end_matches('\r')
.to_string();
assert_eq!(via_capture, via_prompt_logic);
assert_eq!(via_capture, "999:Zz-_.");
}
#[test]
fn bot_session_name_is_stable_and_unique() {
assert_eq!(bot_session_name("t-", "teamctl:pm"), "t-bot-teamctl-pm");
assert_eq!(
bot_session_name("a-", "startup:eng_lead"),
"a-bot-startup-eng_lead"
);
}
#[test]
fn write_env_file_replaces_in_place() {
let dir = tempfile::tempdir().unwrap();
let env_path = dir.path().join(".env");
std::fs::write(
&env_path,
"EXISTING=value\nTEAMCTL_TG_PM_TOKEN=oldtok\nKEEP=me\n",
)
.unwrap();
write_env_file(
dir.path(),
"TEAMCTL_TG_PM_TOKEN",
"newtok",
"TEAMCTL_TG_PM_CHATS",
"12345",
)
.unwrap();
let got = std::fs::read_to_string(&env_path).unwrap();
assert!(got.contains("EXISTING=value"));
assert!(got.contains("KEEP=me"));
assert!(got.contains("TEAMCTL_TG_PM_TOKEN=newtok"));
assert!(!got.contains("oldtok"));
assert!(got.contains("TEAMCTL_TG_PM_CHATS=12345"));
}
#[test]
fn upsert_manager_telegram_succeeds_for_consecutive_managers_in_same_project() {
let dir = tempfile::tempdir().unwrap();
let team = dir.path().join(".team");
std::fs::create_dir_all(team.join("projects")).unwrap();
std::fs::create_dir_all(team.join("roles")).unwrap();
std::fs::write(
team.join("team-compose.yaml"),
"version: 2\n\
broker:\n type: sqlite\n path: state/mailbox.db\n\
supervisor:\n type: tmux\n tmux_prefix: a-\n\
projects:\n - file: projects/p.yaml\n",
)
.unwrap();
std::fs::write(
team.join("projects/p.yaml"),
"version: 2\n\
project:\n id: p\n name: P\n cwd: .\n\
managers:\n\
\x20\x20alpha:\n runtime: claude-code\n role_prompt: roles/alpha.md\n\
\x20\x20beta:\n runtime: claude-code\n role_prompt: roles/beta.md\n",
)
.unwrap();
std::fs::write(team.join("roles/alpha.md"), "alpha\n").unwrap();
std::fs::write(team.join("roles/beta.md"), "beta\n").unwrap();
let compose = Compose::load(&team).expect("compose loads cleanly");
upsert_manager_telegram(&compose, "p:alpha", "ALPHA_TOKEN", "ALPHA_CHATS")
.expect("first manager upsert succeeds");
upsert_manager_telegram(&compose, "p:beta", "BETA_TOKEN", "BETA_CHATS")
.expect("second manager upsert in same project must succeed");
let got = std::fs::read_to_string(team.join("projects/p.yaml")).unwrap();
assert!(
got.contains("ALPHA_TOKEN") && got.contains("ALPHA_CHATS"),
"first manager's telegram block must survive the second write:\n{got}"
);
assert!(
got.contains("BETA_TOKEN") && got.contains("BETA_CHATS"),
"second manager's telegram block must land:\n{got}"
);
}
#[test]
fn fresh_essentials_team_bot_setup_yields_resolvable_bridge_spec() {
let dir = tempfile::tempdir().unwrap();
let team = dir.path().join(".team");
let ess = crate::cmd::init::TEMPLATES
.iter()
.find(|t| t.key == "essentials")
.expect("essentials template present");
for (rel, content) in ess.entries() {
let body = content
.replace("{{project_id}}", "main")
.replace("{{project_name}}", "Main");
let path = team.join(rel);
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
std::fs::write(&path, body).unwrap();
}
let compose =
Compose::load(&team).expect("freshly-scaffolded essentials compose must load");
assert_eq!(
all_managers(&compose),
vec!["ops:ops".to_string()],
"essentials must expose exactly ops:ops to `bot setup`"
);
let (tok_env, chats_env) = manager_telegram(&compose, "ops:ops")
.expect("essentials pre-wires ops:ops's telegram env-var names");
assert_eq!(tok_env, "TEAMCTL_TG_OPS_TOKEN");
assert_eq!(chats_env, "TEAMCTL_TG_OPS_CHATS");
write_env_file(&team, &tok_env, "123456:FAKE-TOKEN", &chats_env, "99001122").unwrap();
upsert_manager_telegram(&compose, "ops:ops", &tok_env, &chats_env).unwrap();
let compose = Compose::load(&team).expect("compose reloads after bot setup");
let specs = bot_specs(&compose);
assert_eq!(specs.len(), 1, "exactly one bot spec (ops:ops) expected");
let spec = &specs[0];
assert_eq!(spec.manager, "ops:ops");
assert_eq!(spec.token_env, "TEAMCTL_TG_OPS_TOKEN");
assert_eq!(spec.chats_env, "TEAMCTL_TG_OPS_CHATS");
let env_body = std::fs::read_to_string(team.join(".env")).unwrap();
assert!(
env_body.contains("TEAMCTL_TG_OPS_TOKEN=123456:FAKE-TOKEN"),
"the bot token the bridge resolves must be persisted to .team/.env:\n{env_body}"
);
assert!(
env_body.contains("TEAMCTL_TG_OPS_CHATS=99001122"),
"the authorized chat id must be persisted to .team/.env:\n{env_body}"
);
let ops_yaml = std::fs::read_to_string(team.join("projects/ops.yaml")).unwrap();
let parsed: team_core::compose::Project = serde_yaml::from_str(&ops_yaml).unwrap();
let tg = parsed
.managers
.get("ops")
.and_then(|a| a.telegram())
.expect("ops:ops telegram must survive the upsert and re-parse");
assert_eq!(tg.bot_token_env, "TEAMCTL_TG_OPS_TOKEN");
assert_eq!(tg.chat_ids_env, "TEAMCTL_TG_OPS_CHATS");
}
#[test]
fn edit_manager_yaml_inserts_interfaces_telegram_block() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("p.yaml");
std::fs::write(
&path,
"version: 2\n\
project:\n id: p\n name: P\n cwd: ..\n\
managers:\n pm:\n runtime: claude-code\n role_prompt: roles/pm.md\n",
)
.unwrap();
edit_manager_yaml(&path, "pm", "PM_TOKEN", "PM_CHATS").unwrap();
let got = std::fs::read_to_string(&path).unwrap();
assert!(
got.contains("interfaces:"),
"missing interfaces block:\n{got}"
);
assert!(got.contains("telegram:"));
assert!(got.contains("bot_token_env: PM_TOKEN"));
assert!(got.contains("chat_ids_env: PM_CHATS"));
let parsed: team_core::compose::Project = serde_yaml::from_str(&got).unwrap();
let tg = parsed
.managers
.get("pm")
.and_then(|a| a.telegram())
.expect("telegram parses out");
assert_eq!(tg.bot_token_env, "PM_TOKEN");
edit_manager_yaml(&path, "pm", "PM_TOKEN_2", "PM_CHATS_2").unwrap();
let got2 = std::fs::read_to_string(&path).unwrap();
assert_eq!(got2.matches("telegram:").count(), 1);
assert_eq!(got2.matches("interfaces:").count(), 1);
assert!(got2.contains("PM_TOKEN_2"));
assert!(!got2.contains("PM_TOKEN\n"));
}
#[test]
fn forced_manual_skips_the_fork() {
assert!(matches!(
choose_setup_mode(true).unwrap(),
SetupMode::Manual
));
}
fn manager_bot_token_env(yaml: &str) -> String {
let p: team_core::compose::Project = serde_yaml::from_str(yaml).unwrap();
p.telegram()
.and_then(|t| t.manager_bot.as_ref())
.expect("manager_bot parses out")
.token_env
.clone()
}
#[test]
fn splice_manager_bot_seeds_interfaces_when_absent() {
let src = "version: 2\n\
project:\n id: p\n name: P\n cwd: ..\n\
managers:\n pm:\n runtime: claude-code\n role_prompt: roles/pm.md\n";
let doc: team_core::yaml_edit::Document = src.parse().unwrap();
let got = splice_project_manager_bot(doc, "TEAMCTL_TG_MANAGER_TOKEN")
.unwrap()
.to_string();
assert!(got.contains("interfaces:"), "missing interfaces:\n{got}");
assert!(got.contains("manager_bot:"), "missing manager_bot:\n{got}");
assert_eq!(manager_bot_token_env(&got), "TEAMCTL_TG_MANAGER_TOKEN");
assert_eq!(
got.matches("interfaces:").count(),
1,
"dup interfaces:\n{got}"
);
}
#[test]
fn splice_manager_bot_preserves_trailing_comment_on_insert() {
let src = "version: 2\n\
project:\n id: p\n name: P\n cwd: ..\n\
managers:\n pm:\n runtime: claude-code\n role_prompt: roles/pm.md\n\
# keep this trailing comment\n";
let doc: team_core::yaml_edit::Document = src.parse().unwrap();
let got = splice_project_manager_bot(doc, "TEAMCTL_TG_MANAGER_TOKEN")
.unwrap()
.to_string();
assert!(
got.contains("# keep this trailing comment"),
"trailing comment eaten on insert (#319):\n{got}"
);
}
#[ignore = "blocked on #319: yaml_edit::block_end_after eats the file-final \
trailing comment on a leaf replace. The managed-bots wizard's own \
writes don't create this shape (seeding appends `interfaces:` last, \
pushing comments before it), so typical usage is safe — but a \
--force re-run after an operator hand-adds a trailing comment hits \
it. Un-ignore when #319 lands; this is its ready regression guard."]
#[test]
fn splice_manager_bot_preserves_file_final_comment_on_replace() {
let src = "version: 2\n\
project:\n id: p\n name: P\n cwd: ..\n\
interfaces:\n telegram:\n manager_bot:\n token_env: OLD_TOKEN_ENV\n\
# final trailing comment\n";
let doc: team_core::yaml_edit::Document = src.parse().unwrap();
let got = splice_project_manager_bot(doc, "NEW_TOKEN_ENV")
.unwrap()
.to_string();
assert_eq!(manager_bot_token_env(&got), "NEW_TOKEN_ENV");
assert!(
got.contains("# final trailing comment"),
"file-final trailing comment eaten on replace (#319 fired for managed-bots path):\n{got}"
);
}
#[test]
fn managed_bot_write_validates_clean_and_round_trips() {
let dir = tempfile::tempdir().unwrap();
let team = dir.path().join(".team");
let ess = crate::cmd::init::TEMPLATES
.iter()
.find(|t| t.key == "essentials")
.expect("essentials template present");
for (rel, content) in ess.entries() {
let body = content
.replace("{{project_id}}", "main")
.replace("{{project_name}}", "Main");
let path = team.join(rel);
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
std::fs::write(&path, body).unwrap();
}
let compose = Compose::load(&team).expect("essentials compose loads");
let project_id = compose.projects[0].project.id.clone();
upsert_project_manager_bot(&compose, &project_id, "TEAMCTL_TG_MANAGER_TOKEN")
.expect("managed_bot write succeeds");
let compose = Compose::load(&team).expect("compose reloads after managed_bot write");
let errs = team_core::validate::validate(&compose);
assert!(
errs.is_empty(),
"managed-bots YAML must validate clean: {errs:?}"
);
let mb = compose.projects[0]
.telegram()
.and_then(|t| t.manager_bot.as_ref())
.expect("manager_bot round-trips through the schema");
assert_eq!(mb.token_env, "TEAMCTL_TG_MANAGER_TOKEN");
}
#[test]
fn resume_prompt_nothing_set_reads_as_first_time_setup() {
let msg = resume_prompt("p:pm", "TOK_ENV", "CHATS_ENV", false, false);
assert!(
msg.contains("Set up Telegram bot for"),
"first-time wording expected:\n{msg}"
);
assert!(msg.contains("p:pm"), "manager name expected:\n{msg}");
}
#[test]
fn resume_prompt_token_only_collects_chat_id() {
let msg = resume_prompt("p:pm", "TOK_ENV", "CHATS_ENV", true, false);
assert!(
msg.contains("Token already in"),
"token-present wording expected:\n{msg}"
);
assert!(
msg.contains("TOK_ENV"),
"the token env-var name expected:\n{msg}"
);
assert!(
msg.contains("collect the chat id"),
"should promise to collect the missing chat id:\n{msg}"
);
}
#[test]
fn resume_prompt_chats_only_collects_token() {
let msg = resume_prompt("p:pm", "TOK_ENV", "CHATS_ENV", false, true);
assert!(
msg.contains("Chat id already in"),
"chat-id-present wording expected:\n{msg}"
);
assert!(
msg.contains("CHATS_ENV"),
"the chats env-var name expected:\n{msg}"
);
assert!(
msg.contains("collect the token"),
"should promise to collect the missing token:\n{msg}"
);
}
#[test]
fn resume_prompt_both_set_reads_as_re_run() {
let msg = resume_prompt("p:pm", "TOK_ENV", "CHATS_ENV", true, true);
assert!(
msg.contains("Re-run Telegram setup"),
"re-run wording expected:\n{msg}"
);
assert!(
msg.contains("Existing env-var names"),
"should note the existing env-var names are reused:\n{msg}"
);
}
#[test]
fn process_secret_bytes_keeps_plain_ascii_verbatim() {
assert_eq!(
process_secret_bytes(b"123456:abcDEF").unwrap(),
"123456:abcDEF"
);
}
#[test]
fn process_secret_bytes_terminates_on_cr_or_lf() {
assert_eq!(process_secret_bytes(b"abc\r").unwrap(), "abc");
assert_eq!(process_secret_bytes(b"abc\n").unwrap(), "abc");
}
#[test]
fn process_secret_bytes_ignores_bytes_after_terminator() {
assert_eq!(process_secret_bytes(b"abc\rxyz").unwrap(), "abc");
}
#[test]
fn process_secret_bytes_backspace_clears_whole_buffer() {
assert_eq!(process_secret_bytes(b"abc\x7fxyz").unwrap(), "xyz");
assert_eq!(process_secret_bytes(b"abc\x08xyz").unwrap(), "xyz");
}
#[test]
fn process_secret_bytes_drops_other_control_bytes() {
assert_eq!(process_secret_bytes(b"a\x01b\x1bc").unwrap(), "abc");
}
#[test]
fn process_secret_bytes_round_trips_pasted_multibyte_utf8() {
let original = "tökèn-✓-naïve";
assert_eq!(process_secret_bytes(original.as_bytes()).unwrap(), original);
}
#[test]
fn process_secret_bytes_empty_input_yields_empty_string() {
assert_eq!(process_secret_bytes(b"").unwrap(), "");
}
#[test]
fn process_secret_bytes_rejects_invalid_utf8() {
assert!(process_secret_bytes(&[b'a', 0xff]).is_err());
}
}