Skip to content

Commit 614d33b

Browse files
committed
Bot foundations + Telegram connector Add classical bot runtime (springtale-bot) with deterministic command routing and first chat connector (connector-telegram). No AI in the critical path — the entire bot operates with NoopAdapter. springtale-bot crate (new): - Command router: prefix, pattern, alias, fallback (longest-match for multi-word commands like /github create_issue) - 6 builtin handlers: /help, /status, /rules, /connectors, /prefs, /alias
- Connector auto-registration: installed connector actions become commands - Session state per (user_id, channel_id) with SQLite isolation - Conversation memory with provenance tracking (author, source, trust_score) and random XChaCha20 nonces for Phase 2 encryption readiness - Truncation compaction (drop oldest beyond context window) - Bot identity: Ed25519 keypair stored in vault - Event loop: tokio::select! over connector + rule channels, never crashes on bad messages, dispatches actions with chain depth limits - HeadlessBot test harness for testing without Telegram - Alias runtime reload after /alias set/remove connector-telegram (new): - Hand-rolled Telegram Bot API client (no teloxide dependency) - 5 actions: send_message, send_photo, edit_message, delete_message, send_inline_keyboard - 2 triggers: message_received, command_received - Long-polling loop with offset tracking (at-most-once delivery) - Webhook secret verification via subtle::ConstantTimeEq - Handles Telegram ok:false responses and 429 rate limiting springtale-store extensions: - Migration 002: bot_sessions, user_prefs, bot_memory, bot_aliases tables - 12 new StorageBackend trait methods (sessions, prefs, memory, aliases) - Memory compaction with deterministic tie-breaking (created_at DESC, id DESC) springtaled integration: - Bot runtime initialized between job queue and API server in boot sequence - Optional [telegram] config section for connector instantiation - Polling dispatcher bridges sync callback to async bot channel via tokio::spawn - Response dispatcher routes bot replies through connector registry Security: - #![forbid(unsafe_code)] on both new crates - All credentials in Secret<T> with SECURITY-annotated expose_secret() calls - Notifications default OFF (IPV safety — docs/current-arch/SECURITY.md §2.8) - Parameterized SQL throughout (no injection vectors) - Empty alias validation, chain depth limits (MAX_CHAIN_DEPTH) - Response channel failures logged (no silent drops)
1 parent c0b5eb7 commit 614d33b

119 files changed

Lines changed: 5887 additions & 527 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

Cargo.lock

