Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 33 additions & 1 deletion docs/reference/cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).

Expand Down Expand Up @@ -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=<id>` 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.
Expand Down
3 changes: 3 additions & 0 deletions src-tauri/src/cli/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down Expand Up @@ -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),
Expand Down
184 changes: 184 additions & 0 deletions src-tauri/src/cli/telegram_send_image.rs
Original file line number Diff line number Diff line change
@@ -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.
Expand Down Expand Up @@ -47,6 +48,67 @@ pub struct TelegramSendImageArgs {
pub bot_label: Option<String>,
}

/// 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<String>,
/// Configured bot label (exact, unique match)
#[arg(long)]
pub bot_label: Option<String>,
}

// 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<TelegramBotConfig>,
send: impl FnOnce(&TelegramBotConfig, &str) -> Result<(), &'static str>,
) -> Result<String, String> {
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:
Expand Down Expand Up @@ -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());
}
}
11 changes: 11 additions & 0 deletions src-tauri/src/commands/telegram.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
49 changes: 49 additions & 0 deletions src-tauri/tests/cli_behavior_contract.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
Expand Down Expand Up @@ -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"]),
Expand Down Expand Up @@ -842,6 +847,7 @@ fn missing_required_args_contracts() {
],
&["harness"],
&["telegram-send-image"],
&["telegram-send-message"],
];

for args in cases {
Expand Down Expand Up @@ -1513,3 +1519,46 @@ 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());
// 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"),
(
"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 (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"));
assert!(!stderr.contains("Sent:"));
let log = config.join("app.log");
if log.exists() {
assert!(!std::fs::read_to_string(log)
.unwrap()
.contains("BODY_SENTINEL"));
}
}
}
Loading