opencode_voice/bridge/
client.rs1use anyhow::{Context, Result};
4use base64::{engine::general_purpose::STANDARD, Engine as _};
5use reqwest::Client;
6use serde_json::json;
7use std::time::Duration;
8
9use crate::approval::types::PermissionReply;
10
11pub struct OpenCodeBridge {
13 client: Client,
14 base_url: String,
15 password: Option<String>,
16}
17
18impl OpenCodeBridge {
19 pub fn new(url: &str, port: u16, password: Option<String>) -> Self {
23 let base_url = format!("{}:{}", url.trim_end_matches('/'), port);
24 OpenCodeBridge {
25 client: Client::new(),
26 base_url,
27 password,
28 }
29 }
30
31 pub fn get_base_url(&self) -> &str {
33 &self.base_url
34 }
35
36 fn auth_header(&self) -> Option<String> {
38 self.password.as_ref().map(|pw| {
39 let credentials = format!(":{}", pw);
40 format!("Basic {}", STANDARD.encode(credentials))
41 })
42 }
43
44 async fn post_json(&self, path: &str, body: serde_json::Value) -> Result<()> {
46 let url = format!("{}{}", self.base_url, path);
47 let mut req = self.client.post(&url).json(&body);
48 if let Some(auth) = self.auth_header() {
49 req = req.header("Authorization", auth);
50 }
51 let resp = req
52 .send()
53 .await
54 .with_context(|| friendly_connection_error(&self.base_url))?;
55 if !resp.status().is_success() {
56 let status = resp.status();
57 let body = resp.text().await.unwrap_or_default();
58 anyhow::bail!("OpenCode API error {}: {}", status, body);
59 }
60 Ok(())
61 }
62
63 pub async fn append_prompt(
65 &self,
66 text: &str,
67 directory: Option<&str>,
68 workspace: Option<&str>,
69 ) -> Result<()> {
70 let mut url = format!("{}/tui/append-prompt", self.base_url);
71 let mut params = Vec::new();
72 if let Some(dir) = directory {
73 params.push(format!("directory={}", urlencoding_encode(dir)));
74 }
75 if let Some(ws) = workspace {
76 params.push(format!("workspace={}", urlencoding_encode(ws)));
77 }
78 if !params.is_empty() {
79 url.push('?');
80 url.push_str(¶ms.join("&"));
81 }
82 let mut req = self.client.post(&url).json(&json!({"text": text}));
83 if let Some(auth) = self.auth_header() {
84 req = req.header("Authorization", auth);
85 }
86 let resp = req
87 .send()
88 .await
89 .with_context(|| friendly_connection_error(&self.base_url))?;
90 if !resp.status().is_success() {
91 let status = resp.status();
92 anyhow::bail!("append_prompt failed: {}", status);
93 }
94 Ok(())
95 }
96
97 pub async fn submit_prompt(&self) -> Result<()> {
99 self.post_json("/tui/submit-prompt", json!({})).await
100 }
101
102 pub async fn is_connected(&self) -> bool {
104 let url = format!("{}/", self.base_url);
105 let client = Client::builder()
106 .timeout(Duration::from_secs(2))
107 .build()
108 .unwrap_or_else(|_| Client::new());
109 let mut req = client.get(&url);
110 if let Some(auth) = self.auth_header() {
111 req = req.header("Authorization", auth);
112 }
113 req.send().await.is_ok()
114 }
115
116 pub async fn reply_permission(
118 &self,
119 id: &str,
120 reply: PermissionReply,
121 message: Option<&str>,
122 ) -> Result<()> {
123 let path = format!("/permission/{}/reply", id);
124 let mut body = json!({"reply": reply});
125 if let Some(msg) = message {
126 body["message"] = json!(msg);
127 }
128 self.post_json(&path, body).await
129 }
130
131 pub async fn reply_question(&self, id: &str, answers: Vec<Vec<String>>) -> Result<()> {
133 let path = format!("/question/{}/reply", id);
134 self.post_json(&path, json!({"answers": answers})).await
135 }
136
137 pub async fn reject_question(&self, id: &str) -> Result<()> {
139 let path = format!("/question/{}/reject", id);
140 self.post_json(&path, json!({})).await
141 }
142}
143
144fn friendly_connection_error(base_url: &str) -> String {
145 format!(
146 "Cannot connect to OpenCode at {}. Make sure OpenCode is running with --port flag: opencode --port <port>",
147 base_url
148 )
149}
150
151fn urlencoding_encode(s: &str) -> String {
152 s.chars()
153 .flat_map(|c| {
154 if c.is_alphanumeric() || matches!(c, '-' | '_' | '.' | '~') {
155 vec![c]
156 } else {
157 format!("%{:02X}", c as u32).chars().collect::<Vec<_>>()
158 }
159 })
160 .collect()
161}