Lines changed: 41 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ members = [
1010
"crates/springtale-ai",
1111
"crates/springtale-mcp",
1212
# Phase 1b
13-
# "crates/springtale-bot",
13+
"crates/springtale-bot",
1414
# Phase 2a
1515
# "crates/springtale-sentinel",
1616
# Phase 1a connectors
@@ -22,7 +22,7 @@ members = [
2222
"connectors/connector-shell",
2323
"connectors/connector-http",
2424
# Phase 1b connectors
25-
# "connectors/connector-telegram",
25+
"connectors/connector-telegram",
2626
# Apps
2727
"apps/springtaled",
2828
"apps/springtale-cli",
@@ -141,4 +141,6 @@ springtale-connector = { path = "crates/springtale-connector" }
141141
springtale-scheduler = { path = "crates/springtale-scheduler" }
142142
springtale-store = { path = "crates/springtale-store" }
143143
springtale-ai = { path = "crates/springtale-ai" }
144+
springtale-bot = { path = "crates/springtale-bot" }
144145
springtale-mcp = { path = "crates/springtale-mcp" }
146+
connector-telegram = { path = "connectors/connector-telegram" }

apps/springtale-cli/src/commands/connector.rs

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -56,8 +56,9 @@ pub async fn run(action: ConnectorAction, store: &SqliteBackend, json: bool) ->
5656
println!("Removed connector: {name}");
5757
}
5858
ConnectorAction::Install { path } => {
59-
let contents = std::fs::read_to_string(&path)
60-
.map_err(|e| anyhow::anyhow!("failed to read manifest at {}: {e}", path.display()))?;
59+
let contents = std::fs::read_to_string(&path).map_err(|e| {
60+
anyhow::anyhow!("failed to read manifest at {}: {e}", path.display())
61+
})?;
6162
let manifest: ConnectorManifest = toml::from_str(&contents)
6263
.map_err(|e| anyhow::anyhow!("failed to parse manifest TOML: {e}"))?;
6364

@@ -66,7 +67,9 @@ pub async fn run(action: ConnectorAction, store: &SqliteBackend, json: bool) ->
6667
.map_err(|e| anyhow::anyhow!("manifest validation failed: {e}"))?;
6768

6869
if manifest.signature.is_some() {
69-
println!(" Note: manifest has signature — verification requires author key registry (Phase 2)");
70+
println!(
71+
" Note: manifest has signature — verification requires author key registry (Phase 2)"
72+
);
7073
}
7174

7275
let manifest_json = serde_json::to_string(&manifest)

apps/springtale-cli/src/commands/init.rs

Lines changed: 5 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -33,18 +33,15 @@ pub async fn run() -> Result<()> {
3333
}
3434

3535
let keypair = Keypair::generate().context("failed to generate identity")?;
36-
let mut vault = Vault::create(&vault_path, passphrase.as_bytes())
37-
.context("failed to create vault")?;
36+
let mut vault =
37+
Vault::create(&vault_path, passphrase.as_bytes()).context("failed to create vault")?;
3838
// SECURITY: expose needed to persist identity key material
3939
vault
4040
.set("identity", keypair.expose_secret_bytes().to_vec())
4141
.context("failed to store identity")?;
4242
vault.save().context("failed to save vault")?;
4343

44-
println!(
45-
" Created vault at {}",
46-
vault_path.display()
47-
);
44+
println!(" Created vault at {}", vault_path.display());
4845
println!(
4946
" Generated identity: {}",
5047
hex::encode(keypair.node_id().as_bytes())
@@ -56,8 +53,7 @@ pub async fn run() -> Result<()> {
5653
if db_path.exists() {
5754
println!(" Database already exists at {}", db_path.display());
5855
} else {
59-
let _store = SqliteBackend::open(&db_path)
60-
.context("failed to create database")?;
56+
let _store = SqliteBackend::open(&db_path).context("failed to create database")?;
6157
println!(" Created database at {}", db_path.display());
6258
}
6359

@@ -87,12 +83,10 @@ bind = "127.0.0.1:8080"
8783
socket_path = data_dir.join("springtale.sock").display(),
8884
);
8985

90-
std::fs::write(&config_path, default_config)
91-
.context("failed to write springtale.toml")?;
86+
std::fs::write(&config_path, default_config).context("failed to write springtale.toml")?;
9287
println!(" Created {}", config_path.display());
9388
}
9489

9590
println!("\nSpringtale initialized. Run `springtale server start` to begin.");
9691
Ok(())
9792
}
98-

apps/springtale-cli/src/commands/rule.rs

Lines changed: 10 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -48,8 +48,9 @@ pub async fn run(action: RuleAction, store: &SqliteBackend, json: bool) -> Resul
4848
}
4949
}
5050
RuleAction::Add { file } => {
51-
let contents = std::fs::read_to_string(&file)
52-
.map_err(|e| anyhow::anyhow!("failed to read rule file at {}: {e}", file.display()))?;
51+
let contents = std::fs::read_to_string(&file).map_err(|e| {
52+
anyhow::anyhow!("failed to read rule file at {}: {e}", file.display())
53+
})?;
5354

5455
let rule: Rule = match file.extension().and_then(|ext| ext.to_str()) {
5556
Some("toml") => toml::from_str(&contents)
@@ -59,10 +60,9 @@ pub async fn run(action: RuleAction, store: &SqliteBackend, json: bool) -> Resul
5960
_ => {
6061
// Try TOML first, then JSON
6162
toml::from_str(&contents).or_else(|_| {
62-
serde_json::from_str(&contents)
63-
.map_err(|e| anyhow::anyhow!(
64-
"failed to parse rule file (tried TOML and JSON): {e}"
65-
))
63+
serde_json::from_str(&contents).map_err(|e| {
64+
anyhow::anyhow!("failed to parse rule file (tried TOML and JSON): {e}")
65+
})
6666
})?
6767
}
6868
};
@@ -71,8 +71,8 @@ pub async fn run(action: RuleAction, store: &SqliteBackend, json: bool) -> Resul
7171
println!("Added rule: {} (id: {rule_id})", rule.name);
7272
}
7373
RuleAction::Run { id } => {
74-
let uuid = uuid::Uuid::parse_str(&id)
75-
.map_err(|e| anyhow::anyhow!("invalid rule ID: {e}"))?;
74+
let uuid =
75+
uuid::Uuid::parse_str(&id).map_err(|e| anyhow::anyhow!("invalid rule ID: {e}"))?;
7676
let rule_id = RuleId(uuid);
7777

7878
// Load all rules and find the target
@@ -102,8 +102,8 @@ pub async fn run(action: RuleAction, store: &SqliteBackend, json: bool) -> Resul
102102
}
103103
}
104104
RuleAction::Toggle { id } => {
105-
let uuid = uuid::Uuid::parse_str(&id)
106-
.map_err(|e| anyhow::anyhow!("invalid rule ID: {e}"))?;
105+
let uuid =
106+
uuid::Uuid::parse_str(&id).map_err(|e| anyhow::anyhow!("invalid rule ID: {e}"))?;
107107
let rule_id = RuleId(uuid);
108108
// Toggle: read current state and flip
109109
let rules = store.list_rules().await?;

apps/springtale-cli/src/commands/server.rs

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,10 @@ pub async fn run() -> Result<()> {
2020
.with_context(|| format!("failed to start {}", springtaled_path.display()))?;
2121

2222
// Wait for the child process
23-
let status = child.wait().await.context("failed to wait for springtaled")?;
23+
let status = child
24+
.wait()
25+
.await
26+
.context("failed to wait for springtaled")?;
2427

2528
if status.success() {
2629
println!("springtaled exited cleanly");

apps/springtale-cli/src/main.rs

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -51,9 +51,10 @@ fn open_store() -> Result<SqliteBackend> {
5151
// Parse just the store section from config
5252
let figment = figment::Figment::new()
5353
.merge(<figment::providers::Toml as figment::providers::Format>::file(config_path))
54-
.merge(figment::providers::Env::prefixed("SPRINGTALE_").map(|key| {
55-
key.as_str().replace("__", ".").into()
56-
}));
54+
.merge(
55+
figment::providers::Env::prefixed("SPRINGTALE_")
56+
.map(|key| key.as_str().replace("__", ".").into()),
57+
);
5758

5859
#[derive(serde::Deserialize, Default)]
5960
struct PartialConfig {

apps/springtaled/Cargo.toml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,10 @@ springtale-connector = { workspace = true }
3131
springtale-scheduler = { workspace = true }
3232
springtale-store = { workspace = true }
3333
springtale-ai = { workspace = true }
34+
springtale-bot = { workspace = true }
3435
springtale-mcp = { workspace = true }
36+
connector-telegram = { workspace = true }
37+
secrecy = { workspace = true }
3538

3639
[dev-dependencies]
3740
tokio = { workspace = true, features = ["test-util"] }

apps/springtaled/src/api/connectors.rs

Lines changed: 14 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
1+
use axum::Json;
12
use axum::extract::{Path, State};
23
use axum::http::StatusCode;
34
use axum::response::IntoResponse;
4-
use axum::Json;
55

66
use springtale_store::backend::trait_::StorageBackend;
77

@@ -31,9 +31,7 @@ pub async fn remove(
3131
) -> Result<impl IntoResponse, StatusCode> {
3232
super::validate_path_param(&name)?;
3333
let mut registry = state.registry.write().await;
34-
registry
35-
.remove(&name)
36-
.map_err(|_| StatusCode::NOT_FOUND)?;
34+
registry.remove(&name).map_err(|_| StatusCode::NOT_FOUND)?;
3735

3836
Ok((StatusCode::OK, Json(serde_json::json!({ "removed": name }))))
3937
}
@@ -45,9 +43,7 @@ pub async fn enable(
4543
) -> Result<impl IntoResponse, StatusCode> {
4644
super::validate_path_param(&name)?;
4745
let mut registry = state.registry.write().await;
48-
registry
49-
.enable(&name)
50-
.map_err(|_| StatusCode::NOT_FOUND)?;
46+
registry.enable(&name).map_err(|_| StatusCode::NOT_FOUND)?;
5147

5248
Ok((StatusCode::OK, Json(serde_json::json!({ "enabled": name }))))
5349
}
@@ -59,11 +55,12 @@ pub async fn disable(
5955
) -> Result<impl IntoResponse, StatusCode> {
6056
super::validate_path_param(&name)?;
6157
let mut registry = state.registry.write().await;
62-
registry
63-
.disable(&name)
64-
.map_err(|_| StatusCode::NOT_FOUND)?;
58+
registry.disable(&name).map_err(|_| StatusCode::NOT_FOUND)?;
6559

66-
Ok((StatusCode::OK, Json(serde_json::json!({ "disabled": name }))))
60+
Ok((
61+
StatusCode::OK,
62+
Json(serde_json::json!({ "disabled": name })),
63+
))
6764
}
6865

6966
/// POST /connectors/install — install a connector from manifest JSON.
@@ -77,11 +74,10 @@ pub async fn install(
7774
Json(manifest): Json<springtale_connector::ConnectorManifest>,
7875
) -> Result<impl IntoResponse, StatusCode> {
7976
// Validate manifest structure (name, version, no wildcard hosts)
80-
springtale_connector::manifest::verify::verify_manifest(&manifest)
81-
.map_err(|e| {
82-
tracing::warn!(error = %e, "manifest validation failed");
83-
StatusCode::BAD_REQUEST
84-
})?;
77+
springtale_connector::manifest::verify::verify_manifest(&manifest).map_err(|e| {
78+
tracing::warn!(error = %e, "manifest validation failed");
79+
StatusCode::BAD_REQUEST
80+
})?;
8581

8682
// If manifest has a signature, log that verification is deferred to Phase 2
8783
// (requires author public key registry which doesn't exist yet)
@@ -92,8 +88,8 @@ pub async fn install(
9288
);
9389
}
9490

95-
let manifest_json = serde_json::to_string(&manifest)
96-
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
91+
let manifest_json =
92+
serde_json::to_string(&manifest).map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
9793

9894
let row = springtale_store::schema::connectors::ConnectorRow {
9995
name: manifest.name.clone(),

apps/springtaled/src/api/events.rs

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
1+
use axum::Json;
12
use axum::extract::{Query, State};
23
use axum::response::IntoResponse;
3-
use axum::Json;
44

55
use springtale_store::backend::trait_::StorageBackend;
66
use springtale_store::schema::events::EventFilter;
@@ -38,7 +38,11 @@ pub async fn list(
3838
let filter = EventFilter {
3939
connector_name: params.connector.clone(),
4040
limit: Some(clamped_limit),
41-
offset: if params.offset > 0 { Some(params.offset) } else { None },
41+
offset: if params.offset > 0 {
42+
Some(params.offset)
43+
} else {
44+
None
45+
},
4246
..Default::default()
4347
};
4448

0 commit comments

Comments
 (0)