use std::borrow::Cow;
use std::future::Future;
use std::sync::{Arc, Mutex, OnceLock};
use std::time::Duration;
use autumn_web::actuator::{HealthIndicator, MetricsSource};
use autumn_web::app::AppBuilder;
use autumn_web::config::AutumnConfig;
use autumn_web::plugin::Plugin;
use autumn_web::plugin_contract::PluginContract;
use autumn_web::reexports::axum::Router;
use autumn_web::time::{ClockSource, SystemClock};
use autumn_web::webhook::{InMemoryWebhookReplayStore, WebhookReplayStore};
use autumn_web::{AppState, AutumnError, ProcessRole};
use tokio_util::task::TaskTracker;
use crate::activity::{Activity, InvokeResponse};
use crate::auth::{KeySource, OpenIdKeySource, Verifier, VerifierRules};
use crate::client::TeamsClient;
use crate::config::{SECTION, TeamsConfig, env_var};
use crate::engine::Engine;
use crate::error::{HandlerResult, TeamsError};
use crate::handlers::{Kind, Registration, Registry, Runner, TeamsContext, boxed};
use crate::health::TeamsHealth;
use crate::metrics::TeamsMetrics;
use crate::routes::{self, EngineCell, MESSAGES};
use crate::token::{Credentials, TokenProvider};
use crate::transport::{HttpTransport, ReqwestTransport};
pub const PLUGIN_NAME: &str = "autumn-plugin-teams";
pub const DEFAULT_BASE_PATH: &str = "/teams";
pub struct TeamsPlugin {
config: Option<TeamsConfig>,
base_path: String,
readiness: bool,
app_id: Option<String>,
app_password: Option<String>,
tenant_id: Option<String>,
transport: Option<Arc<dyn HttpTransport>>,
clock: Option<Arc<dyn ClockSource>>,
keys: Option<Arc<dyn KeySource>>,
replay: Option<Arc<dyn WebhookReplayStore>>,
registrations: Vec<Registration>,
metrics: Arc<TeamsMetrics>,
}
impl std::fmt::Debug for TeamsPlugin {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("TeamsPlugin")
.field("base_path", &self.base_path)
.field(
"handlers",
&self
.registrations
.iter()
.map(|r| {
let (k, key) = r.describe();
(k.as_str(), key.to_owned())
})
.collect::<Vec<_>>(),
)
.finish_non_exhaustive()
}
}
impl Default for TeamsPlugin {
fn default() -> Self {
Self::new()
}
}
macro_rules! background {
($(#[$doc:meta])* $name:ident, $kind:expr) => {
$(#[$doc])*
#[must_use]
pub fn $name<F, Fut>(self, handler: F) -> Self
where
F: Fn(TeamsContext, Activity) -> Fut + Send + Sync + 'static,
Fut: Future<Output = HandlerResult<()>> + Send + 'static,
{
self.register(Registration::Background($kind, String::new(), boxed(handler)))
}
};
}
impl TeamsPlugin {
#[must_use]
pub fn new() -> Self {
Self {
config: None,
base_path: DEFAULT_BASE_PATH.to_owned(),
readiness: false,
app_id: None,
app_password: None,
tenant_id: None,
transport: None,
clock: None,
keys: None,
replay: None,
registrations: Vec::new(),
metrics: Arc::new(TeamsMetrics::new()),
}
}
#[must_use]
pub fn with_config(config: TeamsConfig) -> Self {
Self {
config: Some(config),
..Self::new()
}
}
#[must_use]
pub fn base_path(mut self, path: &str) -> Self {
self.base_path = normalize_base(path);
self
}
#[must_use]
pub const fn readiness(mut self, on: bool) -> Self {
self.readiness = on;
self
}
#[must_use]
pub fn with_app_id(mut self, app_id: impl Into<String>) -> Self {
self.app_id = Some(app_id.into());
self
}
#[must_use]
pub fn with_app_password(mut self, password: impl Into<String>) -> Self {
self.app_password = Some(password.into());
self
}
#[must_use]
pub fn with_tenant_id(mut self, tenant_id: impl Into<String>) -> Self {
self.tenant_id = Some(tenant_id.into());
self
}
#[must_use]
pub fn with_transport(self, transport: impl HttpTransport) -> Self {
self.with_transport_arc(Arc::new(transport))
}
#[must_use]
pub fn with_transport_arc(mut self, transport: Arc<dyn HttpTransport>) -> Self {
self.transport = Some(transport);
self
}
#[must_use]
pub fn with_clock(mut self, clock: Arc<dyn ClockSource>) -> Self {
self.clock = Some(clock);
self
}
#[must_use]
pub fn with_key_source(mut self, keys: impl KeySource) -> Self {
self.keys = Some(Arc::new(keys));
self
}
#[must_use]
pub fn with_replay_store(mut self, store: Arc<dyn WebhookReplayStore>) -> Self {
self.replay = Some(store);
self
}
fn register(mut self, reg: Registration) -> Self {
self.registrations.push(reg);
self
}
background!(
on_message,
Kind::Message
);
#[must_use]
pub fn command<F, Fut>(self, word: &str, handler: F) -> Self
where
F: Fn(TeamsContext, Activity) -> Fut + Send + Sync + 'static,
Fut: Future<Output = HandlerResult<()>> + Send + 'static,
{
self.register(Registration::Background(
Kind::Command,
word.to_owned(),
boxed(handler),
))
}
background!(
on_message_update,
Kind::MessageUpdate
);
background!(
on_message_delete,
Kind::MessageDelete
);
background!(
on_members_added,
Kind::MembersAdded
);
background!(
on_members_removed,
Kind::MembersRemoved
);
background!(
on_conversation_update,
Kind::ConversationUpdate
);
background!(
on_reaction,
Kind::Reaction
);
background!(
on_installation_update,
Kind::InstallationUpdate
);
#[must_use]
pub fn on_event<F, Fut>(self, name: &str, handler: F) -> Self
where
F: Fn(TeamsContext, Activity) -> Fut + Send + Sync + 'static,
Fut: Future<Output = HandlerResult<()>> + Send + 'static,
{
self.register(Registration::Background(
Kind::Event,
name.to_owned(),
boxed(handler),
))
}
#[must_use]
pub fn invoke<F, Fut>(self, name: &str, handler: F) -> Self
where
F: Fn(TeamsContext, Activity) -> Fut + Send + Sync + 'static,
Fut: Future<Output = HandlerResult<InvokeResponse>> + Send + 'static,
{
self.register(Registration::Invoke(
Kind::Invoke,
name.to_owned(),
boxed(handler),
))
}
#[must_use]
pub fn card_action<F, Fut>(self, verb: &str, handler: F) -> Self
where
F: Fn(TeamsContext, Activity) -> Fut + Send + Sync + 'static,
Fut: Future<Output = HandlerResult<InvokeResponse>> + Send + 'static,
{
self.register(Registration::Invoke(
Kind::CardAction,
verb.to_owned(),
boxed(handler),
))
}
#[must_use]
pub fn metrics(&self) -> Arc<TeamsMetrics> {
Arc::clone(&self.metrics)
}
pub fn start(self, state: &AppState) -> Result<TeamsRuntime, TeamsError> {
let role = state.role();
self.start_with_role(state, role)
}
pub fn start_with_role(
self,
state: &AppState,
role: ProcessRole,
) -> Result<TeamsRuntime, TeamsError> {
let health = Arc::new(TeamsHealth::new(self.readiness));
self.start_inner(state, role, health)
}
#[allow(clippy::too_many_lines)] fn start_inner(
self,
state: &AppState,
role: ProcessRole,
health: Arc<TeamsHealth>,
) -> Result<TeamsRuntime, TeamsError> {
let config = match self.config {
Some(c) => c,
None => TeamsConfig::load(Some(state.profile()).filter(|p| *p != "default"))?,
};
config.validate()?;
let inbound = role.serves_http();
if inbound {
check_security(&state.config(), &self.base_path)?;
}
let registry = Registry::build(self.registrations)?;
let secret = |given: Option<String>, env: &str| {
given
.or_else(|| env_var(env))
.map(|v| v.trim().to_owned())
.filter(|v| !v.is_empty())
};
let app_id = secret(self.app_id, &config.app_id_env);
let password = secret(self.app_password, &config.app_password_env);
let tenant_id = secret(self.tenant_id, &config.tenant_id_env);
let missing =
|what: &str, env: &str| TeamsError::Config(format!("no {what}; set the env var {env}"));
if inbound {
if app_id.is_none() {
return Err(missing("app ID", &config.app_id_env));
}
if password.is_none() {
return Err(missing("app password", &config.app_password_env));
}
} else {
if app_id.is_none() {
tracing::warn!(env = %config.app_id_env, "teams: no app ID; Connector calls fail");
}
if password.is_none() {
tracing::warn!(env = %config.app_password_env, "teams: no app password; Connector calls fail");
}
}
let app_id = app_id.unwrap_or_default();
let transport: Arc<dyn HttpTransport> = match self.transport {
Some(t) => t,
None => Arc::new(
ReqwestTransport::new(Duration::from_millis(config.api.timeout_ms))
.map_err(|e| TeamsError::Config(e.0))?,
),
};
let clock = self.clock.unwrap_or_else(|| Arc::new(SystemClock));
let tokens = Arc::new(TokenProvider::new(
Arc::clone(&transport),
Credentials {
app_id: app_id.clone(),
password,
password_env: config.app_password_env.clone(),
token_url: config.token_url(tenant_id.as_deref()),
scope: config.oauth_scope.clone(),
},
Arc::clone(&clock),
Arc::clone(&self.metrics),
config.api.rule(),
));
let client = TeamsClient::new(
Arc::clone(&transport),
config.clone(),
tokens,
Arc::clone(&self.metrics),
);
health.set(
client.clone(),
Duration::from_secs(config.health.cache_secs),
);
state.insert_extension(client.clone());
let keys = self.keys.unwrap_or_else(|| {
Arc::new(OpenIdKeySource::new(
Arc::clone(&transport),
config.openid_metadata_url.clone(),
))
});
let verifier = Verifier::new(
VerifierRules {
app_id,
issuers: config.issuers.clone(),
clock_skew_secs: config.clock_skew_secs,
key_cache_secs: config.keys.cache_secs,
key_min_refresh_secs: config.keys.min_refresh_secs,
},
keys,
Arc::clone(&self.metrics),
Arc::clone(&clock),
);
tracing::info!(
base_path = %self.base_path,
handlers = registry.len(),
single_tenant = tenant_id.is_some(),
"teams started"
);
let engine = Engine {
verifier,
registry,
client,
metrics: Arc::clone(&self.metrics),
clock,
replay: self
.replay
.unwrap_or_else(|| Arc::new(InMemoryWebhookReplayStore::default())),
runner: Runner {
tracker: TaskTracker::new(),
metrics: Arc::clone(&self.metrics),
},
state: state.clone(),
config,
};
Ok(TeamsRuntime {
engine: Arc::new(engine),
health,
})
}
}
fn normalize_base(path: &str) -> String {
let trimmed = path.trim().trim_matches('/');
if trimmed.is_empty() {
String::new()
} else {
format!("/{trimmed}")
}
}
fn route_path(base: &str) -> String {
format!("{base}{MESSAGES}")
}
fn is_exempt(path: &str, exempt: &[String]) -> bool {
exempt.iter().any(|p| {
let p = p.as_str();
path == p
|| (path.starts_with(p)
&& (p.ends_with('/') || path.as_bytes().get(p.len()) == Some(&b'/')))
})
}
fn check_security(cfg: &AutumnConfig, base: &str) -> Result<(), TeamsError> {
let path = route_path(base);
let fix = if base.is_empty() { &path } else { base };
let mut exempt = cfg.security.csrf.exempt_paths.clone();
exempt.extend(
cfg.security
.webhooks
.endpoints
.iter()
.map(|e| e.path.clone()),
);
if cfg.security.csrf.enabled && !is_exempt(&path, &exempt) {
return Err(TeamsError::Config(format!(
"CSRF protection blocks {path}. Teams sends no CSRF token. \
Add [\"{fix}\"] to [security.csrf] exempt_paths. \
The Bot Framework token check protects this route."
)));
}
Ok(())
}
pub struct TeamsRuntime {
engine: Arc<Engine>,
health: Arc<TeamsHealth>,
}
impl std::fmt::Debug for TeamsRuntime {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("TeamsRuntime")
.field("in_flight", &self.in_flight())
.finish_non_exhaustive()
}
}
impl TeamsRuntime {
#[must_use]
pub fn client(&self) -> TeamsClient {
self.engine.client.clone()
}
#[must_use]
pub fn metrics(&self) -> Arc<TeamsMetrics> {
Arc::clone(&self.engine.metrics)
}
#[must_use]
pub fn health(&self) -> Arc<TeamsHealth> {
Arc::clone(&self.health)
}
#[must_use]
pub fn in_flight(&self) -> u64 {
self.engine.metrics.in_flight()
}
pub fn router(&self) -> Router {
let cell: EngineCell = Arc::new(OnceLock::new());
let _ = cell.set(Arc::clone(&self.engine));
routes::router(&cell)
}
pub async fn shutdown(&self) {
drain(&self.engine).await;
}
}
async fn drain(engine: &Engine) {
let tracker = &engine.runner.tracker;
tracker.close();
let limit = Duration::from_secs(engine.config.drain_timeout_secs);
if tokio::time::timeout(limit, tracker.wait()).await.is_err() {
tracing::warn!(
left = tracker.len(),
"teams: handlers still run after the drain timeout"
);
}
}
impl Plugin for TeamsPlugin {
fn name(&self) -> Cow<'static, str> {
Cow::Borrowed(PLUGIN_NAME)
}
fn contract(&self) -> Option<PluginContract> {
Some(
PluginContract::new(env!("CARGO_PKG_NAME"))
.plugin_version(env!("CARGO_PKG_VERSION"))
.autumn_web("0.9"),
)
}
fn build(self, app: AppBuilder) -> AppBuilder {
let base = self.base_path.clone();
let cell: EngineCell = Arc::new(OnceLock::new());
let health = Arc::new(TeamsHealth::new(self.readiness));
let metrics = Arc::clone(&self.metrics);
let failure: Arc<Mutex<Option<TeamsError>>> = Arc::default();
let routes = routes::autumn_routes(&cell, &base);
let (init_cell, init_health, init_failure) =
(Arc::clone(&cell), Arc::clone(&health), Arc::clone(&failure));
let (check_cell, stop_cell) = (Arc::clone(&cell), Arc::clone(&cell));
app.config_section(SECTION)
.metrics_source("teams", metrics as Arc<dyn MetricsSource>)
.health_indicator("teams", health as Arc<dyn HealthIndicator>)
.routes(routes)
.state_initializer(move |state| {
match self.start_inner(state, state.role(), init_health) {
Ok(rt) => {
let _ = init_cell.set(rt.engine);
}
Err(e) => {
tracing::error!(error = %e, "teams plugin did not start");
*init_failure
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner) = Some(e);
}
}
})
.on_startup(move |_state| {
let failed = failure
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.take();
let started = check_cell.get().is_some();
async move {
match failed {
Some(e) => Err(AutumnError::internal_server_error(e)),
None if !started => Err(AutumnError::internal_server_error(
TeamsError::Config("the teams plugin did not start".to_owned()),
)),
None => Ok(()),
}
}
})
.on_shutdown(move || shutdown_hook(&stop_cell))
}
}
fn shutdown_hook(cell: &EngineCell) -> impl Future<Output = ()> + Send + use<> {
let engine = cell.get().cloned();
async move {
if let Some(engine) = engine {
drain(&engine).await;
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn activities_get_503_when_shutdown_starts_after_the_route_check() {
use autumn_web::reexports::axum::{body::Body, http::HeaderMap};
let authority = crate::testing::TestAuthority::new(crate::testing::APP_ID);
let rt = TeamsPlugin::with_config(TeamsConfig::default())
.with_app_id(crate::testing::APP_ID)
.with_app_password("pw")
.with_transport(crate::transport::MemoryTransport::new())
.with_key_source(authority.key_source())
.on_message(|_c, _a| async { Ok(()) })
.start(&AppState::for_test())
.unwrap();
let now = u64::try_from(autumn_web::reexports::chrono::Utc::now().timestamp()).unwrap();
let activity = crate::testing::message("x");
let mut headers = HeaderMap::new();
headers.insert(
"authorization",
authority.bearer(&activity, now).parse().unwrap(),
);
rt.engine.runner.tracker.close();
let body = Body::from(serde_json::to_string(&activity).unwrap());
let res = Arc::clone(&rt.engine).messages(&headers, body).await;
assert_eq!(res.status().as_u16(), 503);
assert_eq!(rt.metrics().requests("shutting_down"), 1);
}
#[tokio::test]
async fn a_broken_body_gets_400_not_413() {
use autumn_web::reexports::axum::{body::Body, http::HeaderMap};
let authority = crate::testing::TestAuthority::new(crate::testing::APP_ID);
let rt = TeamsPlugin::with_config(TeamsConfig::default())
.with_app_id(crate::testing::APP_ID)
.with_app_password("pw")
.with_transport(crate::transport::MemoryTransport::new())
.with_key_source(authority.key_source())
.start(&AppState::for_test())
.unwrap();
let now = u64::try_from(autumn_web::reexports::chrono::Utc::now().timestamp()).unwrap();
let mut headers = HeaderMap::new();
headers.insert(
"authorization",
authority
.bearer(&crate::testing::message("x"), now)
.parse()
.unwrap(),
);
let broken = futures::stream::iter([Err::<Vec<u8>, std::io::Error>(
std::io::Error::other("client went away"),
)]);
let res = Arc::clone(&rt.engine)
.messages(&headers, Body::from_stream(broken))
.await;
assert_eq!(res.status().as_u16(), 400);
}
#[test]
fn base_path_normalizes() {
assert_eq!(normalize_base("/teams/"), "/teams");
assert_eq!(normalize_base("hooks/teams"), "/hooks/teams");
assert_eq!(normalize_base("/"), "");
assert_eq!(route_path(""), "/messages");
}
#[test]
fn exempt_rule_matches_autumn() {
let ex = |list: &[&str]| list.iter().map(|s| (*s).to_owned()).collect::<Vec<_>>();
assert!(is_exempt("/teams/messages", &ex(&["/teams"])));
assert!(is_exempt("/teams/messages", &ex(&["/teams/"])));
assert!(is_exempt("/teams/messages", &ex(&["/teams/messages"])));
assert!(!is_exempt("/teams/messages", &ex(&["/tea"])));
assert!(!is_exempt("/teams/messages", &ex(&["/teams/m"])));
assert!(is_exempt("/teams/messages", &ex(&[""])));
}
#[test]
fn csrf_check_names_the_fix() {
let mut cfg = AutumnConfig::default();
check_security(&cfg, "/teams").unwrap();
cfg.security.csrf.enabled = true;
let e = check_security(&cfg, "/teams").unwrap_err().to_string();
assert!(e.contains("[\"/teams\"]"), "{e}");
let e = check_security(&cfg, "").unwrap_err().to_string();
assert!(e.contains("[\"/messages\"]"), "{e}");
cfg.security.csrf.exempt_paths = vec!["/teams".into()];
check_security(&cfg, "/teams").unwrap();
let mut cfg = AutumnConfig::default();
cfg.security.csrf.enabled = true;
cfg.security
.webhooks
.endpoints
.push(autumn_web::webhook::WebhookEndpointConfig::slack(
"s",
"/teams/messages",
"S",
));
check_security(&cfg, "/teams").unwrap();
}
}