@pheem49/mint 1.6.1 → 1.6.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.github/workflows/ci.yml +14 -0
- package/Cargo.lock +3 -3
- package/Cargo.toml +1 -1
- package/bin/mint +0 -0
- package/package.json +11 -3
- package/src/renderer/index-web.html +1 -1
- package/src/renderer/index.html +1 -1
- package/src/renderer/src/components/DashboardSidebar.tsx +2 -2
- package/src/renderer/src/components/MintDashboard.tsx +1 -1
- package/src/renderer/src-web/components/ChatPanel.tsx +1 -1
- package/src/renderer/src-web/components/DashboardSidebar.tsx +2 -2
- package/src/renderer/src-web/components/MintDashboard.tsx +1 -1
- package/src-tauri/Cargo.toml +29 -0
- package/src-tauri/build.rs +3 -0
- package/src-tauri/capabilities/main.json +7 -0
- package/src-tauri/gen/schemas/acl-manifests.json +1 -0
- package/src-tauri/gen/schemas/capabilities.json +1 -0
- package/src-tauri/gen/schemas/desktop-schema.json +2412 -0
- package/src-tauri/gen/schemas/linux-schema.json +2412 -0
- package/src-tauri/src/browser.rs +141 -0
- package/src-tauri/src/desktop.rs +392 -0
- package/src-tauri/src/discord_rpc.rs +99 -0
- package/src-tauri/src/events.rs +70 -0
- package/src-tauri/src/headless.rs +222 -0
- package/src-tauri/src/integrations.rs +126 -0
- package/src-tauri/src/lib.rs +1033 -0
- package/src-tauri/src/main.rs +3 -0
- package/src-tauri/src/plugins.rs +16 -0
- package/src-tauri/src/proactive.rs +254 -0
- package/src-tauri/src/system.rs +250 -0
- package/src-tauri/src/updater.rs +148 -0
- package/src-tauri/src/webhooks.rs +255 -0
- package/src-tauri/src/workflows.rs +70 -0
- package/src-tauri/tauri.conf.json +48 -0
- package/tsconfig.json +1 -1
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
use std::collections::BTreeMap;
|
|
2
|
+
|
|
3
|
+
use base64::{Engine as _, engine::general_purpose::STANDARD};
|
|
4
|
+
use hmac::{Hmac, Mac};
|
|
5
|
+
use mint_core::{ChatRequest, load_config, orchestrate_chat};
|
|
6
|
+
use serde_json::{Value, json};
|
|
7
|
+
use sha2::Sha256;
|
|
8
|
+
use tokio::{
|
|
9
|
+
io::{AsyncReadExt, AsyncWriteExt},
|
|
10
|
+
net::{TcpListener, TcpStream},
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
type HmacSha256 = Hmac<Sha256>;
|
|
14
|
+
|
|
15
|
+
pub fn start_webhooks() {
|
|
16
|
+
tauri::async_runtime::spawn(async {
|
|
17
|
+
let _ = serve(3000, Service::Line).await;
|
|
18
|
+
});
|
|
19
|
+
tauri::async_runtime::spawn(async {
|
|
20
|
+
let _ = serve(3001, Service::Whatsapp).await;
|
|
21
|
+
});
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
#[derive(Clone, Copy)]
|
|
25
|
+
enum Service {
|
|
26
|
+
Line,
|
|
27
|
+
Whatsapp,
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
async fn serve(port: u16, service: Service) -> Result<(), String> {
|
|
31
|
+
let listener = TcpListener::bind(("127.0.0.1", port))
|
|
32
|
+
.await
|
|
33
|
+
.map_err(|error| error.to_string())?;
|
|
34
|
+
loop {
|
|
35
|
+
let (stream, _) = listener.accept().await.map_err(|error| error.to_string())?;
|
|
36
|
+
tauri::async_runtime::spawn(handle(stream, service));
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
async fn handle(mut stream: TcpStream, service: Service) {
|
|
41
|
+
let Ok(request) = read_request(&mut stream).await else {
|
|
42
|
+
return;
|
|
43
|
+
};
|
|
44
|
+
let response = match service {
|
|
45
|
+
Service::Line => handle_line(request).await,
|
|
46
|
+
Service::Whatsapp => handle_whatsapp(request).await,
|
|
47
|
+
};
|
|
48
|
+
let (status, body) = response.unwrap_or_else(|error| ("500 Internal Server Error", error));
|
|
49
|
+
let _ = stream.write_all(format!(
|
|
50
|
+
"HTTP/1.1 {status}\r\nContent-Type: text/plain\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",
|
|
51
|
+
body.len()
|
|
52
|
+
).as_bytes()).await;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
async fn handle_line(request: HttpRequest) -> Result<(&'static str, String), String> {
|
|
56
|
+
if request.method != "POST" || request.path != "/callback" {
|
|
57
|
+
return Ok(("404 Not Found", "not found".into()));
|
|
58
|
+
}
|
|
59
|
+
let config = load_config().map_err(|error| error.to_string())?;
|
|
60
|
+
if !extra_bool(&config.extra, "enableLineBridge") {
|
|
61
|
+
return Ok(("503 Service Unavailable", "LINE bridge disabled".into()));
|
|
62
|
+
}
|
|
63
|
+
let secret = extra(&config.extra, "lineChannelSecret").ok_or("missing lineChannelSecret")?;
|
|
64
|
+
let signature = request
|
|
65
|
+
.headers
|
|
66
|
+
.get("x-line-signature")
|
|
67
|
+
.ok_or("missing LINE signature")?;
|
|
68
|
+
verify_signature(secret.as_bytes(), &request.body, signature, false)?;
|
|
69
|
+
let token = extra(&config.extra, "lineChannelAccessToken")
|
|
70
|
+
.ok_or("missing lineChannelAccessToken")?
|
|
71
|
+
.to_owned();
|
|
72
|
+
let payload: Value =
|
|
73
|
+
serde_json::from_slice(&request.body).map_err(|error| error.to_string())?;
|
|
74
|
+
for event in payload["events"].as_array().cloned().unwrap_or_default() {
|
|
75
|
+
let (Some(reply_token), Some(text)) = (
|
|
76
|
+
event["replyToken"].as_str(),
|
|
77
|
+
event["message"]["text"].as_str(),
|
|
78
|
+
) else {
|
|
79
|
+
continue;
|
|
80
|
+
};
|
|
81
|
+
let answer = answer(text, "Reply concisely for a LINE chat.").await;
|
|
82
|
+
let _ = mint_core::HTTP_CLIENT.clone().post("https://api.line.me/v2/bot/message/reply").bearer_auth(&token)
|
|
83
|
+
.json(&json!({ "replyToken": reply_token, "messages": [{ "type": "text", "text": answer }] })).send().await;
|
|
84
|
+
}
|
|
85
|
+
Ok(("200 OK", "ok".into()))
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
async fn handle_whatsapp(request: HttpRequest) -> Result<(&'static str, String), String> {
|
|
89
|
+
let config = load_config().map_err(|error| error.to_string())?;
|
|
90
|
+
if request.method == "GET" {
|
|
91
|
+
let query = parse_query(&request.path);
|
|
92
|
+
let verify = extra(&config.extra, "whatsappVerifyToken").unwrap_or_default();
|
|
93
|
+
return if query
|
|
94
|
+
.get("hub.verify_token")
|
|
95
|
+
.is_some_and(|token| token == verify)
|
|
96
|
+
{
|
|
97
|
+
Ok((
|
|
98
|
+
"200 OK",
|
|
99
|
+
query.get("hub.challenge").cloned().unwrap_or_default(),
|
|
100
|
+
))
|
|
101
|
+
} else {
|
|
102
|
+
Ok(("403 Forbidden", "verification failed".into()))
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
if request.method != "POST" {
|
|
106
|
+
return Ok(("404 Not Found", "not found".into()));
|
|
107
|
+
}
|
|
108
|
+
if !extra_bool(&config.extra, "enableWhatsappBridge") {
|
|
109
|
+
return Ok(("503 Service Unavailable", "WhatsApp bridge disabled".into()));
|
|
110
|
+
}
|
|
111
|
+
if let (Some(secret), Some(signature)) = (
|
|
112
|
+
extra(&config.extra, "whatsappAppSecret"),
|
|
113
|
+
request.headers.get("x-hub-signature-256"),
|
|
114
|
+
) {
|
|
115
|
+
verify_signature(secret.as_bytes(), &request.body, signature, true)?;
|
|
116
|
+
}
|
|
117
|
+
let access_token = extra(&config.extra, "whatsappCloudAccessToken")
|
|
118
|
+
.ok_or("missing whatsappCloudAccessToken")?
|
|
119
|
+
.to_owned();
|
|
120
|
+
let phone_id = extra(&config.extra, "whatsappPhoneNumberId")
|
|
121
|
+
.ok_or("missing whatsappPhoneNumberId")?
|
|
122
|
+
.to_owned();
|
|
123
|
+
let payload: Value =
|
|
124
|
+
serde_json::from_slice(&request.body).map_err(|error| error.to_string())?;
|
|
125
|
+
for message in payload["entry"]
|
|
126
|
+
.as_array()
|
|
127
|
+
.into_iter()
|
|
128
|
+
.flatten()
|
|
129
|
+
.flat_map(|entry| entry["changes"].as_array().into_iter().flatten())
|
|
130
|
+
.flat_map(|change| change["value"]["messages"].as_array().into_iter().flatten())
|
|
131
|
+
{
|
|
132
|
+
let (Some(to), Some(text)) = (message["from"].as_str(), message["text"]["body"].as_str())
|
|
133
|
+
else {
|
|
134
|
+
continue;
|
|
135
|
+
};
|
|
136
|
+
let answer = answer(text, "Reply concisely for a WhatsApp chat.").await;
|
|
137
|
+
let _ = mint_core::HTTP_CLIENT.clone().post(format!("https://graph.facebook.com/v23.0/{phone_id}/messages")).bearer_auth(&access_token)
|
|
138
|
+
.json(&json!({ "messaging_product": "whatsapp", "to": to, "type": "text", "text": { "body": answer } })).send().await;
|
|
139
|
+
}
|
|
140
|
+
Ok(("200 OK", "ok".into()))
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
async fn answer(text: &str, system: &str) -> String {
|
|
144
|
+
let Ok(config) = load_config() else {
|
|
145
|
+
return "Mint config error".into();
|
|
146
|
+
};
|
|
147
|
+
orchestrate_chat(
|
|
148
|
+
&config,
|
|
149
|
+
&ChatRequest {
|
|
150
|
+
message: text.into(),
|
|
151
|
+
system_instruction: system.into(),
|
|
152
|
+
chat_id: None,
|
|
153
|
+
image_data_uri: None,
|
|
154
|
+
audio_data_uri: None,
|
|
155
|
+
document_attachment: None,
|
|
156
|
+
workspace_path: None,
|
|
157
|
+
},
|
|
158
|
+
)
|
|
159
|
+
.await
|
|
160
|
+
.map(|response| response.text)
|
|
161
|
+
.unwrap_or_else(|error| format!("Mint error: {error}"))
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
struct HttpRequest {
|
|
165
|
+
method: String,
|
|
166
|
+
path: String,
|
|
167
|
+
headers: BTreeMap<String, String>,
|
|
168
|
+
body: Vec<u8>,
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
async fn read_request(stream: &mut TcpStream) -> Result<HttpRequest, String> {
|
|
172
|
+
let mut bytes = Vec::new();
|
|
173
|
+
let mut buffer = [0_u8; 4096];
|
|
174
|
+
loop {
|
|
175
|
+
let read = stream
|
|
176
|
+
.read(&mut buffer)
|
|
177
|
+
.await
|
|
178
|
+
.map_err(|error| error.to_string())?;
|
|
179
|
+
if read == 0 {
|
|
180
|
+
break;
|
|
181
|
+
}
|
|
182
|
+
bytes.extend_from_slice(&buffer[..read]);
|
|
183
|
+
if let Some(split) = bytes.windows(4).position(|window| window == b"\r\n\r\n") {
|
|
184
|
+
let header = String::from_utf8_lossy(&bytes[..split]);
|
|
185
|
+
let mut lines = header.lines();
|
|
186
|
+
let mut first = lines.next().unwrap_or_default().split_whitespace();
|
|
187
|
+
let method = first.next().unwrap_or_default().to_owned();
|
|
188
|
+
let path = first.next().unwrap_or_default().to_owned();
|
|
189
|
+
let headers = lines
|
|
190
|
+
.filter_map(|line| line.split_once(':'))
|
|
191
|
+
.map(|(key, value)| (key.trim().to_ascii_lowercase(), value.trim().to_owned()))
|
|
192
|
+
.collect::<BTreeMap<_, _>>();
|
|
193
|
+
let length = headers
|
|
194
|
+
.get("content-length")
|
|
195
|
+
.and_then(|value| value.parse().ok())
|
|
196
|
+
.unwrap_or(0);
|
|
197
|
+
let start = split + 4;
|
|
198
|
+
while bytes.len() < start + length {
|
|
199
|
+
let read = stream
|
|
200
|
+
.read(&mut buffer)
|
|
201
|
+
.await
|
|
202
|
+
.map_err(|error| error.to_string())?;
|
|
203
|
+
if read == 0 {
|
|
204
|
+
break;
|
|
205
|
+
}
|
|
206
|
+
bytes.extend_from_slice(&buffer[..read]);
|
|
207
|
+
}
|
|
208
|
+
return Ok(HttpRequest {
|
|
209
|
+
method,
|
|
210
|
+
path,
|
|
211
|
+
headers,
|
|
212
|
+
body: bytes[start..start + length.min(bytes.len() - start)].to_vec(),
|
|
213
|
+
});
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
Err("invalid HTTP request".into())
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
fn verify_signature(secret: &[u8], body: &[u8], signature: &str, hex: bool) -> Result<(), String> {
|
|
220
|
+
let mut mac = HmacSha256::new_from_slice(secret).map_err(|error| error.to_string())?;
|
|
221
|
+
mac.update(body);
|
|
222
|
+
let expected = if hex {
|
|
223
|
+
hex_encode(&mac.finalize().into_bytes())
|
|
224
|
+
} else {
|
|
225
|
+
STANDARD.encode(mac.finalize().into_bytes())
|
|
226
|
+
};
|
|
227
|
+
(signature.trim_start_matches("sha256=") == expected)
|
|
228
|
+
.then_some(())
|
|
229
|
+
.ok_or_else(|| "webhook signature verification failed".into())
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
fn parse_query(path: &str) -> BTreeMap<String, String> {
|
|
233
|
+
path.split_once('?')
|
|
234
|
+
.map(|(_, query)| {
|
|
235
|
+
query
|
|
236
|
+
.split('&')
|
|
237
|
+
.filter_map(|item| item.split_once('='))
|
|
238
|
+
.map(|(key, value)| (key.to_owned(), value.to_owned()))
|
|
239
|
+
.collect()
|
|
240
|
+
})
|
|
241
|
+
.unwrap_or_default()
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
fn extra<'a>(extra: &'a BTreeMap<String, Value>, key: &str) -> Option<&'a str> {
|
|
245
|
+
extra
|
|
246
|
+
.get(key)
|
|
247
|
+
.and_then(Value::as_str)
|
|
248
|
+
.filter(|value| !value.is_empty())
|
|
249
|
+
}
|
|
250
|
+
fn extra_bool(extra: &BTreeMap<String, Value>, key: &str) -> bool {
|
|
251
|
+
extra.get(key).and_then(Value::as_bool).unwrap_or(false)
|
|
252
|
+
}
|
|
253
|
+
fn hex_encode(bytes: &[u8]) -> String {
|
|
254
|
+
bytes.iter().map(|byte| format!("{byte:02x}")).collect()
|
|
255
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
use std::{
|
|
2
|
+
collections::BTreeMap,
|
|
3
|
+
process::Command,
|
|
4
|
+
thread,
|
|
5
|
+
time::{Duration, Instant},
|
|
6
|
+
};
|
|
7
|
+
|
|
8
|
+
use mint_core::{load_config, load_workflows};
|
|
9
|
+
use serde_json::json;
|
|
10
|
+
use tauri::{AppHandle, Emitter, Manager};
|
|
11
|
+
|
|
12
|
+
pub fn start_monitor(app: AppHandle) {
|
|
13
|
+
thread::spawn(move || {
|
|
14
|
+
let mut last_triggered = BTreeMap::<String, Instant>::new();
|
|
15
|
+
loop {
|
|
16
|
+
thread::sleep(Duration::from_secs(15));
|
|
17
|
+
if load_config()
|
|
18
|
+
.ok()
|
|
19
|
+
.and_then(|config| config.extra.get("enableCustomWorkflows").cloned())
|
|
20
|
+
.and_then(|value| value.as_bool())
|
|
21
|
+
== Some(false)
|
|
22
|
+
{
|
|
23
|
+
continue;
|
|
24
|
+
}
|
|
25
|
+
let Ok(workflows) = load_workflows() else {
|
|
26
|
+
continue;
|
|
27
|
+
};
|
|
28
|
+
let Ok(output) = Command::new("ps").args(["-A", "-o", "comm="]).output() else {
|
|
29
|
+
continue;
|
|
30
|
+
};
|
|
31
|
+
let processes = String::from_utf8_lossy(&output.stdout)
|
|
32
|
+
.lines()
|
|
33
|
+
.map(|line| line.trim().to_ascii_lowercase())
|
|
34
|
+
.collect::<Vec<_>>();
|
|
35
|
+
for workflow in workflows {
|
|
36
|
+
let Some(id) = workflow["id"].as_str() else {
|
|
37
|
+
continue;
|
|
38
|
+
};
|
|
39
|
+
let Some(process_name) = workflow["trigger"]["processName"].as_str() else {
|
|
40
|
+
continue;
|
|
41
|
+
};
|
|
42
|
+
if !processes
|
|
43
|
+
.iter()
|
|
44
|
+
.any(|process| process == &process_name.to_ascii_lowercase())
|
|
45
|
+
{
|
|
46
|
+
continue;
|
|
47
|
+
}
|
|
48
|
+
if last_triggered
|
|
49
|
+
.get(id)
|
|
50
|
+
.is_some_and(|last| last.elapsed() < Duration::from_secs(60 * 60))
|
|
51
|
+
{
|
|
52
|
+
continue;
|
|
53
|
+
}
|
|
54
|
+
last_triggered.insert(id.into(), Instant::now());
|
|
55
|
+
if let Some(main) = app.get_webview_window("main") {
|
|
56
|
+
let payload = json!({
|
|
57
|
+
"message": workflow["action"]["message"]
|
|
58
|
+
.as_str()
|
|
59
|
+
.unwrap_or("Automation workflow triggered"),
|
|
60
|
+
"suggestions": [
|
|
61
|
+
{ "label": "Yes, please", "action": workflow["action"] },
|
|
62
|
+
{ "label": "Dismiss", "action": { "type": "none" } }
|
|
63
|
+
]
|
|
64
|
+
});
|
|
65
|
+
let _ = main.emit("proactive-suggestion", payload);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
});
|
|
70
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://schema.tauri.app/config/2",
|
|
3
|
+
"productName": "Mint",
|
|
4
|
+
"version": "1.6.2",
|
|
5
|
+
"identifier": "com.pheem49.mint",
|
|
6
|
+
"build": {
|
|
7
|
+
"beforeDevCommand": "npm run dev:desktop",
|
|
8
|
+
"beforeBuildCommand": "npm run build:desktop:ui",
|
|
9
|
+
"devUrl": "http://localhost:9000",
|
|
10
|
+
"frontendDist": "../out/renderer"
|
|
11
|
+
},
|
|
12
|
+
"app": {
|
|
13
|
+
"windows": [
|
|
14
|
+
{
|
|
15
|
+
"label": "main",
|
|
16
|
+
"title": "Mint Agent",
|
|
17
|
+
"width": 1440,
|
|
18
|
+
"height": 900,
|
|
19
|
+
"minWidth": 960,
|
|
20
|
+
"minHeight": 640,
|
|
21
|
+
"transparent": true,
|
|
22
|
+
"decorations": true
|
|
23
|
+
}
|
|
24
|
+
],
|
|
25
|
+
"security": {
|
|
26
|
+
"csp": null,
|
|
27
|
+
"capabilities": ["main"],
|
|
28
|
+
"assetProtocol": {
|
|
29
|
+
"enable": true,
|
|
30
|
+
"scope": ["$HOME/.config/mint/**/*"]
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
},
|
|
34
|
+
"bundle": {
|
|
35
|
+
"active": true,
|
|
36
|
+
"createUpdaterArtifacts": false,
|
|
37
|
+
"targets": ["deb"],
|
|
38
|
+
"icon": [
|
|
39
|
+
"../assets/icon.png"
|
|
40
|
+
]
|
|
41
|
+
},
|
|
42
|
+
"plugins": {
|
|
43
|
+
"updater": {
|
|
44
|
+
"pubkey": "",
|
|
45
|
+
"endpoints": []
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
}
|
package/tsconfig.json
CHANGED