story-kit: merge 324_story_slack_bot_integration_for_bot_commands
This commit is contained in:
@@ -46,3 +46,16 @@ enabled = false
|
||||
#
|
||||
# Once approved, set the name below (default: "pipeline_notification"):
|
||||
# whatsapp_notification_template = "pipeline_notification"
|
||||
|
||||
# ── Slack Bot API ─────────────────────────────────────────────────────
|
||||
# Set transport = "slack" to use Slack instead of Matrix.
|
||||
# The webhook endpoint will be available at /webhook/slack.
|
||||
# Configure this URL in the Slack App → Event Subscriptions → Request URL.
|
||||
#
|
||||
# Required Slack App scopes: chat:write, chat:update
|
||||
# Subscribe to bot events: message.channels, message.groups, message.im
|
||||
#
|
||||
# transport = "slack"
|
||||
# slack_bot_token = "xoxb-..."
|
||||
# slack_signing_secret = "your-signing-secret"
|
||||
# slack_channel_ids = ["C01ABCDEF"]
|
||||
|
||||
@@ -29,6 +29,7 @@ use settings::SettingsApi;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::slack::SlackWebhookContext;
|
||||
use crate::whatsapp::WhatsAppWebhookContext;
|
||||
|
||||
const DEFAULT_PORT: u16 = 3001;
|
||||
@@ -56,6 +57,7 @@ pub fn remove_port_file(path: &Path) {
|
||||
pub fn build_routes(
|
||||
ctx: AppContext,
|
||||
whatsapp_ctx: Option<Arc<WhatsAppWebhookContext>>,
|
||||
slack_ctx: Option<Arc<SlackWebhookContext>>,
|
||||
) -> impl poem::Endpoint {
|
||||
let ctx_arc = std::sync::Arc::new(ctx);
|
||||
|
||||
@@ -87,6 +89,13 @@ pub fn build_routes(
|
||||
);
|
||||
}
|
||||
|
||||
if let Some(sl_ctx) = slack_ctx {
|
||||
route = route.at(
|
||||
"/webhook/slack",
|
||||
post(crate::slack::webhook_receive).data(sl_ctx),
|
||||
);
|
||||
}
|
||||
|
||||
route.data(ctx_arc)
|
||||
}
|
||||
|
||||
@@ -196,6 +205,6 @@ mod tests {
|
||||
fn build_routes_constructs_without_panic() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let ctx = context::AppContext::new_test(tmp.path().to_path_buf());
|
||||
let _endpoint = build_routes(ctx, None);
|
||||
let _endpoint = build_routes(ctx, None, None);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ mod state;
|
||||
mod store;
|
||||
pub mod transport;
|
||||
mod workflow;
|
||||
pub mod slack;
|
||||
pub mod whatsapp;
|
||||
mod worktree;
|
||||
|
||||
@@ -228,7 +229,38 @@ async fn main() -> Result<(), std::io::Error> {
|
||||
})
|
||||
});
|
||||
|
||||
let app = build_routes(ctx, whatsapp_ctx);
|
||||
// Build Slack webhook context if bot.toml configures transport = "slack".
|
||||
let slack_ctx: Option<Arc<slack::SlackWebhookContext>> = startup_root
|
||||
.as_ref()
|
||||
.and_then(|root| matrix::BotConfig::load(root))
|
||||
.filter(|cfg| cfg.transport == "slack")
|
||||
.map(|cfg| {
|
||||
let transport = Arc::new(slack::SlackTransport::new(
|
||||
cfg.slack_bot_token.clone().unwrap_or_default(),
|
||||
));
|
||||
let bot_name = cfg
|
||||
.display_name
|
||||
.clone()
|
||||
.unwrap_or_else(|| "Assistant".to_string());
|
||||
let root = startup_root.clone().unwrap();
|
||||
let history = slack::load_slack_history(&root);
|
||||
let channel_ids: std::collections::HashSet<String> =
|
||||
cfg.slack_channel_ids.iter().cloned().collect();
|
||||
Arc::new(slack::SlackWebhookContext {
|
||||
signing_secret: cfg.slack_signing_secret.clone().unwrap_or_default(),
|
||||
transport,
|
||||
project_root: root,
|
||||
agents: Arc::clone(&startup_agents),
|
||||
bot_name,
|
||||
bot_user_id: "slack-bot".to_string(),
|
||||
ambient_rooms: Arc::new(std::sync::Mutex::new(std::collections::HashSet::new())),
|
||||
history: std::sync::Arc::new(tokio::sync::Mutex::new(history)),
|
||||
history_size: cfg.history_size,
|
||||
channel_ids,
|
||||
})
|
||||
});
|
||||
|
||||
let app = build_routes(ctx, whatsapp_ctx, slack_ctx);
|
||||
|
||||
|
||||
// Optional Matrix bot: connect to the homeserver and start listening for
|
||||
|
||||
@@ -87,6 +87,19 @@ pub struct BotConfig {
|
||||
/// use. Defaults to `"pipeline_notification"`.
|
||||
#[serde(default)]
|
||||
pub whatsapp_notification_template: Option<String>,
|
||||
|
||||
// ── Slack Bot API fields ─────────────────────────────────────────
|
||||
// These are only required when `transport = "slack"`.
|
||||
|
||||
/// Slack Bot User OAuth Token (starts with `xoxb-`).
|
||||
#[serde(default)]
|
||||
pub slack_bot_token: Option<String>,
|
||||
/// Slack Signing Secret used to verify incoming webhook requests.
|
||||
#[serde(default)]
|
||||
pub slack_signing_secret: Option<String>,
|
||||
/// Slack channel IDs the bot should listen in.
|
||||
#[serde(default)]
|
||||
pub slack_channel_ids: Vec<String>,
|
||||
}
|
||||
|
||||
fn default_transport() -> String {
|
||||
@@ -142,6 +155,29 @@ impl BotConfig {
|
||||
);
|
||||
return None;
|
||||
}
|
||||
} else if config.transport == "slack" {
|
||||
// Validate Slack-specific fields.
|
||||
if config.slack_bot_token.as_ref().is_none_or(|s| s.is_empty()) {
|
||||
eprintln!(
|
||||
"[bot] bot.toml: transport=\"slack\" requires \
|
||||
slack_bot_token"
|
||||
);
|
||||
return None;
|
||||
}
|
||||
if config.slack_signing_secret.as_ref().is_none_or(|s| s.is_empty()) {
|
||||
eprintln!(
|
||||
"[bot] bot.toml: transport=\"slack\" requires \
|
||||
slack_signing_secret"
|
||||
);
|
||||
return None;
|
||||
}
|
||||
if config.slack_channel_ids.is_empty() {
|
||||
eprintln!(
|
||||
"[bot] bot.toml: transport=\"slack\" requires \
|
||||
at least one slack_channel_ids entry"
|
||||
);
|
||||
return None;
|
||||
}
|
||||
} else if config.room_ids.is_empty() {
|
||||
eprintln!(
|
||||
"[matrix-bot] bot.toml has no room_ids configured — \
|
||||
@@ -680,6 +716,97 @@ enabled = true
|
||||
transport = "whatsapp"
|
||||
whatsapp_phone_number_id = "123456"
|
||||
whatsapp_access_token = "EAAtoken"
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
assert!(BotConfig::load(tmp.path()).is_none());
|
||||
}
|
||||
|
||||
// ── Slack config tests ─────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn load_slack_transport_reads_config() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let sk = tmp.path().join(".story_kit");
|
||||
fs::create_dir_all(&sk).unwrap();
|
||||
fs::write(
|
||||
sk.join("bot.toml"),
|
||||
r#"
|
||||
homeserver = "https://matrix.example.com"
|
||||
username = "@bot:example.com"
|
||||
password = "secret"
|
||||
enabled = true
|
||||
transport = "slack"
|
||||
slack_bot_token = "xoxb-123"
|
||||
slack_signing_secret = "secret123"
|
||||
slack_channel_ids = ["C01ABCDEF"]
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
let config = BotConfig::load(tmp.path()).unwrap();
|
||||
assert_eq!(config.transport, "slack");
|
||||
assert_eq!(config.slack_bot_token.as_deref(), Some("xoxb-123"));
|
||||
assert_eq!(config.slack_signing_secret.as_deref(), Some("secret123"));
|
||||
assert_eq!(config.slack_channel_ids, vec!["C01ABCDEF"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn load_slack_returns_none_when_missing_bot_token() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let sk = tmp.path().join(".story_kit");
|
||||
fs::create_dir_all(&sk).unwrap();
|
||||
fs::write(
|
||||
sk.join("bot.toml"),
|
||||
r#"
|
||||
homeserver = "https://matrix.example.com"
|
||||
username = "@bot:example.com"
|
||||
password = "secret"
|
||||
enabled = true
|
||||
transport = "slack"
|
||||
slack_signing_secret = "secret123"
|
||||
slack_channel_ids = ["C01ABCDEF"]
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
assert!(BotConfig::load(tmp.path()).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn load_slack_returns_none_when_missing_signing_secret() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let sk = tmp.path().join(".story_kit");
|
||||
fs::create_dir_all(&sk).unwrap();
|
||||
fs::write(
|
||||
sk.join("bot.toml"),
|
||||
r#"
|
||||
homeserver = "https://matrix.example.com"
|
||||
username = "@bot:example.com"
|
||||
password = "secret"
|
||||
enabled = true
|
||||
transport = "slack"
|
||||
slack_bot_token = "xoxb-123"
|
||||
slack_channel_ids = ["C01ABCDEF"]
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
assert!(BotConfig::load(tmp.path()).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn load_slack_returns_none_when_missing_channel_ids() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let sk = tmp.path().join(".story_kit");
|
||||
fs::create_dir_all(&sk).unwrap();
|
||||
fs::write(
|
||||
sk.join("bot.toml"),
|
||||
r#"
|
||||
homeserver = "https://matrix.example.com"
|
||||
username = "@bot:example.com"
|
||||
password = "secret"
|
||||
enabled = true
|
||||
transport = "slack"
|
||||
slack_bot_token = "xoxb-123"
|
||||
slack_signing_secret = "secret123"
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
@@ -62,9 +62,12 @@ pub fn spawn_bot(
|
||||
}
|
||||
};
|
||||
|
||||
// WhatsApp transport is handled via HTTP webhooks, not the Matrix sync loop.
|
||||
if config.transport == "whatsapp" {
|
||||
crate::slog!("[bot] transport=whatsapp — skipping Matrix bot; webhooks handle WhatsApp");
|
||||
// WhatsApp and Slack transports are handled via HTTP webhooks, not the Matrix sync loop.
|
||||
if config.transport == "whatsapp" || config.transport == "slack" {
|
||||
crate::slog!(
|
||||
"[bot] transport={} — skipping Matrix bot; webhooks handle this transport",
|
||||
config.transport
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
1180
server/src/slack.rs
Normal file
1180
server/src/slack.rs
Normal file
File diff suppressed because it is too large
Load Diff
@@ -83,4 +83,15 @@ mod tests {
|
||||
fn assert_send_sync<T: Send + Sync>() {}
|
||||
assert_send_sync::<crate::matrix::transport_impl::MatrixTransport>();
|
||||
}
|
||||
|
||||
/// Verify that SlackTransport satisfies the ChatTransport trait and
|
||||
/// can be used as `Arc<dyn ChatTransport>` (compile-time check).
|
||||
#[test]
|
||||
fn slack_transport_satisfies_trait() {
|
||||
fn assert_transport<T: ChatTransport>() {}
|
||||
assert_transport::<crate::slack::SlackTransport>();
|
||||
|
||||
let _: Arc<dyn ChatTransport> =
|
||||
Arc::new(crate::slack::SlackTransport::new("xoxb-test".to_string()));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user