From 401492d9800722ce0c371f5ab2dd0822022d215b Mon Sep 17 00:00:00 2001 From: Mariano Blua Date: Sat, 17 Oct 2026 23:42:04 -0300 Subject: [PATCH 1/2] feat(cli): send Telegram text without attachments (#518) --- docs/reference/cli.md | 34 ++++- src-tauri/src/cli/mod.rs | 3 + src-tauri/src/cli/telegram_send_image.rs | 184 +++++++++++++++++++++++ src-tauri/src/commands/telegram.rs | 11 ++ src-tauri/tests/cli_behavior_contract.rs | 58 +++++++ 5 files changed, 289 insertions(+), 1 deletion(-) diff --git a/docs/reference/cli.md b/docs/reference/cli.md index 09bf8daff..a4c4532d8 100644 --- a/docs/reference/cli.md +++ b/docs/reference/cli.md @@ -24,7 +24,7 @@ agentscommander send --token "$AGENTSCOMMANDER_TOKEN" --root "$AGENTSCOMMANDER_R If token validation keeps failing, restart or respawn the session — live token refresh is not supported. -`list-peers`, `list-peers-lean`, `open-project`, `new-project`, and `telegram-send-image` read disk state directly and do not authorize per token at the CLI. `list-sessions` does not require a token at all. `coding-agent`, `loop`, and `injected-messages` also need no token: they mutate the user-local settings and agents files or config directory, which any local process can already write. `api-client` requires host authority: every subcommand takes the master/root token and rejects session UUIDs. `purge-room` requires the caller to be the identity-verified room orchestrator, and the master/root token does NOT bypass that check (a root token has no room). +`list-peers`, `list-peers-lean`, `open-project`, `new-project`, `telegram-send-image`, and `telegram-send-message` read disk state directly and do not authorize per token at the CLI. `list-sessions` does not require a token at all. `coding-agent`, `loop`, and `injected-messages` also need no token: they mutate the user-local settings and agents files or config directory, which any local process can already write. `api-client` requires host authority: every subcommand takes the master/root token and rejects session UUIDs. `purge-room` requires the caller to be the identity-verified room orchestrator, and the master/root token does NOT bypass that check (a root token has no room). `task-get` and `task-status-set` perform local task operations without a daemon mailbox identity check. They require explicit token/root flags and use the trusted local orchestrator/root-master checks described [below](#task-status-set). @@ -1099,6 +1099,38 @@ Output is JSON for list/create/update/enable/disable. `remove` prints a short me --- +## `telegram-send-message` + +Send plain text through a bot in the executable's effective configuration, without an attachment, room, session, bridge or active GUI. No AC token is required. + +```bash +agentscommander telegram-send-message --text "Build finished" --bot-label "Personal bot" +``` + +| Flag | Required | Description | +|---|---|---| +| `--text` | Yes | Original plain text. Use `--text=-value` for a leading hyphen. | +| `--bot-id` | No | Exact configured bot id; first match if ids are duplicated. | +| `--bot-label` | No | Exact, unique configured bot label. Conflicts with `--bot-id`. | + +With one bot, omit both selectors. Missing bots, unknown selectors, duplicate labels or multiple bots without a selector fail with available ids/labels. Configure bots in Settings/Telegram first. + +Text is sent unchanged, including spaces and newlines, without formatting, trimming, truncation or splitting. Empty/whitespace text fails before configuration is loaded. The conservative local limit is 4096 UTF-16 code units (4096 BMP characters or 2048 supplementary characters); local acceptance does not guarantee Telegram acceptance. + +Configuration follows the executable's existing instance rules: public `AGENTSCOMMANDER_CONFIG_DIR` override, debug-only `AGENTSCOMMANDER_TEST_CONFIG_DIR` override, then executable/location/home defaults. Relative overrides resolve against the current directory. `AGENTSCOMMANDER_ROOT`, `AGENTSCOMMANDER_TOKEN`, `AGENTSCOMMANDER_LOCAL_DIR` and the current room do not select bots. The lenient loader may return defaults for unreadable/corrupt settings, resulting in “No Telegram bots”. This command does not save settings. + +Success exits 0 and prints `Sent: bot=` after Telegram accepts the request; this does not confirm that the recipient read it. Operational failures exit 1, with no stdout receipt and a stderr diagnostic: + +- `Error: --text must not be empty` +- `Error: --text must not exceed 4096 UTF-16 code units` +- `Error: could not initialize Telegram runtime` +- `Error: could not initialize Telegram network` +- `Error: Telegram send failed; delivery may be uncertain` + +Remote error details, text and bot tokens are excluded from the new flow's diagnostics. Configured ids/labels remain visible; command arguments remain visible to the operating system. Failed delivery can be uncertain, and repeating a send is not idempotent. There are no automatic retries. + +--- + ## `telegram-send-image` Send a local file (image or document) to a configured Telegram bot from the terminal, bypassing the GUI. diff --git a/src-tauri/src/cli/mod.rs b/src-tauri/src/cli/mod.rs index 8e36c70eb..1fe3bb903 100644 --- a/src-tauri/src/cli/mod.rs +++ b/src-tauri/src/cli/mod.rs @@ -183,6 +183,8 @@ pub enum Commands { NewProject(new_project::NewProjectArgs), /// Send a local image/file to a configured Telegram bot (no GUI required) TelegramSendImage(telegram_send_image::TelegramSendImageArgs), + /// Send plain text to a configured Telegram bot (no GUI required) + TelegramSendMessage(telegram_send_image::TelegramSendMessageArgs), /// Manage rooms in an AC project #[command(name = "room", alias = "workgroup")] Workgroup(workgroup::WorkgroupArgs), @@ -453,6 +455,7 @@ pub fn handle_cli(cmd: Commands) -> i32 { Commands::OpenProject(args) => open_project::execute(args), Commands::NewProject(args) => new_project::execute(args), Commands::TelegramSendImage(args) => telegram_send_image::execute(args), + Commands::TelegramSendMessage(args) => telegram_send_image::execute_message(args), Commands::Workgroup(args) => workgroup::execute(args), Commands::Team(args) => team::execute(args), Commands::Loop(args) => loop_cmd::execute(args), diff --git a/src-tauri/src/cli/telegram_send_image.rs b/src-tauri/src/cli/telegram_send_image.rs index f4fedd444..512af97bb 100644 --- a/src-tauri/src/cli/telegram_send_image.rs +++ b/src-tauri/src/cli/telegram_send_image.rs @@ -1,3 +1,4 @@ +//! `telegram-send-message` sends original plain text without an attachment. //! `telegram-send-image` CLI verb — sends a local image (or generic file) to //! a configured Telegram bot from an external terminal, bypassing the Tauri //! frontend. Issue #282. @@ -47,6 +48,67 @@ pub struct TelegramSendImageArgs { pub bot_label: Option, } +/// Send plain text through a bot configured in this executable's instance. +#[derive(Args)] +#[command( + after_help = "Text is sent unchanged. Empty text and more than 4096 UTF-16 code units are rejected locally. Select a bot by exact id or unique label; omit selectors only with one configured bot. No room, session, bridge or GUI is required. Use --text=-value for leading hyphens." +)] +pub struct TelegramSendMessageArgs { + /// Original plain text to send (required) + #[arg(long)] + pub text: String, + /// Configured bot id; mutually exclusive with --bot-label + #[arg(long, conflicts_with = "bot_label")] + pub bot_id: Option, + /// Configured bot label (exact, unique match) + #[arg(long)] + pub bot_label: Option, +} + +// The loader and sender are one-shot closures so tests never need real settings or network. +fn send_message_with( + args: TelegramSendMessageArgs, + load_bots: impl FnOnce() -> Vec, + send: impl FnOnce(&TelegramBotConfig, &str) -> Result<(), &'static str>, +) -> Result { + if args.text.trim().is_empty() { + return Err("--text must not be empty".into()); + } + if args.text.encode_utf16().count() > 4096 { + return Err("--text must not exceed 4096 UTF-16 code units".into()); + } + let bots = load_bots(); + let bot = resolve_bot(&bots, args.bot_id.as_deref(), args.bot_label.as_deref())?; + send(bot, &args.text).map_err(str::to_owned)?; + Ok(format!("Sent: bot={}", bot.id)) +} + +fn send_message(bot: &TelegramBotConfig, text: &str) -> Result<(), &'static str> { + let rt = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .map_err(|_| "could not initialize Telegram runtime")?; + let network = crate::network::OutboundNetwork::new() + .map_err(|_| "could not initialize Telegram network")?; + rt.block_on(crate::commands::telegram::perform_send_message( + &network, bot, text, + )) +} + +pub fn execute_message(args: TelegramSendMessageArgs) -> i32 { + let result = send_message_with(args, || load_settings_for_cli().telegram_bots, send_message); + match result { + Ok(receipt) => { + crate::cli_println!("{}", receipt); + 0 + } + Err(error) => { + eprintln!("Error: {}", error); + 1 + } + } +} + /// Pick a bot from `bots` using the CLI selector flags. /// /// Selection rules: @@ -284,4 +346,126 @@ mod tests { help ); } + fn message_args(text: &str) -> TelegramSendMessageArgs { + TelegramSendMessageArgs { + text: text.into(), + bot_id: None, + bot_label: None, + } + } + + #[test] + fn message_rejects_before_loading_or_sending() { + for text in [ + String::new(), + " \n\t".into(), + "a".repeat(4097), + "😀".repeat(2049), + ] { + let result = send_message_with( + message_args(&text), + || panic!("loader called"), + |_, _| panic!("sender called"), + ); + assert!(result.unwrap_err().starts_with("--text must not")); + } + } + + #[test] + fn message_preserves_original_and_accepts_boundaries() { + for text in [ + " hello\nworld ".into(), + "a".repeat(4096), + "😀".repeat(2048), + "-value".into(), + ] { + let mut sent = false; + let result = send_message_with( + message_args(&text), + || vec![bot("one", "Only")], + |selected, original| { + assert_eq!(selected.id, "one"); + assert_eq!(original, text); + sent = true; + Ok(()) + }, + ); + assert!(sent); + assert_eq!(result.unwrap(), "Sent: bot=one"); + } + } + + #[test] + fn message_failures_have_no_receipt_or_private_values() { + for failure in [ + "could not initialize Telegram runtime", + "could not initialize Telegram network", + "Telegram send failed; delivery may be uncertain", + ] { + let result = send_message_with( + message_args("BODY_SENTINEL"), + || vec![bot("one", "Only")], + |_, _| Err(failure), + ); + let error = result.unwrap_err(); + assert_eq!(error, failure); + for private in ["Sent:", "BODY_SENTINEL", "test-token"] { + assert!(!error.contains(private)); + } + } + } + + #[test] + fn message_selector_errors_do_not_send_or_leak_token() { + let mut args = message_args("BODY_SENTINEL"); + args.bot_id = Some("unknown".into()); + let error = send_message_with( + args, + || vec![bot("one", "Only")], + |_, _| panic!("sender called"), + ) + .unwrap_err(); + assert!(error.contains("id=one")); + assert!(!error.contains("test-token")); + assert!(!error.contains("BODY_SENTINEL")); + let bots = vec![bot("same", "First"), bot("same", "Second")]; + assert_eq!( + resolve_bot(&bots, Some("same"), None).unwrap().label, + "First" + ); + } + + #[test] + fn message_parses_selectors_and_leading_hyphen() { + use clap::Parser; + for selector in ["--bot-id", "--bot-label"] { + let parsed = crate::cli::Cli::try_parse_from([ + "ac", + "telegram-send-message", + "--text=-value", + selector, + "one", + ]) + .unwrap(); + let Some(crate::cli::Commands::TelegramSendMessage(args)) = parsed.command else { + panic!("wrong command") + }; + assert_eq!(args.text, "-value"); + assert_eq!( + args.bot_id.as_deref().or(args.bot_label.as_deref()), + Some("one") + ); + } + assert!(crate::cli::Cli::try_parse_from([ + "ac", + "telegram-send-message", + "--text", + "hello", + "--bot-id", + "one", + "--bot-label", + "Only" + ]) + .is_err()); + } } diff --git a/src-tauri/src/commands/telegram.rs b/src-tauri/src/commands/telegram.rs index 49857d5e7..4a2dda542 100644 --- a/src-tauri/src/commands/telegram.rs +++ b/src-tauri/src/commands/telegram.rs @@ -802,6 +802,17 @@ fn truncate_caption(input: &str) -> String { String::from_utf16_lossy(&cut) } +/// Send original plain text, discarding remote errors at the CLI boundary. +pub(crate) async fn perform_send_message( + network: &OutboundNetwork, + bot: &TelegramBotConfig, + text: &str, +) -> Result<(), &'static str> { + crate::telegram::api::send_message(network, &bot.token, bot.chat_id, text) + .await + .map_err(|_| "Telegram send failed; delivery may be uncertain") +} + /// Send a local image (or generic file) through a configured Telegram bot. /// /// v1 contract: diff --git a/src-tauri/tests/cli_behavior_contract.rs b/src-tauri/tests/cli_behavior_contract.rs index e04ed24bd..4dc4804c3 100644 --- a/src-tauri/tests/cli_behavior_contract.rs +++ b/src-tauri/tests/cli_behavior_contract.rs @@ -224,6 +224,7 @@ fn root_help_lists_public_subcommands() { "open-project", "new-project", "telegram-send-image", + "telegram-send-message", // #1614: `room` is the canonical subcommand; `workgroup` remains an // accepted deprecated alias, and D9 hides an alias from help on purpose, // so root help must list `room` and must NOT list `workgroup`. @@ -355,6 +356,10 @@ fn public_subcommand_help_contracts() { (&["open-project", "--help"], &["PATH"]), (&["new-project", "--help"], &["PATH"]), (&["telegram-send-image", "--help"], &["--path", "--caption"]), + ( + &["telegram-send-message", "--help"], + &["--text", "--bot-id", "--bot-label"], + ), (&["room", "--help"], &["list", "activity", "add", "remove"]), (&["room", "list", "--help"], &["--project"]), (&["room", "activity", "--help"], &["--project"]), @@ -842,6 +847,7 @@ fn missing_required_args_contracts() { ], &["harness"], &["telegram-send-image"], + &["telegram-send-message"], ]; for args in cases { @@ -1513,3 +1519,55 @@ fn task_inherited_startup_failure_precedes_handler_json() { assert!(!out.2.is_empty()); assert!(!out.2.contains("authorization_denied")); } + +#[test] +fn telegram_send_message_local_rejections_and_missing_config() { + let tmp = Tmp::new("cli-telegram-message"); + let bin = copy_binary_into(tmp.path()); + let cases = [ + (" \n\t".to_string(), "--text must not be empty"), + ( + "a".repeat(4097), + "--text must not exceed 4096 UTF-16 code units", + ), + ( + "😀".repeat(2049), + "--text must not exceed 4096 UTF-16 code units", + ), + ( + "BODY_SENTINEL".to_string(), + "No Telegram bots are configured", + ), + ("-value".to_string(), "No Telegram bots are configured"), + ]; + for (text, expected) in cases { + let text_arg = format!("--text={text}"); + let mut command = command_for_binary(&bin); + let config = config_dir_for_bin(&bin); + command + .env("AGENTSCOMMANDER_CONFIG_DIR", &config) + .env("AGENTSCOMMANDER_TEST_CONFIG_DIR", &config) + .env_remove("AGENTSCOMMANDER_TOKEN") + .env_remove("AGENTSCOMMANDER_ROOT") + .env_remove("AGENTSCOMMANDER_LOCAL_DIR") + .args(["telegram-send-message", &text_arg]) + .stdin(Stdio::null()); + let out = { + let _guard = spawn_lock(); + command.output().expect("run message rejection") + }; + let stdout = String::from_utf8_lossy(&out.stdout); + let stderr = String::from_utf8_lossy(&out.stderr); + assert_eq!(out.status.code(), Some(1)); + assert_no_stdout_on_error(&stdout); + assert!(stderr.contains(expected), "{stderr}"); + assert!(!stderr.contains("BODY_SENTINEL")); + assert!(!stderr.contains("Sent:")); + let log = config.join("app.log"); + if log.exists() { + assert!(!std::fs::read_to_string(log) + .unwrap() + .contains("BODY_SENTINEL")); + } + } +} From 2168d1526d40491d137e429ac170f3f1b9fab748 Mon Sep 17 00:00:00 2001 From: Mariano Blua Date: Sun, 18 Oct 2026 01:40:41 -0300 Subject: [PATCH 2/2] test(cli): reuse isolated runner for Telegram rejections --- src-tauri/tests/cli_behavior_contract.rs | 25 ++++++++---------------- 1 file changed, 8 insertions(+), 17 deletions(-) diff --git a/src-tauri/tests/cli_behavior_contract.rs b/src-tauri/tests/cli_behavior_contract.rs index 4dc4804c3..58d5f89da 100644 --- a/src-tauri/tests/cli_behavior_contract.rs +++ b/src-tauri/tests/cli_behavior_contract.rs @@ -1524,6 +1524,12 @@ fn task_inherited_startup_failure_precedes_handler_json() { fn telegram_send_message_local_rejections_and_missing_config() { let tmp = Tmp::new("cli-telegram-message"); let bin = copy_binary_into(tmp.path()); + // The existing runner pins the public config override for this unsuffixed + // copy; it takes precedence over inherited debug config and selects no real + // settings or credentials. ROOT/TOKEN/LOCAL_DIR do not select Telegram bots. + assert!(!bin.file_stem().unwrap().to_string_lossy().contains('_')); + let config = config_dir_for_bin(&bin); + assert!(!config.exists()); let cases = [ (" \n\t".to_string(), "--text must not be empty"), ( @@ -1542,23 +1548,8 @@ fn telegram_send_message_local_rejections_and_missing_config() { ]; for (text, expected) in cases { let text_arg = format!("--text={text}"); - let mut command = command_for_binary(&bin); - let config = config_dir_for_bin(&bin); - command - .env("AGENTSCOMMANDER_CONFIG_DIR", &config) - .env("AGENTSCOMMANDER_TEST_CONFIG_DIR", &config) - .env_remove("AGENTSCOMMANDER_TOKEN") - .env_remove("AGENTSCOMMANDER_ROOT") - .env_remove("AGENTSCOMMANDER_LOCAL_DIR") - .args(["telegram-send-message", &text_arg]) - .stdin(Stdio::null()); - let out = { - let _guard = spawn_lock(); - command.output().expect("run message rejection") - }; - let stdout = String::from_utf8_lossy(&out.stdout); - let stderr = String::from_utf8_lossy(&out.stderr); - assert_eq!(out.status.code(), Some(1)); + let (code, stdout, stderr) = run(&bin, &["telegram-send-message", &text_arg]); + assert_eq!(code, Some(1)); assert_no_stdout_on_error(&stdout); assert!(stderr.contains(expected), "{stderr}"); assert!(!stderr.contains("BODY_SENTINEL"));