use serde_json::{json, Map, Value};
use crate::error::ImError;
use crate::outbound::registry::{require_str, OutboundCommand, OutboundRegistration};
pub const ANNOUNCEMENT_SAVE_PATH: &str = "post/announcement/save";
const SAVE_COMMAND: &str = "im_announcement_save";
const SAVE_KEYS: &[&str] = &[
"channel_id",
"announcement_id",
"content",
"self_id",
"team_id",
"user_name",
"org_name",
"dept_name",
"req_id",
];
const HELIX_OWNED_KEYS: &[&str] = &[
"temporary_id",
"create_at",
"simple_message",
"read_bits",
"message",
"type",
"viewers",
"topic_id",
"user_snapshot",
"post",
"props",
];
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SaveAnnouncementCommand {
pub channel_id: String,
pub announcement_id: String,
pub content: String,
pub self_id: String,
pub team_id: String,
pub user_name: String,
pub org_name: String,
pub dept_name: String,
}
impl SaveAnnouncementCommand {
pub fn parse(args: &Value) -> Result<Self, ImError> {
let obj = args
.as_object()
.ok_or_else(|| ImError::Parse(format!("{SAVE_COMMAND}: payload 必须是 JSON 对象")))?;
for key in obj.keys() {
if HELIX_OWNED_KEYS.contains(&key.as_str()) {
return Err(ImError::Parse(format!(
"{SAVE_COMMAND}: 字段 {key} 由 Helix 铸造,客户端不得提交(INV-01)"
)));
}
if !SAVE_KEYS.contains(&key.as_str()) {
return Err(ImError::Parse(format!("{SAVE_COMMAND}: 未知字段 {key}")));
}
}
Ok(Self {
channel_id: require_str(args, "channel_id", SAVE_COMMAND)?.to_string(),
announcement_id: optional_announcement_id(obj)?,
content: require_str(args, "content", SAVE_COMMAND)?.to_string(),
self_id: optional_str(obj, "self_id", SAVE_COMMAND)?,
team_id: optional_str(obj, "team_id", SAVE_COMMAND)?,
user_name: optional_str(obj, "user_name", SAVE_COMMAND)?,
org_name: optional_str(obj, "org_name", SAVE_COMMAND)?,
dept_name: optional_str(obj, "dept_name", SAVE_COMMAND)?,
})
}
pub fn temporary_id(&self) -> String {
let mut hash: u64 = 0xcbf2_9ce4_8422_2325;
for part in [
self.channel_id.as_str(),
self.announcement_id.as_str(),
self.content.as_str(),
] {
for byte in part.as_bytes().iter().chain(std::iter::once(&0x1fu8)) {
hash ^= *byte as u64;
hash = hash.wrapping_mul(0x0000_0100_0000_01b3);
}
}
format!("ann{hash:016x}")
}
pub fn wire_body(&self) -> Value {
json!({
"temporaryId": self.temporary_id(),
"channelId": self.channel_id,
"userId": self.self_id,
"teamId": self.team_id,
"topicId": "",
"type": "ANNOUNCEMENT",
"message": self.content,
"simpleMessage": self.content,
"readBits": "",
"viewers": ["all"],
"userSnapshot": {
"userId": self.self_id,
"teamId": self.team_id,
"userName": self.user_name,
"orgName": self.org_name,
"deptName": self.dept_name,
},
"props": {
"announcement": {
"announcementId": self.announcement_id,
"content": self.content,
}
},
})
}
}
fn optional_announcement_id(obj: &Map<String, Value>) -> Result<String, ImError> {
match obj.get("announcement_id") {
None | Some(Value::Null) => Ok(String::new()),
Some(Value::String(value)) => Ok(value.clone()),
Some(_) => Err(ImError::Parse(format!(
"{SAVE_COMMAND}: announcement_id 必须是字符串或 null"
))),
}
}
fn optional_str(obj: &Map<String, Value>, key: &str, cmd: &str) -> Result<String, ImError> {
match obj.get(key) {
None | Some(Value::Null) => Ok(String::new()),
Some(Value::String(value)) => Ok(value.clone()),
Some(_) => Err(ImError::Parse(format!("{cmd}: {key} 必须是字符串"))),
}
}
fn require_delete_keys(args: &Value, cmd: &str) -> Result<(), ImError> {
let object = args
.as_object()
.ok_or_else(|| ImError::Parse(format!("{cmd}: payload 必须是 object")))?;
if let Some(unknown) = object
.keys()
.find(|key| !matches!(key.as_str(), "channel_id" | "announcement_id" | "req_id"))
{
return Err(ImError::Parse(format!(
"{cmd}: 未知或非 canonical 字段 '{unknown}'"
)));
}
if let Some(req_id) = object.get("req_id") {
if req_id.as_str().is_none_or(str::is_empty) {
return Err(ImError::Parse(format!("{cmd}: req_id 必须是非空字符串")));
}
}
Ok(())
}
struct AnnouncementSaveCommand;
impl OutboundCommand for AnnouncementSaveCommand {
fn name(&self) -> &'static str {
SAVE_COMMAND
}
fn build(&self, args: &Value) -> Result<(&'static str, Value), ImError> {
let command = SaveAnnouncementCommand::parse(args)?;
Ok((ANNOUNCEMENT_SAVE_PATH, command.wire_body()))
}
}
static ANNOUNCEMENT_SAVE: AnnouncementSaveCommand = AnnouncementSaveCommand;
inventory::submit! {
OutboundRegistration {
name: "im_announcement_save",
command: &ANNOUNCEMENT_SAVE,
}
}
struct AnnouncementDeleteCommand;
impl OutboundCommand for AnnouncementDeleteCommand {
fn name(&self) -> &'static str {
"im_announcement_delete"
}
fn build(&self, args: &Value) -> Result<(&'static str, Value), ImError> {
require_delete_keys(args, self.name())?;
let channel_id = require_str(args, "channel_id", self.name())?;
let announcement_id = require_str(args, "announcement_id", self.name())?;
Ok((
"post/announcement/delete",
json!({ "channelId": channel_id, "announcementId": announcement_id }),
))
}
fn is_read(&self) -> bool {
true
}
}
static ANNOUNCEMENT_DELETE: AnnouncementDeleteCommand = AnnouncementDeleteCommand;
inventory::submit! {
OutboundRegistration {
name: "im_announcement_delete",
command: &ANNOUNCEMENT_DELETE,
}
}
struct AnnouncementReadCommand;
impl OutboundCommand for AnnouncementReadCommand {
fn name(&self) -> &'static str {
"im_announcement_read"
}
fn build(&self, args: &Value) -> Result<(&'static str, Value), ImError> {
let post_id = require_str(args, "post_id", self.name())?;
let channel_id = require_str(args, "channel_id", self.name())?;
Ok((
"post/announcement/read",
json!({ "postId": post_id, "channelId": channel_id }),
))
}
}
static ANNOUNCEMENT_READ: AnnouncementReadCommand = AnnouncementReadCommand;
inventory::submit! {
OutboundRegistration {
name: "im_announcement_read",
command: &ANNOUNCEMENT_READ,
}
}
#[cfg(test)]
mod tests {
use crate::outbound::handle_outbound;
use helix_core::effect::Effect;
use helix_core::Correlation;
use serde_json::{json, Value};
fn dispatch(name: &str, args: &Value) -> (String, Value) {
let corr = Correlation::from_raw(1);
let payload = serde_json::to_vec(args).unwrap();
let effects = handle_outbound(name, &payload, "http://h/api", "http://h", Some("c1"), corr)
.expect("should dispatch");
match &effects[0] {
Effect::Http { req, .. } => {
let body: Value =
serde_json::from_slice(req.body.as_ref().unwrap()).expect("body json");
(req.url.clone(), body)
}
other => panic!("expected Http, got {other:?}"),
}
}
#[test]
fn announcement_save_builds_full_post_body() {
let args = json!({
"channel_id": "c1",
"announcement_id": null,
"content": "公告内容",
"self_id": "u1",
"team_id": "t1",
});
let (url, body) = dispatch("im_announcement_save", &args);
assert_eq!(url, "http://h/api/post/announcement/save");
assert_eq!(body["type"], "ANNOUNCEMENT");
assert_eq!(body["channelId"], "c1");
assert_eq!(body["userId"], "u1");
assert_eq!(body["message"], "公告内容");
assert_eq!(body["simpleMessage"], "公告内容");
assert_eq!(body["readBits"], "");
assert_eq!(body["viewers"], json!(["all"]));
assert_eq!(body["props"]["announcement"]["announcementId"], "");
assert_eq!(body["props"]["announcement"]["content"], "公告内容");
assert!(body.get("channel_id").is_none());
assert!(body.get("self_id").is_none());
}
#[test]
fn announcement_save_rejects_client_minted_fields() {
let corr = Correlation::from_raw(1);
let payload = serde_json::to_vec(&json!({
"channel_id": "c1",
"content": "公告内容",
"temporary_id": "client-minted",
}))
.unwrap();
assert!(handle_outbound(
"im_announcement_save",
&payload,
"http://h/api",
"http://h",
None,
corr
)
.is_err());
}
#[test]
fn announcement_delete_builds_channel_announcement_body() {
let args = json!({
"channel_id": "c1",
"announcement_id": "a1",
"req_id": "rq-delete"
});
let (url, body) = dispatch("im_announcement_delete", &args);
assert_eq!(url, "http://h/api/post/announcement/delete");
assert_eq!(body, json!({ "channelId": "c1", "announcementId": "a1" }));
}
#[test]
fn announcement_delete_rejects_legacy_or_invalid_fields() {
let corr = Correlation::from_raw(1);
for args in [
json!({ "post_ids": ["p1"] }),
json!({ "channel_id": "c1" }),
json!({ "channel_id": "c1", "announcement_id": "a1", "user_id": "u1" }),
json!({ "channel_id": "c1", "announcement_id": "a1", "req_id": "" }),
] {
let payload = serde_json::to_vec(&args).unwrap();
assert!(
handle_outbound(
"im_announcement_delete",
&payload,
"http://h/api",
"http://h",
None,
corr
)
.is_err(),
"args={args}"
);
}
}
#[test]
fn announcement_read_body() {
let args = json!({ "post_id": "p1", "channel_id": "c1" });
let (url, body) = dispatch("im_announcement_read", &args);
assert_eq!(url, "http://h/api/post/announcement/read");
assert_eq!(body, json!({ "postId": "p1", "channelId": "c1" }));
}
#[test]
fn announcement_read_rejects_missing() {
let corr = Correlation::from_raw(1);
let payload = serde_json::to_vec(&json!({ "post_id": "p1" })).unwrap();
assert!(handle_outbound(
"im_announcement_read",
&payload,
"http://h/api",
"http://h",
None,
corr
)
.is_err());
}
}