use std::collections::HashMap;
use std::sync::OnceLock;
use helix_core::effect::Effect;
use helix_core::{AuthKind, Correlation};
use serde_json::Value;
use crate::error::ImError;
mod request;
use request::http_request;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub(crate) enum Gateway {
Im,
Default,
}
pub(crate) trait OutboundCommand: Sync {
fn name(&self) -> &'static str;
fn build(&self, args: &Value) -> Result<(&'static str, Value), ImError>;
fn path_override(&self, _args: &Value) -> Option<Result<String, ImError>> {
None
}
fn auth_kind(&self) -> AuthKind {
AuthKind::Session
}
fn method(&self) -> &'static str {
outbound_method(self.name())
}
fn is_read(&self) -> bool {
false
}
fn gateway(&self) -> Gateway {
Gateway::Im
}
}
pub(crate) struct OutboundRegistration {
pub(crate) name: &'static str,
pub(crate) command: &'static dyn OutboundCommand,
}
inventory::collect!(OutboundRegistration);
static OUTBOUND_MAP: OnceLock<
Result<HashMap<&'static str, &'static dyn OutboundCommand>, ImError>,
> = OnceLock::new();
fn registry() -> Result<&'static HashMap<&'static str, &'static dyn OutboundCommand>, ImError> {
OUTBOUND_MAP
.get_or_init(|| build_registry(inventory::iter::<OutboundRegistration>))
.as_ref()
.map_err(Clone::clone)
}
fn build_registry(
entries: impl IntoIterator<Item = &'static OutboundRegistration>,
) -> Result<HashMap<&'static str, &'static dyn OutboundCommand>, ImError> {
let entries = entries.into_iter();
let mut map = HashMap::with_capacity(entries.size_hint().0);
for entry in entries {
let cmd_name = entry.command.name();
if entry.name != cmd_name {
return Err(ImError::InvalidWsHandlerRegistration {
registered: entry.name.to_string(),
handler: cmd_name.to_string(),
});
}
if map.insert(entry.name, entry.command).is_some() {
return Err(ImError::DuplicateWsAction(entry.name.to_string()));
}
}
Ok(map)
}
pub fn is_outbound(name: &str) -> bool {
registry().map(|m| m.contains_key(name)).unwrap_or(false)
}
pub fn canonical_command_name(name: &str) -> Option<&'static str> {
registry()
.ok()
.and_then(|m| m.get_key_value(name))
.map(|(k, _)| *k)
}
pub fn is_read(name: &str) -> bool {
registry()
.ok()
.and_then(|m| m.get(name))
.map(|cmd| cmd.is_read())
.unwrap_or(false)
}
pub fn outbound_command_count() -> usize {
registry().map(HashMap::len).unwrap_or(0)
}
pub fn outbound_command_names() -> Vec<&'static str> {
let mut names: Vec<&'static str> = registry()
.map(|m| m.keys().copied().collect())
.unwrap_or_default();
names.sort_unstable();
names
}
fn outbound_method(name: &str) -> &'static str {
match name {
"im_team_quit" | "im_bot_agent_config_delete" | "im_bot_agent_channel_remove" => "DELETE",
"im_bot_list_visible"
| "im_bot_token_list"
| "im_bot_visible_user_list"
| "im_bot_agent_config_get"
| "im_bot_agent_configs_enabled"
| "im_bot_agent_channel_bots"
| "im_bot_agent_channel_available_bots"
| "im_bot_agent_info"
| "im_bot_agent_team_members"
| "im_bot_agent_get_user"
| "im_bot_agent_team_channel"
| "im_webhook_config_get" => "GET",
_ => "POST",
}
}
pub fn handle_outbound(
name: &str,
payload: &[u8],
api_base_url: &str,
default_api_base_url: &str,
connection_id: Option<&str>,
corr: Correlation,
) -> Result<Vec<Effect>, ImError> {
let command = *registry()?
.get(name)
.ok_or_else(|| ImError::Parse(format!("未认领的 outbound 命令 '{name}'")))?;
let args: Value = serde_json::from_slice(payload)
.map_err(|e| ImError::Parse(format!("{name} payload: {e}")))?;
let request_id = args.get("req_id").and_then(Value::as_str);
let (static_path, body) = command.build(&args)?;
let path: std::borrow::Cow<'static, str> = match command.path_override(&args) {
Some(dynamic) => std::borrow::Cow::Owned(dynamic?),
None => std::borrow::Cow::Borrowed(static_path),
};
let (base, config_name) = match command.gateway() {
Gateway::Im => (api_base_url, "api_base_url"),
Gateway::Default => (default_api_base_url, "default_api_base_url"),
};
if base.trim().is_empty() {
return Err(ImError::Parse(format!(
"{name}: host 未注入 {config_name},拒绝把请求回落到其它网关"
)));
}
Ok(vec![http_request(
base,
command.method(),
&path,
body,
command.auth_kind(),
connection_id,
request_id,
corr,
)])
}
pub(crate) fn require_str<'a>(args: &'a Value, key: &str, cmd: &str) -> Result<&'a str, ImError> {
args.get(key)
.and_then(Value::as_str)
.filter(|s| !s.is_empty())
.ok_or_else(|| ImError::Parse(format!("{cmd}: 缺/空必填字段 '{key}'")))
}
pub(crate) fn build_creator_member_users(
args: &Value,
cmd: &str,
) -> Result<(Vec<Value>, Vec<String>), ImError> {
let self_id = require_str(args, "self_id", cmd)?;
let team_id = args.get("team_id").and_then(Value::as_str).unwrap_or("");
let mut users = vec![serde_json::json!({
"id": self_id,
"teamId": team_id,
"role": "CREATOR",
})];
let mut user_ids = vec![self_id.to_string()];
if let Some(arr) = args.get("member_ids").and_then(Value::as_array) {
for m in arr {
if let Some(mid) = m.as_str() {
if !mid.is_empty() && mid != self_id {
users.push(serde_json::json!({
"id": mid,
"teamId": team_id,
"role": "MEMBER",
}));
user_ids.push(mid.to_string());
}
}
}
}
Ok((users, user_ids))
}
#[cfg(test)]
#[path = "registry_tests.rs"]
mod tests;