hajimi-claw 0.1.0

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.
@@ -0,0 +1,305 @@
1
+ use std::sync::Arc;
2
+
3
+ use anyhow::{Context, Result};
4
+ use hajimi_claw_gateway::{
5
+ Gateway, GatewayRequest, InlineKeyboard, SessionDirective, parse_gateway_command,
6
+ };
7
+ use serde::Deserialize;
8
+ use tokio::sync::Mutex;
9
+ use tracing::{error, info, warn};
10
+
11
+ #[derive(Debug, Clone)]
12
+ pub struct TelegramConfig {
13
+ pub token: String,
14
+ pub poll_timeout_secs: u64,
15
+ pub admin_user_id: i64,
16
+ pub admin_chat_id: i64,
17
+ }
18
+
19
+ pub struct TelegramBot {
20
+ client: reqwest::Client,
21
+ config: TelegramConfig,
22
+ gateway: Arc<dyn Gateway>,
23
+ current_session: Mutex<Option<String>>,
24
+ }
25
+
26
+ impl TelegramBot {
27
+ pub fn new(config: TelegramConfig, gateway: Arc<dyn Gateway>) -> Self {
28
+ Self {
29
+ client: reqwest::Client::new(),
30
+ config,
31
+ gateway,
32
+ current_session: Mutex::new(None),
33
+ }
34
+ }
35
+
36
+ pub async fn run(&self) -> Result<()> {
37
+ let mut offset = 0_i64;
38
+ loop {
39
+ match self.get_updates(offset).await {
40
+ Ok(updates) => {
41
+ for update in updates {
42
+ offset = update.update_id + 1;
43
+ if let Err(err) = self.handle_update(update).await {
44
+ error!(error = %err, "failed to handle telegram update");
45
+ }
46
+ }
47
+ }
48
+ Err(err) => {
49
+ warn!(error = %err, "telegram polling failed");
50
+ tokio::time::sleep(tokio::time::Duration::from_secs(2)).await;
51
+ }
52
+ }
53
+ }
54
+ }
55
+
56
+ async fn handle_update(&self, update: Update) -> Result<()> {
57
+ if let Some(message) = update.message {
58
+ if !self.is_authorized(
59
+ message.chat.id,
60
+ message
61
+ .from
62
+ .as_ref()
63
+ .map(|user| user.id)
64
+ .unwrap_or_default(),
65
+ ) {
66
+ warn!("ignored telegram message from unauthorized actor");
67
+ return Ok(());
68
+ }
69
+
70
+ let Some(text) = message.text else {
71
+ return Ok(());
72
+ };
73
+ let reply = self.dispatch_command(&text).await;
74
+ self.send_message(message.chat.id, &reply.text, reply.keyboard)
75
+ .await?;
76
+ return Ok(());
77
+ }
78
+
79
+ if let Some(callback_query) = update.callback_query {
80
+ let chat_id = callback_query
81
+ .message
82
+ .as_ref()
83
+ .map(|message| message.chat.id)
84
+ .unwrap_or(self.config.admin_chat_id);
85
+ if !self.is_authorized(chat_id, callback_query.from.id) {
86
+ warn!("ignored telegram callback from unauthorized actor");
87
+ return Ok(());
88
+ }
89
+
90
+ if let Some(data) = callback_query.data {
91
+ let reply = self.dispatch_command(&data).await;
92
+ self.send_message(chat_id, &reply.text, reply.keyboard)
93
+ .await?;
94
+ }
95
+ self.answer_callback_query(&callback_query.id).await?;
96
+ }
97
+
98
+ Ok(())
99
+ }
100
+
101
+ async fn dispatch_command(&self, text: &str) -> BotReply {
102
+ let current_session_id = self.current_session.lock().await.clone();
103
+ match self
104
+ .gateway
105
+ .handle(GatewayRequest {
106
+ actor_user_id: self.config.admin_user_id,
107
+ actor_chat_id: self.config.admin_chat_id,
108
+ raw_text: text.to_string(),
109
+ command: parse_gateway_command(text),
110
+ current_session_id,
111
+ })
112
+ .await
113
+ {
114
+ Ok(response) => {
115
+ match response.session {
116
+ SessionDirective::Keep => {}
117
+ SessionDirective::Set(session_id) => {
118
+ *self.current_session.lock().await = Some(session_id);
119
+ }
120
+ SessionDirective::Clear => {
121
+ *self.current_session.lock().await = None;
122
+ }
123
+ }
124
+ BotReply {
125
+ text: response.text,
126
+ keyboard: response.keyboard,
127
+ }
128
+ }
129
+ Err(err) => BotReply {
130
+ text: format!("error: {err}"),
131
+ keyboard: None,
132
+ },
133
+ }
134
+ }
135
+
136
+ async fn get_updates(&self, offset: i64) -> Result<Vec<Update>> {
137
+ let response = self
138
+ .client
139
+ .post(self.api_url("getUpdates"))
140
+ .json(&serde_json::json!({
141
+ "timeout": self.config.poll_timeout_secs,
142
+ "offset": offset,
143
+ }))
144
+ .send()
145
+ .await
146
+ .context("getUpdates request")?;
147
+
148
+ let payload: TelegramEnvelope<Vec<Update>> =
149
+ response.json().await.context("decode updates")?;
150
+ if !payload.ok {
151
+ anyhow::bail!("telegram getUpdates returned ok=false");
152
+ }
153
+ Ok(payload.result)
154
+ }
155
+
156
+ async fn send_message(
157
+ &self,
158
+ chat_id: i64,
159
+ text: &str,
160
+ keyboard: Option<InlineKeyboard>,
161
+ ) -> Result<()> {
162
+ let payload = if let Some(keyboard) = keyboard {
163
+ serde_json::json!({
164
+ "chat_id": chat_id,
165
+ "text": clamp_text(text),
166
+ "reply_markup": to_reply_markup(keyboard),
167
+ })
168
+ } else {
169
+ serde_json::json!({
170
+ "chat_id": chat_id,
171
+ "text": clamp_text(text),
172
+ })
173
+ };
174
+ let response = self
175
+ .client
176
+ .post(self.api_url("sendMessage"))
177
+ .json(&payload)
178
+ .send()
179
+ .await
180
+ .context("sendMessage request")?;
181
+ let payload: TelegramEnvelope<serde_json::Value> =
182
+ response.json().await.context("decode sendMessage")?;
183
+ if !payload.ok {
184
+ anyhow::bail!("telegram sendMessage returned ok=false");
185
+ }
186
+ info!("sent telegram message");
187
+ Ok(())
188
+ }
189
+
190
+ async fn answer_callback_query(&self, callback_query_id: &str) -> Result<()> {
191
+ let response = self
192
+ .client
193
+ .post(self.api_url("answerCallbackQuery"))
194
+ .json(&serde_json::json!({
195
+ "callback_query_id": callback_query_id,
196
+ }))
197
+ .send()
198
+ .await
199
+ .context("answerCallbackQuery request")?;
200
+ let payload: TelegramEnvelope<serde_json::Value> = response
201
+ .json()
202
+ .await
203
+ .context("decode answerCallbackQuery")?;
204
+ if !payload.ok {
205
+ anyhow::bail!("telegram answerCallbackQuery returned ok=false");
206
+ }
207
+ Ok(())
208
+ }
209
+
210
+ fn api_url(&self, method: &str) -> String {
211
+ format!(
212
+ "https://api.telegram.org/bot{}/{}",
213
+ self.config.token, method
214
+ )
215
+ }
216
+
217
+ fn is_authorized(&self, chat_id: i64, user_id: i64) -> bool {
218
+ chat_id == self.config.admin_chat_id && user_id == self.config.admin_user_id
219
+ }
220
+ }
221
+
222
+ #[derive(Debug, Deserialize)]
223
+ struct TelegramEnvelope<T> {
224
+ ok: bool,
225
+ result: T,
226
+ }
227
+
228
+ #[derive(Debug, Deserialize)]
229
+ struct Update {
230
+ update_id: i64,
231
+ message: Option<Message>,
232
+ callback_query: Option<CallbackQuery>,
233
+ }
234
+
235
+ #[derive(Debug, Deserialize)]
236
+ struct Message {
237
+ chat: Chat,
238
+ from: Option<User>,
239
+ text: Option<String>,
240
+ }
241
+
242
+ #[derive(Debug, Deserialize)]
243
+ struct Chat {
244
+ id: i64,
245
+ }
246
+
247
+ #[derive(Debug, Deserialize)]
248
+ struct User {
249
+ id: i64,
250
+ }
251
+
252
+ #[derive(Debug, Deserialize)]
253
+ struct CallbackQuery {
254
+ id: String,
255
+ from: User,
256
+ data: Option<String>,
257
+ message: Option<Message>,
258
+ }
259
+
260
+ struct BotReply {
261
+ text: String,
262
+ keyboard: Option<InlineKeyboard>,
263
+ }
264
+
265
+ fn clamp_text(text: &str) -> String {
266
+ const MAX: usize = 3500;
267
+ if text.len() <= MAX {
268
+ return text.to_string();
269
+ }
270
+ format!("{}...\n[truncated]", &text[..MAX])
271
+ }
272
+
273
+ fn to_reply_markup(keyboard: InlineKeyboard) -> serde_json::Value {
274
+ let rows = keyboard
275
+ .rows
276
+ .into_iter()
277
+ .map(|row| {
278
+ row.into_iter()
279
+ .map(|button| {
280
+ serde_json::json!({
281
+ "text": button.text,
282
+ "callback_data": button.data,
283
+ })
284
+ })
285
+ .collect::<Vec<_>>()
286
+ })
287
+ .collect::<Vec<_>>();
288
+ serde_json::json!({ "inline_keyboard": rows })
289
+ }
290
+
291
+ #[cfg(test)]
292
+ mod tests {
293
+ use hajimi_claw_gateway::{GatewayCommand, parse_gateway_command};
294
+
295
+ #[test]
296
+ fn parses_elevated_start() {
297
+ assert_eq!(
298
+ parse_gateway_command("/elevated start 15 maintenance"),
299
+ GatewayCommand::ElevatedStart {
300
+ minutes: 15,
301
+ reason: "maintenance".into(),
302
+ }
303
+ );
304
+ }
305
+ }
@@ -0,0 +1,24 @@
1
+ [package]
2
+ name = "hajimi-claw-daemon"
3
+ version.workspace = true
4
+ edition.workspace = true
5
+ license.workspace = true
6
+ authors.workspace = true
7
+
8
+ [dependencies]
9
+ anyhow.workspace = true
10
+ chrono.workspace = true
11
+ serde.workspace = true
12
+ tokio.workspace = true
13
+ toml.workspace = true
14
+ tracing.workspace = true
15
+ tracing-subscriber.workspace = true
16
+ hajimi-claw-agent = { path = "../hajimi-claw-agent" }
17
+ hajimi-claw-bot = { path = "../hajimi-claw-bot" }
18
+ hajimi-claw-exec = { path = "../hajimi-claw-exec" }
19
+ hajimi-claw-gateway = { path = "../hajimi-claw-gateway" }
20
+ hajimi-claw-llm = { path = "../hajimi-claw-llm" }
21
+ hajimi-claw-policy = { path = "../hajimi-claw-policy" }
22
+ hajimi-claw-store = { path = "../hajimi-claw-store" }
23
+ hajimi-claw-tools = { path = "../hajimi-claw-tools" }
24
+ hajimi-claw-types = { path = "../hajimi-claw-types" }
@@ -0,0 +1,173 @@
1
+ use std::path::PathBuf;
2
+ use std::sync::Arc;
3
+
4
+ use anyhow::{Context, Result};
5
+ use hajimi_claw_agent::AgentRuntime;
6
+ use hajimi_claw_bot::{TelegramBot, TelegramConfig};
7
+ use hajimi_claw_exec::{LocalExecutor, PlatformMode};
8
+ use hajimi_claw_gateway::InProcessGateway;
9
+ use hajimi_claw_llm::{StaticBackend, StoreBackedBackend};
10
+ use hajimi_claw_policy::{PolicyConfig, PolicyEngine};
11
+ use hajimi_claw_store::{SecretCipher, Store};
12
+ use hajimi_claw_tools::ToolRegistry;
13
+ use serde::Deserialize;
14
+ use tracing_subscriber::EnvFilter;
15
+
16
+ #[derive(Debug, Clone, Deserialize)]
17
+ pub struct AppConfig {
18
+ pub telegram: TelegramSection,
19
+ pub llm: LlmSection,
20
+ pub storage: StorageSection,
21
+ pub security: SecuritySection,
22
+ pub policy: PolicyConfig,
23
+ pub execution: ExecutionSection,
24
+ }
25
+
26
+ #[derive(Debug, Clone, Deserialize)]
27
+ pub struct TelegramSection {
28
+ pub bot_token: String,
29
+ pub poll_timeout_secs: Option<u64>,
30
+ }
31
+
32
+ #[derive(Debug, Clone, Deserialize)]
33
+ pub struct LlmSection {
34
+ pub base_url: Option<String>,
35
+ pub api_key: Option<String>,
36
+ pub model: Option<String>,
37
+ pub static_fallback_response: Option<String>,
38
+ }
39
+
40
+ #[derive(Debug, Clone, Deserialize)]
41
+ pub struct StorageSection {
42
+ pub sqlite_path: PathBuf,
43
+ }
44
+
45
+ #[derive(Debug, Clone, Deserialize)]
46
+ pub struct SecuritySection {
47
+ pub master_key_env: Option<String>,
48
+ }
49
+
50
+ #[derive(Debug, Clone, Deserialize)]
51
+ pub struct ExecutionSection {
52
+ pub mode: Option<String>,
53
+ }
54
+
55
+ pub async fn run_from_env() -> Result<()> {
56
+ init_tracing();
57
+ let config_path = std::env::var("HAJIMI_CLAW_CONFIG").unwrap_or_else(|_| "config.toml".into());
58
+ let config = load_config(PathBuf::from(config_path))?;
59
+ run(config).await
60
+ }
61
+
62
+ pub async fn run(config: AppConfig) -> Result<()> {
63
+ if let Some(parent) = config.storage.sqlite_path.parent() {
64
+ std::fs::create_dir_all(parent)
65
+ .with_context(|| format!("create storage directory {}", parent.display()))?;
66
+ }
67
+ let policy = Arc::new(PolicyEngine::new(config.policy.clone()));
68
+ let master_key_env = config
69
+ .security
70
+ .master_key_env
71
+ .clone()
72
+ .unwrap_or_else(|| "HAJIMI_CLAW_MASTER_KEY".into());
73
+ let master_key = std::env::var(&master_key_env).with_context(|| {
74
+ format!(
75
+ "missing provider encryption key env `{master_key_env}`; set it before starting hajimi-claw"
76
+ )
77
+ })?;
78
+ let cipher = Arc::new(SecretCipher::from_passphrase(&master_key)?);
79
+ let store = Arc::new(Store::open_with_cipher(
80
+ &config.storage.sqlite_path,
81
+ Some(cipher),
82
+ )?);
83
+ let executor = Arc::new(LocalExecutor::new(
84
+ policy.clone(),
85
+ select_platform_mode(config.execution.mode.as_deref()),
86
+ ));
87
+ let tools = Arc::new(ToolRegistry::default(executor, policy.clone()));
88
+ let fallback = Arc::new(StaticBackend::new(
89
+ config
90
+ .llm
91
+ .static_fallback_response
92
+ .clone()
93
+ .unwrap_or_else(|| "LLM backend not configured.".into()),
94
+ ));
95
+ if let (Some(base_url), Some(api_key), Some(model)) = (
96
+ config.llm.base_url.clone(),
97
+ config.llm.api_key.clone(),
98
+ config.llm.model.clone(),
99
+ ) {
100
+ let bootstrap_record = hajimi_claw_types::ProviderRecord {
101
+ config: hajimi_claw_types::ProviderConfig {
102
+ id: "bootstrap".into(),
103
+ label: "Bootstrap".into(),
104
+ kind: hajimi_claw_types::ProviderKind::OpenAiCompatible,
105
+ base_url,
106
+ api_key,
107
+ model,
108
+ enabled: true,
109
+ extra_headers: vec![],
110
+ created_at: chrono::Utc::now(),
111
+ },
112
+ is_default: store.get_default_provider()?.is_none(),
113
+ };
114
+ store.upsert_provider(&bootstrap_record)?;
115
+ }
116
+ let llm: Arc<dyn hajimi_claw_types::LlmBackend> =
117
+ Arc::new(StoreBackedBackend::new(store.clone(), Some(fallback)));
118
+
119
+ let runtime = Arc::new(AgentRuntime::new(llm, tools, store.clone(), policy.clone()));
120
+ let gateway = Arc::new(InProcessGateway::new(
121
+ runtime,
122
+ policy.clone(),
123
+ store.clone(),
124
+ ));
125
+ let bot = TelegramBot::new(
126
+ TelegramConfig {
127
+ token: config.telegram.bot_token,
128
+ poll_timeout_secs: config.telegram.poll_timeout_secs.unwrap_or(30),
129
+ admin_user_id: config.policy.admin_user_id,
130
+ admin_chat_id: config.policy.admin_chat_id,
131
+ },
132
+ gateway,
133
+ );
134
+
135
+ bot.run().await
136
+ }
137
+
138
+ pub fn load_config(path: PathBuf) -> Result<AppConfig> {
139
+ let raw = std::fs::read_to_string(&path)
140
+ .with_context(|| format!("read config file {}", path.display()))?;
141
+ let config: AppConfig = toml::from_str(&raw).context("parse config.toml")?;
142
+ Ok(config)
143
+ }
144
+
145
+ fn init_tracing() {
146
+ let _ = tracing_subscriber::fmt()
147
+ .with_env_filter(EnvFilter::from_default_env())
148
+ .try_init();
149
+ }
150
+
151
+ fn select_platform_mode(mode: Option<&str>) -> PlatformMode {
152
+ match mode.unwrap_or("auto") {
153
+ "windows-safe" => PlatformMode::WindowsSafe,
154
+ "windows-elevated" => PlatformMode::WindowsElevated,
155
+ "unix" => PlatformMode::Unix,
156
+ _ if cfg!(windows) => PlatformMode::WindowsSafe,
157
+ _ => PlatformMode::Unix,
158
+ }
159
+ }
160
+
161
+ #[cfg(test)]
162
+ mod tests {
163
+ use super::select_platform_mode;
164
+ use hajimi_claw_exec::PlatformMode;
165
+
166
+ #[test]
167
+ fn selects_explicit_mode() {
168
+ assert!(matches!(
169
+ select_platform_mode(Some("windows-safe")),
170
+ PlatformMode::WindowsSafe
171
+ ));
172
+ }
173
+ }
@@ -0,0 +1,21 @@
1
+ [package]
2
+ name = "hajimi-claw-exec"
3
+ version.workspace = true
4
+ edition.workspace = true
5
+ license.workspace = true
6
+ authors.workspace = true
7
+
8
+ [dependencies]
9
+ anyhow.workspace = true
10
+ async-trait.workspace = true
11
+ chrono.workspace = true
12
+ hajimi-claw-policy = { path = "../hajimi-claw-policy" }
13
+ hajimi-claw-types = { path = "../hajimi-claw-types" }
14
+ serde.workspace = true
15
+ tokio.workspace = true
16
+ tracing.workspace = true
17
+ uuid.workspace = true
18
+ windows.workspace = true
19
+
20
+ [dev-dependencies]
21
+ tempfile.workspace = true