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"):
|
# Once approved, set the name below (default: "pipeline_notification"):
|
||||||
# whatsapp_notification_template = "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::path::{Path, PathBuf};
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use crate::slack::SlackWebhookContext;
|
||||||
use crate::whatsapp::WhatsAppWebhookContext;
|
use crate::whatsapp::WhatsAppWebhookContext;
|
||||||
|
|
||||||
const DEFAULT_PORT: u16 = 3001;
|
const DEFAULT_PORT: u16 = 3001;
|
||||||
@@ -56,6 +57,7 @@ pub fn remove_port_file(path: &Path) {
|
|||||||
pub fn build_routes(
|
pub fn build_routes(
|
||||||
ctx: AppContext,
|
ctx: AppContext,
|
||||||
whatsapp_ctx: Option<Arc<WhatsAppWebhookContext>>,
|
whatsapp_ctx: Option<Arc<WhatsAppWebhookContext>>,
|
||||||
|
slack_ctx: Option<Arc<SlackWebhookContext>>,
|
||||||
) -> impl poem::Endpoint {
|
) -> impl poem::Endpoint {
|
||||||
let ctx_arc = std::sync::Arc::new(ctx);
|
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)
|
route.data(ctx_arc)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -196,6 +205,6 @@ mod tests {
|
|||||||
fn build_routes_constructs_without_panic() {
|
fn build_routes_constructs_without_panic() {
|
||||||
let tmp = tempfile::tempdir().unwrap();
|
let tmp = tempfile::tempdir().unwrap();
|
||||||
let ctx = context::AppContext::new_test(tmp.path().to_path_buf());
|
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;
|
mod store;
|
||||||
pub mod transport;
|
pub mod transport;
|
||||||
mod workflow;
|
mod workflow;
|
||||||
|
pub mod slack;
|
||||||
pub mod whatsapp;
|
pub mod whatsapp;
|
||||||
mod worktree;
|
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
|
// Optional Matrix bot: connect to the homeserver and start listening for
|
||||||
|
|||||||
@@ -87,6 +87,19 @@ pub struct BotConfig {
|
|||||||
/// use. Defaults to `"pipeline_notification"`.
|
/// use. Defaults to `"pipeline_notification"`.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub whatsapp_notification_template: Option<String>,
|
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 {
|
fn default_transport() -> String {
|
||||||
@@ -142,6 +155,29 @@ impl BotConfig {
|
|||||||
);
|
);
|
||||||
return None;
|
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() {
|
} else if config.room_ids.is_empty() {
|
||||||
eprintln!(
|
eprintln!(
|
||||||
"[matrix-bot] bot.toml has no room_ids configured — \
|
"[matrix-bot] bot.toml has no room_ids configured — \
|
||||||
@@ -680,6 +716,97 @@ enabled = true
|
|||||||
transport = "whatsapp"
|
transport = "whatsapp"
|
||||||
whatsapp_phone_number_id = "123456"
|
whatsapp_phone_number_id = "123456"
|
||||||
whatsapp_access_token = "EAAtoken"
|
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();
|
.unwrap();
|
||||||
|
|||||||
@@ -62,9 +62,12 @@ pub fn spawn_bot(
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// WhatsApp transport is handled via HTTP webhooks, not the Matrix sync loop.
|
// WhatsApp and Slack transports are handled via HTTP webhooks, not the Matrix sync loop.
|
||||||
if config.transport == "whatsapp" {
|
if config.transport == "whatsapp" || config.transport == "slack" {
|
||||||
crate::slog!("[bot] transport=whatsapp — skipping Matrix bot; webhooks handle WhatsApp");
|
crate::slog!(
|
||||||
|
"[bot] transport={} — skipping Matrix bot; webhooks handle this transport",
|
||||||
|
config.transport
|
||||||
|
);
|
||||||
return;
|
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>() {}
|
fn assert_send_sync<T: Send + Sync>() {}
|
||||||
assert_send_sync::<crate::matrix::transport_impl::MatrixTransport>();
|
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