use std::borrow::Cow;
use bytes::Bytes;
use helix_core::tick::AppCommand;
use serde_json::{json, Map, Value};
use crate::error::ImError;
pub struct CommandAlias {
pub public: &'static str,
pub core: &'static str,
}
pub const COMMAND_ALIASES: &[CommandAlias] = &[
CommandAlias {
public: "im_forward_detail",
core: "im_forward_detail",
},
CommandAlias {
public: "im_cancel_forward_detail",
core: "im_cancel_forward_detail",
},
CommandAlias {
public: "im_category_chain_capabilities",
core: "category_chain_capabilities",
},
CommandAlias {
public: "category_chain_capabilities",
core: "category_chain_capabilities",
},
CommandAlias {
public: "category_chain_create_draft",
core: "category_chain_create_draft",
},
CommandAlias {
public: "im_category_chain_create_draft",
core: "category_chain_create_draft",
},
CommandAlias {
public: "category_chain_update_draft",
core: "category_chain_update_draft",
},
CommandAlias {
public: "im_category_chain_update_draft",
core: "category_chain_update_draft",
},
CommandAlias {
public: "category_chain_publish",
core: "category_chain_publish",
},
CommandAlias {
public: "im_category_chain_publish",
core: "category_chain_publish",
},
CommandAlias {
public: "category_chain_get",
core: "category_chain_get",
},
CommandAlias {
public: "im_category_chain_get",
core: "category_chain_get",
},
CommandAlias {
public: "category_chain_get_mine",
core: "category_chain_get_mine",
},
CommandAlias {
public: "im_category_chain_get_mine",
core: "category_chain_get_mine",
},
CommandAlias {
public: "category_chain_set_participation",
core: "category_chain_set_participation",
},
CommandAlias {
public: "im_category_chain_set_participation",
core: "category_chain_set_participation",
},
CommandAlias {
public: "category_chain_entries",
core: "category_chain_entries",
},
CommandAlias {
public: "im_category_chain_entries",
core: "category_chain_entries",
},
CommandAlias {
public: "category_chain_close",
core: "category_chain_close",
},
CommandAlias {
public: "im_category_chain_close",
core: "category_chain_close",
},
CommandAlias {
public: "category_chain_retract",
core: "category_chain_retract",
},
CommandAlias {
public: "im_category_chain_retract",
core: "category_chain_retract",
},
CommandAlias {
public: "category_chain_reconcile",
core: "category_chain_reconcile",
},
CommandAlias {
public: "im_category_chain_reconcile",
core: "category_chain_reconcile",
},
CommandAlias {
public: "im_send",
core: "im_send_message",
},
CommandAlias {
public: "im_relay_messages",
core: "im_create_posts",
},
CommandAlias {
public: "im_read_channel",
core: "im_channels_view",
},
CommandAlias {
public: "im_sync_channels",
core: "im_sync_channels",
},
CommandAlias {
public: "im_ensure_channel_loaded",
core: "im_channel_load_increment_by_channel_id",
},
CommandAlias {
public: "im_locate_post",
core: "im_get_posts",
},
CommandAlias {
public: "im_channel_create",
core: "im_create_channel",
},
CommandAlias {
public: "im_member_leave",
core: "im_channel_leave",
},
CommandAlias {
public: "im_channel_settings",
core: "im_channel_change_info",
},
CommandAlias {
public: "im_post_pin",
core: "im_set_message_top",
},
CommandAlias {
public: "im_save_announcement",
core: "im_announcement_save",
},
CommandAlias {
public: "im_announcement_write",
core: "im_announcement_save",
},
CommandAlias {
public: "im_get_schedule",
core: "im_get_schedule",
},
CommandAlias {
public: "im_create_schedule",
core: "im_create_schedule",
},
CommandAlias {
public: "im_cancel_schedule",
core: "im_cancel_schedule",
},
CommandAlias {
public: "im_send_message",
core: "im_send_message",
},
CommandAlias {
public: "im_save_draft",
core: "im_save_draft",
},
CommandAlias {
public: "im_query_draft",
core: "im_query_draft",
},
CommandAlias {
public: "im_online_status",
core: "im_channel_online_status",
},
CommandAlias {
public: "im_members_by_ids",
core: "im_channels_members_by_ids",
},
CommandAlias {
public: "im_list_candidate_users",
core: "im_user_candidates",
},
CommandAlias {
public: "im_channel_admin_change",
core: "im_channel_member_role",
},
CommandAlias {
public: "im_member_nickname_change",
core: "im_update_member_nickname",
},
CommandAlias {
public: "im_todo_query",
core: "im_query_todo_list",
},
CommandAlias {
public: "im_system_notice_query",
core: "im_load_target_notifications",
},
CommandAlias {
public: "im_module_read",
core: "im_get_all_modules",
},
CommandAlias {
public: "im_module_read_all",
core: "im_get_all_modules",
},
CommandAlias {
public: "im_company_group_upsert",
core: "im_team_upsert",
},
CommandAlias {
public: "im_company_group_maintain",
core: "im_team_member_add",
},
CommandAlias {
public: "im_company_exit",
core: "im_team_quit",
},
CommandAlias {
public: "post_chain_create",
core: "post_chain_create",
},
CommandAlias {
public: "post_chain_update_draft",
core: "post_chain_update_draft",
},
CommandAlias {
public: "post_chain_publish",
core: "post_chain_publish",
},
CommandAlias {
public: "post_chain_append",
core: "post_chain_append",
},
CommandAlias {
public: "post_chain_get",
core: "post_chain_get",
},
CommandAlias {
public: "post_chain_reconcile",
core: "post_chain_reconcile",
},
CommandAlias {
public: "post_chain_close",
core: "post_chain_close",
},
CommandAlias {
public: "post_chain_retract",
core: "post_chain_retract",
},
CommandAlias {
public: "post_chain_mark_read",
core: "post_chain_mark_read",
},
CommandAlias {
public: "im_post_chain_create",
core: "post_chain_create",
},
CommandAlias {
public: "im_post_chain_update_draft",
core: "post_chain_update_draft",
},
CommandAlias {
public: "im_post_chain_publish",
core: "post_chain_publish",
},
CommandAlias {
public: "im_post_chain_append",
core: "post_chain_append",
},
CommandAlias {
public: "im_post_chain_get",
core: "post_chain_get",
},
CommandAlias {
public: "im_post_chain_reconcile",
core: "post_chain_reconcile",
},
CommandAlias {
public: "im_post_chain_close",
core: "post_chain_close",
},
CommandAlias {
public: "im_post_chain_retract",
core: "post_chain_retract",
},
CommandAlias {
public: "im_post_chain_mark_read",
core: "post_chain_mark_read",
},
];
pub fn command_alias(public: &str) -> Option<&'static str> {
COMMAND_ALIASES
.iter()
.find(|alias| alias.public == public)
.map(|alias| alias.core)
}
pub fn normalize_command_payload(public: &str, payload: &[u8]) -> Result<Vec<u8>, ImError> {
let mut value: Value = serde_json::from_slice(payload)
.map_err(|e| ImError::Parse(format!("{public} payload: {e}")))?;
let obj = value
.as_object_mut()
.ok_or_else(|| ImError::Parse(format!("{public} payload must be object")))?;
normalize_top_level_keys(obj);
normalize_public_semantics(public, obj)?;
serde_json::to_vec(&value).map_err(|e| ImError::Serialize(e.to_string()))
}
pub fn build_command(public_name: &str, payload: &[u8]) -> Result<AppCommand, ImError> {
if public_name == crate::module::RUNTIME_IDENTITY_COMMAND {
return Err(ImError::Parse(
"reserved runtime command is not part of the public command API".to_string(),
));
}
if (public_name.starts_with("im_category_chain_") || public_name.starts_with("category_chain_"))
&& command_alias(public_name).is_none()
{
return Err(ImError::Parse("unknown category-chain command".to_owned()));
}
let normalized_payload = normalize_command_payload(public_name, payload)?;
let core_name = command_alias(public_name).unwrap_or(public_name);
let name = match crate::outbound::canonical_command_name(core_name) {
Some(static_name) => Cow::Borrowed(static_name),
None => Cow::Owned(core_name.to_string()),
};
Ok(AppCommand {
name,
payload: Bytes::from(normalized_payload),
})
}
fn normalize_top_level_keys(obj: &mut Map<String, Value>) {
let keys: Vec<String> = obj.keys().cloned().collect();
for key in keys {
let normalized = camel_to_snake(&key);
if normalized != key {
move_key(obj, &key, &normalized);
}
}
}
fn normalize_public_semantics(public: &str, obj: &mut Map<String, Value>) -> Result<(), ImError> {
match public {
"im_send" | "im_send_message" => {
obj.remove("temporary_id");
obj.remove("allocated_temporary_id");
}
"im_send_quick_reply" => move_key(obj, "reaction", "emoji"),
"im_read_channel" => {
let channel_id = obj
.get("channel_id")
.and_then(Value::as_str)
.filter(|value| !value.is_empty())
.ok_or_else(|| ImError::Parse("im_read_channel: 缺 channel_id".to_string()))?;
obj.insert(
"channels".to_string(),
json!([{ "id": channel_id, "isRoot": true }]),
);
}
"im_mark_read" => {
if !obj.contains_key("posts") {
if let Some(post_id) = obj.get("post_id").and_then(Value::as_str) {
obj.insert("posts".to_string(), json!([post_id]));
}
}
}
"im_post_read" => {
let post_ids = obj.remove("post_ids");
if obj.contains_key("posts") && post_ids.is_some() {
return Err(ImError::Parse(
"im_post_read: post_ids 与 posts 不得同时存在".to_string(),
));
}
if let Some(post_ids) = post_ids {
let post_ids = post_ids.as_array().ok_or_else(|| {
ImError::Parse("im_post_read: post_ids 必须是数组".to_string())
})?;
obj.insert("posts".to_string(), Value::Array(post_ids.clone()));
}
validate_canonical_post_read(obj)?;
}
"im_channels_view" => {
let channel_ids = obj.remove("channel_ids");
if obj.contains_key("channels") && channel_ids.is_some() {
return Err(ImError::Parse(
"im_channels_view: channel_ids 与 channels 不得同时存在".to_string(),
));
}
if let Some(channels) = obj.get("channels") {
validate_canonical_root_channels(channels, "im_channels_view")?;
} else {
let channel_ids = channel_ids.ok_or_else(|| {
ImError::Parse("im_channels_view: 缺 channel_ids".to_string())
})?;
let channel_ids = channel_ids.as_array().ok_or_else(|| {
ImError::Parse("im_channels_view: channel_ids 必须是数组".to_string())
})?;
let mut seen = std::collections::HashSet::with_capacity(channel_ids.len());
let mut channels = Vec::with_capacity(channel_ids.len());
for channel_id in channel_ids {
let channel_id = channel_id
.as_str()
.map(str::trim)
.filter(|channel_id| !channel_id.is_empty())
.ok_or_else(|| {
ImError::Parse(
"im_channels_view: channel_ids 必须是非空字符串数组".to_string(),
)
})?;
if crate::state::ChannelId::from_str(channel_id).is_none() {
return Err(ImError::Parse(format!(
"im_channels_view: 非 canonical channel id: {channel_id}"
)));
}
if seen.insert(channel_id.to_string()) {
channels.push(json!({"id": channel_id, "isRoot": true}));
}
}
if channels.is_empty() {
return Err(ImError::Parse(
"im_channels_view: channel_ids 不能为空".to_string(),
));
}
obj.insert("channels".to_string(), Value::Array(channels));
}
}
"im_get_replies" => move_key(obj, "post_id", "reply_id"),
"im_get_reply_branch" => move_key(obj, "post_id", "reply_first_level_id"),
"im_locate_post" => {
if !obj.contains_key("post_ids") {
if let Some(post_id) = obj.get("post_id").and_then(Value::as_str) {
obj.insert("post_ids".to_string(), json!([post_id]));
}
}
obj.insert("locate".to_string(), Value::Bool(true));
}
"im_create_schedule" => {
if !obj.contains_key("schedule_post_at") {
move_key(obj, "execute_at", "schedule_post_at");
}
if !obj.contains_key("schedule_post_at") {
move_key(obj, "send_at", "schedule_post_at");
}
if let Some(post) = obj.remove("post").and_then(|post| match post {
Value::Object(post) => Some(post),
_ => None,
}) {
if !obj.contains_key("message") {
if let Some(message) = post.get("message").or_else(|| post.get("text")).cloned()
{
obj.insert("message".to_string(), message);
}
}
if !obj.contains_key("type") {
if let Some(post_type) = post.get("type").cloned() {
obj.insert("type".to_string(), post_type);
}
}
if !obj.contains_key("props") {
if let Some(props) = post.get("props").cloned() {
obj.insert("props".to_string(), props);
}
}
if !obj.contains_key("temporary_id") {
if let Some(temporary_id) = post
.get("temporaryId")
.or_else(|| post.get("temporary_id"))
.cloned()
{
obj.insert("temporary_id".to_string(), temporary_id);
}
}
}
}
"im_channel_settings" => {
let settings = obj
.remove("settings")
.and_then(|value| value.as_object().cloned())
.ok_or_else(|| ImError::Parse("im_channel_settings: 缺 settings".to_string()))?;
copy_setting(
&settings,
obj,
&["displayName", "display_name"],
"display_name",
);
copy_setting(&settings, obj, &["description", "purpose"], "purpose");
copy_setting(&settings, obj, &["rules", "header"], "header");
}
_ => {}
}
Ok(())
}
fn validate_canonical_post_read(obj: &Map<String, Value>) -> Result<(), ImError> {
const ALLOWED: [&str; 2] = ["channel_id", "posts"];
if let Some(unknown) = obj.keys().find(|key| !ALLOWED.contains(&key.as_str())) {
return Err(ImError::Parse(format!("im_post_read: 未知字段 {unknown}")));
}
let channel_id = obj
.get("channel_id")
.and_then(Value::as_str)
.filter(|value| value.trim() == *value)
.and_then(crate::state::ChannelId::from_str)
.ok_or_else(|| ImError::Parse("im_post_read: 非 canonical channel_id".to_string()))?;
let _ = channel_id;
let posts = obj
.get("posts")
.and_then(Value::as_array)
.filter(|items| !items.is_empty())
.ok_or_else(|| {
ImError::Parse("im_post_read: posts 必须是非空 canonical 字符串数组".to_string())
})?;
if posts.iter().any(|post| {
post.as_str()
.filter(|value| value.trim() == *value)
.is_none_or(|value| !crate::state::is_canonical_post_id(value))
}) {
return Err(ImError::Parse(
"im_post_read: posts 只能包含 canonical post id".to_string(),
));
}
Ok(())
}
fn validate_canonical_root_channels(value: &Value, command: &str) -> Result<(), ImError> {
let channels = value
.as_array()
.filter(|items| !items.is_empty())
.ok_or_else(|| {
ImError::Parse(format!(
"{command}: channels 必须是非空 canonical object 数组"
))
})?;
let mut seen = std::collections::HashSet::with_capacity(channels.len());
for channel in channels {
let object = channel
.as_object()
.ok_or_else(|| ImError::Parse(format!("{command}: channel 必须是 object")))?;
if object.len() != 2 || !object.contains_key("id") || !object.contains_key("isRoot") {
return Err(ImError::Parse(format!(
"{command}: channel 只能包含 id/isRoot canonical 字段"
)));
}
let id = object
.get("id")
.and_then(Value::as_str)
.filter(|id| !id.is_empty() && id.trim() == *id)
.ok_or_else(|| ImError::Parse(format!("{command}: id 必须是非空 canonical 字符串")))?;
if crate::state::ChannelId::from_str(id).is_none() {
return Err(ImError::Parse(format!(
"{command}: 非 canonical channel id: {id}"
)));
}
if object.get("isRoot").and_then(Value::as_bool) != Some(true) {
return Err(ImError::Parse(format!("{command}: isRoot 必须为 true")));
}
if !seen.insert(id) {
return Err(ImError::Parse(format!("{command}: channel id 重复: {id}")));
}
}
Ok(())
}
fn copy_setting(
settings: &Map<String, Value>,
target: &mut Map<String, Value>,
keys: &[&str],
target_key: &str,
) {
if let Some(value) = keys.iter().find_map(|key| settings.get(*key)).cloned() {
target.insert(target_key.to_string(), value);
}
}
fn camel_to_snake(value: &str) -> String {
let mut normalized = String::with_capacity(value.len());
for ch in value.chars() {
if ch.is_ascii_uppercase() {
normalized.push('_');
normalized.push(ch.to_ascii_lowercase());
} else {
normalized.push(ch);
}
}
normalized
}
fn move_key(obj: &mut Map<String, Value>, from: &str, to: &str) {
if !obj.contains_key(to) {
if let Some(value) = obj.remove(from) {
obj.insert(to.to_string(), value);
}
} else {
obj.remove(from);
}
}
#[cfg(test)]
mod tests {
use super::*;
use helix_core::effect::Effect;
use helix_core::Correlation;
fn test_channel_id(index: u64) -> String {
format!("chfixx{index:020x}")
}
fn test_post_id(index: u64) -> String {
format!("srvfix{index:020x}")
}
#[test]
fn post_read_public_intent_normalizes_post_ids_to_posts() {
let channel_id = test_channel_id(1);
let post_ids = json!([test_post_id(1), test_post_id(2)]);
let normalized = normalize_command_payload(
"im_post_read",
serde_json::to_vec(&json!({"channelId": channel_id, "postIds": post_ids}))
.unwrap()
.as_slice(),
)
.expect("normalize post read");
let value: Value = serde_json::from_slice(&normalized).expect("decode post read");
assert_eq!(value["channel_id"], test_channel_id(1));
assert_eq!(value["posts"], json!([test_post_id(1), test_post_id(2)]));
assert!(value.get("post_ids").is_none());
}
#[test]
fn channels_view_public_intent_normalizes_channel_ids_to_objects() {
let normalized = normalize_command_payload(
"im_channels_view",
br#"{"channelIds":["chfixx0000000000000000001d"," chfixx0000000000000000001d ","chfixx0000000000000000001e"]}"#,
)
.expect("normalize channels view");
let value: Value = serde_json::from_slice(&normalized).expect("decode channels view");
assert_eq!(
value["channels"],
json!([
{"id":"chfixx0000000000000000001d","isRoot":true},
{"id":"chfixx0000000000000000001e","isRoot":true}
])
);
assert!(value.get("channel_ids").is_none());
}
#[test]
fn post_read_public_intent_reaches_go_posts_wire() {
let channel_id = test_channel_id(1);
let post_ids = json!([test_post_id(1), test_post_id(2)]);
let command = build_command(
"im_post_read",
serde_json::to_vec(&json!({"channelId": channel_id, "postIds": post_ids}))
.unwrap()
.as_slice(),
)
.expect("build post read command");
let effects = crate::outbound::handle_outbound(
command.name.as_ref(),
command.payload.as_ref(),
"http://im/api/cses",
"http://default",
Some("conn-1"),
Correlation::from_raw(1),
)
.expect("dispatch post read");
match &effects[0] {
Effect::Http { req, .. } => {
let body: Value =
serde_json::from_slice(req.body.as_ref().expect("post read body").as_ref())
.expect("decode post read body");
assert_eq!(req.url, "http://im/api/cses/post/read");
assert_eq!(
body,
json!({"channelId":test_channel_id(1),"posts":[test_post_id(1),test_post_id(2)]})
);
}
other => panic!("expected Http, got {other:?}"),
}
}
#[test]
fn post_read_public_intent_accepts_java_post_id() {
let command = build_command(
"im_post_read",
br#"{"channelId":"chfixx0000000000000000001d","postIds":["6a80e31f9aac148da3e2c219"]}"#,
)
.expect("build Java post read command");
let effects = crate::outbound::handle_outbound(
command.name.as_ref(),
command.payload.as_ref(),
"http://im/api/cses",
"http://default",
Some("conn-1"),
Correlation::from_raw(11),
)
.expect("dispatch Java post read");
match &effects[0] {
Effect::Http { req, .. } => {
let body: Value =
serde_json::from_slice(req.body.as_ref().expect("post read body").as_ref())
.expect("decode post read body");
assert_eq!(body["posts"], json!(["6a80e31f9aac148da3e2c219"]));
}
other => panic!("expected Http, got {other:?}"),
}
}
#[test]
fn post_read_public_intent_rejects_noncanonical_or_interval_payloads() {
let channel_id = test_channel_id(1);
let cases = [
json!({"channelId": channel_id.clone(), "postIds": []}),
json!({"channelId": channel_id.clone(), "postIds": ["helix_tmp_1"]}),
json!({"channelId": channel_id.clone(), "postIds": [1]}),
json!({"channelId": "temporary-channel", "postIds": [test_post_id(1)]}),
json!({"channelId": channel_id, "startTime": 1, "endTime": 2}),
];
for payload in cases {
let encoded = serde_json::to_vec(&payload).expect("encode case");
assert!(
build_command("im_post_read", &encoded).is_err(),
"invalid public payload must fail: {payload}"
);
}
}
#[test]
fn channels_view_public_intent_reaches_go_channels_wire() {
let command = build_command(
"im_channels_view",
br#"{"channelIds":["chfixx0000000000000000001d","chfixx0000000000000000001e"]}"#,
)
.expect("build channels view command");
let effects = crate::outbound::handle_outbound(
command.name.as_ref(),
command.payload.as_ref(),
"http://im/api/cses",
"http://default",
Some("conn-1"),
Correlation::from_raw(2),
)
.expect("dispatch channels view");
match &effects[0] {
Effect::Http { req, .. } => {
let body: Value =
serde_json::from_slice(req.body.as_ref().expect("channels view body").as_ref())
.expect("decode channels view body");
assert_eq!(req.url, "http://im/api/cses/channels/view");
assert_eq!(
body,
json!({"channels":[
{"id":"chfixx0000000000000000001d","isRoot":true},
{"id":"chfixx0000000000000000001e","isRoot":true}
]})
);
}
other => panic!("expected Http, got {other:?}"),
}
}
#[test]
fn channels_view_public_intent_rejects_noncanonical_channel_ids() {
assert!(normalize_command_payload(
"im_channels_view",
br#"{"channelIds":["temporary-channel"]}"#,
)
.is_err());
}
#[test]
fn channels_view_public_intent_rejects_duplicate_or_incomplete_channel_objects() {
let duplicate = br#"{"channels":[{"id":"chfixx0000000000000000001d","isRoot":true},{"id":"chfixx0000000000000000001d","isRoot":true}]}"#;
assert!(normalize_command_payload("im_channels_view", duplicate).is_err());
let incomplete = br#"{"channels":[{"id":"chfixx0000000000000000001d"}]}"#;
assert!(normalize_command_payload("im_channels_view", incomplete).is_err());
let mixed = br#"{"channelIds":["chfixx0000000000000000001d"],"channels":[{"id":"chfixx0000000000000000001e","isRoot":true}]}"#;
assert!(normalize_command_payload("im_channels_view", mixed).is_err());
}
}