use crate::message_summary::simple_message;
use helix_core::effect::{SqlValue, StorageOp, UpsertSpec};
use serde_json::{json, Map, Value};
pub const MAX_MESSAGE_CHARS: usize = 2000;
const ALL_SENTINEL: &str = "all";
const TEMPLATE_INNER_TEXT: &str = "TEXT";
const DOCUMENT_PROPS_VERSION: u64 = 1;
pub struct UserIdentity<'a> {
pub user_id: &'a str,
pub team_id: &'a str,
pub user_name: &'a str,
pub org_name: &'a str,
pub dept_name: &'a str,
}
pub struct RepliedRef<'a> {
pub id: &'a str,
pub reply_id: &'a str,
pub reply_root_id: &'a str,
pub reply_first_level_id: &'a str,
pub snapshot: Option<RepliedSnapshot>,
}
pub struct RepliedSnapshot {
pub replied_user_id: String,
pub replied_user_name: String,
pub message: String,
pub simple_message: String,
pub is_revoke: bool,
pub msg_type: String,
pub props: Value,
pub viewers: Vec<String>,
}
pub struct BuildInput<'a> {
pub channel_id: &'a str,
pub temporary_id: &'a str,
pub msg_type: &'a str,
pub post_id: &'a str,
pub text: &'a str,
pub viewers: Vec<String>,
pub mentions: Value,
pub props: Value,
pub topic_id: &'a str,
pub replied: Option<RepliedRef<'a>>,
pub now_ms: u64,
pub identity: UserIdentity<'a>,
}
fn find_reply_root_id(replied: &RepliedRef<'_>) -> (String, String, String) {
let id = replied.id;
if replied.reply_id.is_empty() {
return (id.to_string(), id.to_string(), String::new());
}
if !replied.reply_first_level_id.is_empty() {
return (
id.to_string(),
replied.reply_root_id.to_string(),
replied.reply_first_level_id.to_string(),
);
}
(
id.to_string(),
replied.reply_root_id.to_string(),
id.to_string(),
)
}
fn truncate_chars(text: &str) -> String {
if text.chars().count() <= MAX_MESSAGE_CHARS {
return text.to_string();
}
text.chars().take(MAX_MESSAGE_CHARS).collect()
}
fn should_intercept_empty(text: &str, props: &Value) -> bool {
let has_valid_message = !text.trim().is_empty();
let has_valid_props = props.as_object().map(|m| !m.is_empty()).unwrap_or(false);
!(has_valid_message || has_valid_props)
}
fn canonical_message_type(msg_type: &str) -> &str {
match msg_type {
"rich" | "RICH" | "IMAGE" => "RICH",
"file" | "FILE" => "FILE",
"" => "TEXT",
other => other,
}
}
fn normalize_mentions(mentions: &Value) -> Value {
let Some(items) = mentions.as_array() else {
return json!([]);
};
let mut normalized: Vec<String> = Vec::with_capacity(items.len());
for item in items {
let Some(id) = item.as_str() else {
continue;
};
let id = id.trim();
if id.is_empty() {
continue;
}
if id == ALL_SENTINEL {
return json!([ALL_SENTINEL]);
}
if !normalized.iter().any(|existing| existing == id) {
normalized.push(id.to_string());
}
}
json!(normalized)
}
fn normalize_viewers(viewers: &[String]) -> Vec<String> {
let mut normalized: Vec<String> = Vec::with_capacity(viewers.len());
for viewer in viewers {
let viewer = viewer.trim();
if viewer.is_empty() {
continue;
}
if viewer == ALL_SENTINEL {
return vec![ALL_SENTINEL.to_string()];
}
if !normalized.iter().any(|existing| existing == viewer) {
normalized.push(viewer.to_string());
}
}
if normalized.is_empty() {
return vec![ALL_SENTINEL.to_string()];
}
normalized
}
fn normalize_props(msg_type: &str, message: &str, props: &Value) -> Value {
let mut normalized: Map<String, Value> = props.as_object().cloned().unwrap_or_default();
match msg_type {
"TEMPLATE" => {
let mut template = normalized
.get("template")
.and_then(Value::as_object)
.cloned()
.unwrap_or_default();
if !has_non_empty_str(&template, "type") {
template.insert("type".to_string(), json!(TEMPLATE_INNER_TEXT));
}
if !has_non_empty_str(&template, "text") {
template.insert("text".to_string(), json!(message));
}
normalized.insert("template".to_string(), Value::Object(template));
}
"DOCUMENT" => {
let mut document = normalized
.get("document")
.and_then(Value::as_object)
.cloned()
.unwrap_or_default();
if !document.get("version").is_some_and(Value::is_number) {
document.insert("version".to_string(), json!(DOCUMENT_PROPS_VERSION));
}
let has_content = document.contains_key("snapshot") || document.contains_key("url");
if !has_content {
document.insert(
"snapshot".to_string(),
json!({"type": "doc", "content": []}),
);
}
normalized.insert("document".to_string(), Value::Object(document));
}
_ => {}
}
Value::Object(normalized)
}
fn has_non_empty_str(object: &Map<String, Value>, key: &str) -> bool {
object
.get(key)
.and_then(Value::as_str)
.is_some_and(|value| !value.is_empty())
}
fn build_replied_message(snapshot: &RepliedSnapshot) -> Value {
let preview = if snapshot.msg_type != "TEXT" || snapshot.message.chars().count() > 30 {
snapshot.simple_message.as_str()
} else {
snapshot.message.as_str()
};
json!({
"repliedUserId": snapshot.replied_user_id,
"repliedUserName": snapshot.replied_user_name,
"message": preview,
"isRevoke": snapshot.is_revoke,
"type": snapshot.msg_type,
"props": snapshot.props,
"viewers": snapshot.viewers,
})
}
pub fn build_message_object(input: &BuildInput<'_>) -> Option<Value> {
if should_intercept_empty(input.text, &input.props) {
return None;
}
let message = truncate_chars(input.text);
let msg_type = canonical_message_type(input.msg_type);
let props = normalize_props(msg_type, &message, &input.props);
let simple_message = simple_message(msg_type, &message, &props);
let viewers: Vec<String> = if input.viewers.is_empty() {
vec![ALL_SENTINEL.to_string()]
} else {
input.viewers.clone()
};
let id = &input.identity;
let mut body: Map<String, Value> = Map::new();
body.insert("viewers".to_string(), json!(viewers));
body.insert("message".to_string(), json!(message));
body.insert("mentions".to_string(), normalize_mentions(&input.mentions));
body.insert("temporaryId".to_string(), json!(input.temporary_id));
body.insert("type".to_string(), json!(msg_type));
body.insert("simpleMessage".to_string(), json!(simple_message));
body.insert("channelId".to_string(), json!(input.channel_id));
body.insert("userId".to_string(), json!(id.user_id));
body.insert("teamId".to_string(), json!(id.team_id));
body.insert(
"userSnapshot".to_string(),
json!({
"orgName": id.org_name,
"deptName": id.dept_name,
"userName": id.user_name,
"userId": id.user_id,
"teamId": id.team_id,
}),
);
body.insert("id".to_string(), json!(input.post_id));
body.insert("props".to_string(), props);
body.insert("topicId".to_string(), json!(input.topic_id));
body.insert("revoke".to_string(), json!(false));
body.insert("createAt".to_string(), json!(input.now_ms));
if let Some(replied) = &input.replied {
let (reply_id, reply_root_id, reply_first_level_id) = find_reply_root_id(replied);
body.insert("replyId".to_string(), json!(reply_id));
body.insert("replyRootId".to_string(), json!(reply_root_id));
body.insert("replyFirstLevelId".to_string(), json!(reply_first_level_id));
if let Some(snapshot) = &replied.snapshot {
body.insert(
"repliedMessage".to_string(),
build_replied_message(snapshot),
);
}
}
Some(Value::Object(body))
}
pub fn optimistic_persist_op(temporary_id: &str, channel_id: &str, text: String) -> StorageOp {
StorageOp::BatchUpsert(UpsertSpec {
version_column: None,
update_guard: None,
table: "message",
rows: vec![vec![
(
"temporary_id".to_string(),
SqlValue::Text(temporary_id.to_string()),
),
(
"channel_id".to_string(),
SqlValue::Text(channel_id.to_string()),
),
("message".to_string(), SqlValue::Text(text)),
(
"send_status".to_string(),
SqlValue::Text("sending".to_string()),
),
]],
conflict_key: Some("temporary_id"),
exclude_from_update: Vec::new(),
})
}
pub fn build_from_command(
cmd: &Value,
channel_id: &str,
temporary_id: &str,
now_ms: u64,
identity: &UserIdentity<'_>,
) -> Option<Value> {
let text = cmd["text"].as_str().unwrap_or("");
let msg_type = cmd["type"].as_str().unwrap_or("TEXT");
let post_id = cmd["id"].as_str().unwrap_or("");
let viewers: Vec<String> = cmd["viewers"]
.as_array()
.map(|a| {
a.iter()
.filter_map(|v| v.as_str().map(str::to_string))
.collect()
})
.unwrap_or_default();
let viewers = normalize_viewers(&viewers);
let mentions = cmd.get("mentions").cloned().unwrap_or_else(|| json!([]));
let props = cmd.get("props").cloned().unwrap_or_else(|| json!({}));
let topic_id = cmd["topic_id"].as_str().unwrap_or("");
let replied = cmd.get("replied").and_then(|replied| {
Some(RepliedRef {
id: replied["id"].as_str()?,
reply_id: replied["reply_id"].as_str().unwrap_or(""),
reply_root_id: replied["reply_root_id"].as_str().unwrap_or(""),
reply_first_level_id: replied["reply_first_level_id"].as_str().unwrap_or(""),
snapshot: replied.get("snapshot").and_then(parse_replied_snapshot),
})
});
build_message_object(&BuildInput {
channel_id,
temporary_id,
msg_type,
post_id,
text,
viewers,
mentions,
props,
topic_id,
replied,
now_ms,
identity: UserIdentity {
user_id: identity.user_id,
team_id: identity.team_id,
user_name: identity.user_name,
org_name: identity.org_name,
dept_name: identity.dept_name,
},
})
}
fn parse_replied_snapshot(snapshot: &Value) -> Option<RepliedSnapshot> {
snapshot.as_object()?;
let replied_user_id = snapshot["replied_user_id"].as_str()?;
let replied_user_name = snapshot["replied_user_name"].as_str()?;
let message = snapshot["message"].as_str()?;
let is_revoke = snapshot["is_revoke"].as_bool()?;
let msg_type = snapshot["type"].as_str()?;
let props = snapshot.get("props").cloned().unwrap_or_else(|| json!({}));
let simple_message = snapshot["simple_message"]
.as_str()
.map(str::to_string)
.unwrap_or_else(|| simple_message(msg_type, message, &props));
Some(RepliedSnapshot {
replied_user_id: replied_user_id.to_string(),
replied_user_name: replied_user_name.to_string(),
message: message.to_string(),
simple_message,
is_revoke,
msg_type: msg_type.to_string(),
props,
viewers: snapshot["viewers"]
.as_array()
.map(|items| {
items
.iter()
.filter_map(Value::as_str)
.map(str::to_string)
.collect()
})
.unwrap_or_default(),
})
}